Skip to content

feat!: Encly 3.0.0 on Kotlin Multiplatform for Android, desktop and iOS - #92

Open
pasichDev wants to merge 75 commits into
mainfrom
migrate-to-kmp
Open

pasichDev wants to merge 75 commits into
mainfrom
migrate-to-kmp

Conversation

@pasichDev

@pasichDev pasichDev commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

⏳ Draft: Encly 3.0.0: Android, desktop (Windows, macOS, Linux) and iOS on one Kotlin Multiplatform codebase, with the issue fixes and features below.

Before merge: CI green on the head; repoint the Weblate components to core/resources/src/commonMain/strings/values*/…; a manual pass on a test device of the new editor features and the PIN screens.

Before publishing desktop / iOS: signing secrets (desktopApp/README.md, "Signing the desktop installers"); Apple Developer account, App Store Connect record, export-compliance answer and a 1024 px icon (iosApp/README.md).

What

Encly moves to Kotlin Multiplatform and ships on every platform as 3.0.0.

Modules

Module Targets Contents
:shared Android, desktop, iOS screens, ViewModels, navigation, design system, domain, data (backup, import, export); the iOS host
:editor Android, desktop, iOS the block editor library: public API, ABI dump, Dokka, 100 % line and branch coverage of its logic
:core:database Android, desktop, iOS Room + SecureDatabaseManager on the Sealite driver (SQLCipher 4.20); schema 7
:core:security Android, desktop, iOS vault, PIN / duress / wipe / recovery slots, session lock, backups
:core:crypto Android, desktop, iOS one crypto API: JCA on the JVM, CommonCrypto and CryptoKit on iOS; own BIP-39; 100 % coverage
:core:keystore desktop, iOS PIN hardware factor: DPAPI, Keychain, Secret Service (encrypted session); Secure Enclave per guess on iOS
:core:common, :core:resources Android, desktop, iOS platform interfaces, wiping; strings of the ten languages
:app Android activities and Android adapters only
:desktopApp desktop windows, menu bar, PDF and print, installers (MSI, DMG, DEB, Flatpak)
iosApp/ iOS the SwiftUI shell around the shared framework

Replaced: sqlcipher-android → Sealite, Hilt → Metro, Gson → kotlinx.serialization, kotlin-bip39 → own encoding, java.time / UUID → kotlinx-datetime / kotlin.uuid, java.io / java.util.zip → kotlinx-io, R.string → Compose resources (translations stay in Android format for Weblate).

Issues closed by this release: #83 (selection across blocks, block selection), #85 / #106 (desktop), #93 (settings in backups), #94 (screenshots until the app locks), #95 (PIN of 6–12 digits, keep the PIN after a recovery unlock), #97 (trash empties after 28 days), #98 (5 and 10 minute auto-lock), #103 (creation date), #104 (biometric unlock after a background lock), #105 (Arabic and RTL, hide previews, what's new), #107 (iOS), and the open items of #76 (nested lists, drag to reorder, tables, code blocks, custom toolbar, checklist lock button).

From the code review: the duress vault no longer sees the real vault's tag; a lost flags file can no longer lead to onboarding over an existing vault; a lock saves the open note first; the editor forgets its undo text on a lock; link cards refuse hosts browsers resolve differently; a biometric answer after an activity stop is kept; F-Droid reproducibility is checked in CI.

Why

Desktop (#85, #54) and iOS need the logic and UI in common code, and Room's multiplatform API needs a driver sqlcipher-android does not provide. The issues above were reported by users of 2.3.x.

Behaviour changes

  • Vaults, recovery phrases, backups and notes from 2.3.x open unchanged (golden vectors, an upgrade test over a real 2.3.1 vault on an emulator with release builds).
  • Database schema 6 → 7 (trashedAt); notes already in the trash start their 28 days with the update.
  • Backup payload schema 4 → 5 (optional settings); older apps answer "update the app" for a 3.0.0 backup.
  • The lock screen has an Unlock button: the PIN length is stored nowhere.
  • A note with a table or a code block opens read-only in versions before 3.0.0.
  • A backspace at the start of a list item joins it into the item above.

Testing

CI runs fourteen jobs: unit and host tests of every module, Kover gates, detekt, ABI dumps, Dokka, R8 release builds and lint, instrumented tests on API 26 and 35 (Room migrations, a vault written by 2.3), iOS tests on the simulator and an unsigned xcodebuild, the desktop installers and a Flatpak bundle, the PIN factor against the real DPAPI, Keychain and Secret Service, and an F-Droid reproducibility check (two builds in different directories).

Manually: upgrade from 2.3.1 to the new build (release, minified, same signing key) on an emulator — PIN unlock, notes and archive intact, what's new shown, settings working.

Not verified: the new editor features and PIN screens by hand on a device; desktop installers on real Windows, macOS and Linux machines (clipboard history, sleep, Secret Service with KWallet/KeePassXC, the logind live lock state); iOS on a device (Secure Enclave, Face ID, keyboard, clipboard).

References

#76, #83, #85, #93, #94, #95, #97, #98, #103, #104, #105, #106, #107

Room uses the androidx.sqlite driver API (SealiteDriver) instead of sqlcipher-android's open-helper factory. The key is the same raw DEK, so vaults written by earlier releases open unchanged; EarlierReleaseVaultTest writes one with sqlcipher-android 4.19 and opens it with the driver. Migrations, the trigger callback and backup transactions use SQLiteConnection.
… and UUID

Stored timestamps stay epoch milliseconds. Localized formatting (month names, field order, 12/24 h) stays in one platform function, LocalizedDate. The importer's date parser accepts the same inputs as before (ImportTimeTest pins 149 of them).
…stead of Gson

The stored JSON is unchanged byte for byte (BlockJsonFormatTest pins every block kind, Gson's HTML-safe escapes included), and reading stays as lenient as before.
Encly only turns 128 random bits into 12 words and checks phrases; it never derives a BIP-39 seed, so that part is not implemented. Bip39GoldenTest runs the official trezor vectors and pins the keys Encly derives from a phrase; Bip39WordlistTest pins the wordlist's SHA-256.
The archive readers, the Markdown export and the My Notes hand-off read and write kotlinx-io Sources and Sinks; java.util.zip stays behind small zip interfaces in one file (JvmZip). Every limit and check is kept: unsafe paths, entry count, entry and total sizes, zip bombs, CRCs, strict UTF-8. The Markdown export is pinned byte for byte.
AES-256-GCM, HKDF-SHA-256, PBKDF2, SHA-256 and the CSPRNG go through core/crypto/Crypto; JcaCrypto is the only file behind it that imports javax.crypto or java.security, so another platform replaces one file. Hygiene stays as before: a fresh Cipher and Mac per call, the PIN reaches PBKDF2 as characters and is cleared, HKDF wipes its PRK. CryptoGoldenTest pins every slot and the backup format, unchanged from the previous code.
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Coverage

Overall Project 84.61% -6.21% 🍏
Files changed 90.78% 🍏

File Coverage
HandlerMainThreadTimer.kt 100% 🍏
SharedPreferencesKeyValueStore.kt 100% 🍏
AppGraph.kt 100% 🍏
CacheHandoffStagingSweeper.kt 100% 🍏
AndroidDeviceCapabilities.kt 100% 🍏
SystemLockoutClock.kt 100% 🍏
ActivityBiometricHost.kt 100% 🍏
LocalDataModule.kt 100% 🍏
AndroidDocumentPickers.kt 99.08% -0.92% 🍏
PendingDek.kt 97.33% -2.67% 🍏
AppLocales.kt 97.18% 🍏
SystemDeviceLockWatcher.kt 96.92% -3.08% 🍏
AndroidHostServices.kt 96.57% -3.43% 🍏
AndroidDocuments.kt 95.85% -4.15% 🍏
MyApplication.kt 93.55% 🍏
KeystorePinFactor.kt 92.4% -7.6% 🍏
SensitiveClip.kt 90.26% 🍏
MainActivity.kt 88.25% 🍏
BiometricTexts.kt 83.03% -16.97% 🍏
SecureTextInputBoundary.kt 78.68% 🍏
ImportFromMyNotesActivity.kt 30.07% -22.42% 🍏

One compile-time AppGraph: singletons are @singlein(AppScope), the former modules are binding containers contributed to it, ViewModels are contributed into the Metro ViewModel factory (assisted for the three that take a SavedStateHandle), and the Activities inject themselves from the one graph in onCreate. Optional bindings are disabled, so a missing binding fails the build instead of falling back to a default. AppGraphTest checks that every security singleton is a single instance and that every ViewModel can be created.
Everything the editor needs from the app now comes through its own extension points (EditorStrings, EditorTheme, EditorClipboard, LinkOpener, EditorLogger, EditorController), so com.pasich.encly.editor imports nothing from the app; EditorBoundaryTest keeps it that way. The package is gated at 100% line and branch coverage. Backspace in an empty link address now removes the block from a hardware keyboard too (onPreviewKeyEvent, like the other fields). Stored note JSON is unchanged.
…layers

Domain, repositories, backup, import, export, hand-off and the security core no longer import android.* or java.*: SharedPreferences, the main-thread timer, biometrics, the Keystore PIN factor, device lock, app strings and the vault file I/O sit behind small interfaces with one Android implementation each. The vault slot file keeps its DataOutputStream format byte for byte (VaultStoreLegacyFormatTest) and its fsync and atomic replace (JvmVaultFileIo). PlatformBoundaryTest keeps the layers platform-free.
…ion plugins

build-logic holds the convention plugins (KMP library, Compose Multiplatform, quality, spotless). :editor targets Android, desktop JVM and iOS; its sources moved unchanged into commonMain, with one expect/actual for IDN hosts. It keeps 100% line and branch coverage, a checked ABI dump and Dokka docs; CI compiles the iOS targets on macOS.
:core:database holds the Room database, its entities, DAOs, migrations and SecureDatabaseManager for Android, desktop and iOS. The platform gives only the file location and the builder (DatabasePlatform); on Android the files stay where they are (database.db, vault_b.db). Desktop tests open, migrate and reject vaults encrypted with Sealite's JVM natives; the instrumented tests run from the module.
:core:common (platform interfaces, UiText, wiping), :core:crypto (the Crypto API with JCA behind it for Android and desktop, BIP-39) and :core:security (vault, PIN and recovery slots, session lock, backups). Locks move to atomicfu with the same critical sections (the same monitor bytecode on the JVM). Crypto and security have no iOS target yet: public CommonCrypto has no AES-GCM, and no weaker stand-in is shipped. :core:crypto is gated at 100 % line and branch coverage.
@pasichDev pasichDev self-assigned this Oct 29, 2026
:core:resources holds every string and plural of the nine languages. The translation files stay in Android format, byte for byte, so Weblate keeps working; a build step converts them into Compose resources (escapes, bare %d and %% included), and AndroidStringsConversionTest checks every string and plural against the Android values. The in-app language follows through the process locale. Biometric prompts get their texts as plain strings, loaded before the first prompt.
:core:keystore gives desktop the same PIN hardware factor contract as Android: HMAC-SHA256 over salt and PIN with a random 32-byte secret that lives only in the OS store of the user (DPAPI on Windows, the login Keychain on macOS, the Secret Service on Linux), one per key slot. A missing or unreadable secret behaves like a lost Keystore key. SECURITY.md describes the weaker, software-bound threat model. The keystore-it job runs the real backends on Windows, macOS and Linux.
…orm module

:shared holds the screens, ViewModels, navigation, design system, domain and platform-free data code for Android and desktop. :app keeps the activities and the Android adapters behind small platform interfaces (documents and pickers, links, toasts, sensitive clipboard, window security, biometric host, locale, app info). SharedBoundaryTest keeps Android and JVM APIs out of the common code. No test was dropped (1314 -> 1362).
@pasichDev pasichDev changed the title refactor: prepare Encly for Kotlin Multiplatform (Sealite driver, platform-free core) refactor: move Encly to Kotlin Multiplatform (shared UI and core modules, desktop key store) Oct 29, 2026
:desktopApp runs the shared UI in a Compose Desktop window with its own Metro graph: the vault in an owner-only app-data folder, the PIN factor in the OS secret store, native file dialogs (XDG portal on Linux), a native menu bar with shortcuts, drag and drop of backups and imports, remembered window state, a single instance per user. Security parity: capture exclusion where the OS allows it, lock on OS screen lock, minimise and idle, clipboard wiped after 60 s. Installers: MSI, DMG and DEB, built unsigned in CI.
Hand cursor and a keyboard-only focus ring on every interactive element, tooltips on hover as well as on long-press, context menus from a right click, the Menu key or Shift+F10 on notes, tasks and editor blocks (never from a finger), themed scrollbars on desktop, and app shortcuts with the platform's modifier (new note, find, lock, tabs, delete, Esc). Touch behaviour on Android is unchanged; the editor keeps 100 % coverage.
Compact windows keep today's phone UI exactly. Medium windows get a navigation rail and centred content; expanded windows a permanent drawer, the notes list beside the editor and tabs for open notes (#85). Sheets become centred dialogs on wide windows, and a tighter spacing set applies on pointer-first desktops. Relock clears every pane and tab before the lock screen shows, and reopens the active note as a tab after unlock.
The keyboard host at the app root and the menu bar share one key map and
one router, so every chord has a single owner. Note cards get a context
menu and a current-note highlight on pointer devices, tabs close on
middle-click and open in the background on Ctrl/Cmd+click, and the long
pages and lists show scrollbars. The database vault and the view model
factory move into the shared module, and the menu titles are translated.
The Linux packaging job also builds a Flatpak from the app image, with no
network access and only the bus names the key store and the lock watcher
use. The macOS DMG is signed, notarized and stapled and the Windows MSI is
signed only when their secrets exist and the run is not a pull request;
otherwise the installers stay unsigned as before.
Tables with Tab, Enter and arrow navigation and a cell menu, written to
Markdown as pipe tables; code blocks in a monospace font without
corrections, toggled with the < > button; and Settings → Appearance →
Editor toolbar to choose and order the buttons of the bar and its More
menu, kept in backups. A copy of a note whose content could not be read
keeps the stored text instead of an empty one. The store listings say
the PIN can have 6 to 12 digits.

Refs: #76
The password unlock method is removed again: issue #95 asked to keep
the PIN after a recovery-phrase unlock, not for a second secret. The PIN
of 6 to 12 digits and "Keep my PIN" stay; a vault with a leftover
password entry from a development build opens with its PIN. On Linux an
unlock was refused while a screen locker had left the lock flag set;
the state is now read live from logind or the screen saver, and a
refused unlock shows a message. A failed PIN derivation wipes its
intermediate keys.

Refs: #95
…ked checklists stay locked

Cut put escaped Markdown on the clipboard and paste never read it back,
so a cut and paste changed the text and turned tables into lines of
pipes. The editor now keeps what it copied and pastes those blocks back
when the clipboard still holds them. Typing in a long list or a large
table redraws only the row or cell being edited, a selection that
touches a locked checklist leaves it unchanged, dragging into or out of
a table follows the selection rules, and empty tables and code blocks
are kept.

Refs: #76
@pasichDev pasichDev changed the title refactor: move Encly to Kotlin Multiplatform (Android, desktop and iOS on shared code) feat!: Encly 3.0.0 on Kotlin Multiplatform for Android, desktop and iOS Nov 7, 2026
Notes can be sorted by title A to Z or Z to A, compared in the order of
the app's language, with untitled notes last. The unlock reveal takes
about half a second instead of one and is skipped when the system has
animations turned off.
…int prompt in onboarding

The editor's fade during the open and close transition rendered its
content into an offscreen surface of a new size on every frame, and the
GPU kept them all: on a Pixel 7a graphics memory grew from 65 MB to
about 200 MB after a few notes. The fade now modulates the alpha of
each draw instead, and graphics memory stays at about 80 MB. In
onboarding the fingerprint prompt runs through the same guard as on the
lock screen, a cancel stays a cancel, and a button offers it again.
…r database connections

Screens faded in and out through a full-window offscreen layer each;
they now modulate the alpha of each draw, like the note transition. The
database runs its queries on at most two threads, so the pool holds two
readers and the writer instead of four and the writer, and each
connection keeps a 1 MiB page cache instead of 2 MiB. The editor reuses
the theme's fonts instead of loading its own set. On a Pixel 7a the app
uses about 140 MB after opening notes, down from 155 MB, with 58 MB of
graphics memory instead of 80 MB.
The tasks screen read open and done tasks through two queries combined
into one state, so a task that was just checked or unchecked could sit
in both lists for a moment and the list crashed on the repeated key. A
hidden tag's view did the same with its live and archived notes. Each
list now comes from one query.
note_tags (noteId, tagId) replaces notes.tagId in schema 8. Migration 7 -> 8
copies every note's tag into it, drops tags that point at no tag, and rebuilds
notes without the column, keeping its index, uid triggers and id counter.

Refs: #109
The backup payload goes to schema 6: a note lists its tags as tagUids. Files of
schema 1 to 5 are read with the note's one tag as the list. Importers keep all
of a note's tags instead of the first, Markdown front matter lists them all
(a quoted tag with a comma reads back whole), and the PDF names them.

Refs: #109
A tag's view lists every note with that tag, in one query. A note with any
hidden tag stays out of every list but a hidden tag's view. The list sheet puts
on or takes off one tag at a time, and the editor saves only the tags it
changed, so a tag changed elsewhere meanwhile stays. Tag counts come from one
query.

Refs: #109
The note sheet's tag chips and the editor's tag sheet toggle each tag and stay
open; No tag clears them. A card names the first two tags and counts the rest,
and the editor's overline shows every tag.

Refs: #109

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant