From 26eea20b7ed8db91882cb03940665a1fbc7083bf Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:49:47 +0200 Subject: [PATCH 01/75] feat(database): open the vault through the Sealite driver 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. --- app/build.gradle.kts | 10 +- app/proguard-rules.pro | 2 +- .../data/database/AppDatabaseSchemaTest.kt | 169 ++++++++++-------- .../data/database/EarlierReleaseVaultTest.kt | 80 +++++++++ .../SecureDatabaseManagerInstrumentedTest.kt | 2 +- .../pasich/encly/data/database/TagsDaoTest.kt | 3 +- .../encly/data/backup/RoomVaultDataStore.kt | 35 ++-- .../data/database/SecureDatabaseManager.kt | 136 ++++---------- .../pasich/encly/data/database/VaultSchema.kt | 62 ++++--- .../presentation/screen/LicensesScreen.kt | 4 +- .../database/SecureDatabaseManagerTest.kt | 23 +-- .../encly/data/database/VaultMigrationTest.kt | 23 ++- gradle/libs.versions.toml | 4 +- 13 files changed, 304 insertions(+), 249 deletions(-) create mode 100644 app/src/androidTest/java/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1908888f..571b6076 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,9 +329,8 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) - // Cipher - implementation(libs.sqlcipher.android) - implementation(libs.androidx.sqlite.ktx) + // Cipher: SQLCipher through the androidx.sqlite driver API + implementation(libs.sealite) implementation(libs.kotlin.bip39) implementation(libs.icons.lucide) @@ -346,6 +345,8 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) // Compose UI tests run on the JVM under Robolectric (src/test/**/ui), no emulator needed. testImplementation(libs.robolectric) + // Room's migrations run against the platform SQLite under Robolectric. + testImplementation(libs.androidx.sqlite.framework) testImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) @@ -354,4 +355,7 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.sqlite.framework) + // Writes a vault the way earlier releases did, to check that the current driver opens it. + androidTestImplementation(libs.sqlcipher.android) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e03cb38d..18be4e6b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -2,7 +2,7 @@ # # 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 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 index 5d0869c0..65c7c8e1 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt +++ b/app/src/androidTest/java/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt @@ -1,14 +1,19 @@ package com.pasich.encly.data.database import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.SQLiteConnection +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. @@ -17,26 +22,39 @@ import org.junit.runner.RunWith * 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. + * 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( - InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java, + 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(TEST_DB, DB_VERSION).close() - helper.runMigrationsAndValidate(TEST_DB, DB_VERSION, true).close() + helper.createDatabase(DB_VERSION).close() + helper.runMigrationsAndValidate(DB_VERSION, emptyList()).close() } @Test fun migrate2To3DropsReminderAndKeepsTasks() { - helper.createDatabase(TEST_DB, 2).use { db -> + helper.createDatabase(2).use { db -> db.execSQL( "INSERT INTO tasks (id, title, description, isCompleted, createdDate, completedDate, " + "reminderDate, priority, categoryId, position, uid) " + @@ -44,96 +62,99 @@ class AppDatabaseSchemaTest { ) } - 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)) + 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.query("PRAGMA table_info(tasks)").use { c -> - val nameIndex = c.getColumnIndexOrThrow("name") - while (c.moveToNext()) assertNotEquals("reminderDate", c.getString(nameIndex)) + 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.query("SELECT uid FROM tasks WHERE title = 'x'").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(32, c.getString(0).length) + 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(TEST_DB, 3).use { db -> + 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(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)) + 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.query("SELECT COUNT(*) FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) + 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.query("SELECT uid FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - c.getString(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.query("SELECT uid FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(uid, c.getString(0)) + 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.query("SELECT COUNT(*) FROM subtasks").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) + db.prepare("SELECT COUNT(*) FROM subtasks").use { s -> + assertTrue(s.step()) + assertEquals(0, s.getLong(0).toInt()) } } } @Test fun migrate4To5KeepsNotesUnpinnedUnarchivedUncoloured() { - helper.createDatabase(TEST_DB, 4).use { db -> + 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(TEST_DB, 5, true, VaultSchema.MIGRATION_4_5).use { db -> - db.query( + 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 { 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)) + ).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. @@ -141,19 +162,19 @@ class AppDatabaseSchemaTest { "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) + 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(TEST_DB, 5).use { db -> + 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, " + @@ -161,31 +182,31 @@ class AppDatabaseSchemaTest { ) } - 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)) + 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 migrate1ToCurrentRunsTheWholeChain() { - helper.createDatabase(TEST_DB, 1).close() + helper.createDatabase(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, + listOf( + VaultSchema.MIGRATION_1_2, + VaultSchema.MIGRATION_2_3, + VaultSchema.MIGRATION_3_4, + VaultSchema.MIGRATION_4_5, + VaultSchema.MIGRATION_5_6, + ), ).close() } diff --git a/app/src/androidTest/java/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/EarlierReleaseVaultTest.kt new file mode 100644 index 00000000..237a3d1a --- /dev/null +++ b/app/src/androidTest/java/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(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/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt index 44fab51f..13d209ce 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt +++ b/app/src/androidTest/java/com/pasich/encly/data/database/SecureDatabaseManagerInstrumentedTest.kt @@ -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/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt b/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt index 180b68b5..e011bf4e 100644 --- a/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt +++ b/app/src/androidTest/java/com/pasich/encly/data/database/TagsDaoTest.kt @@ -1,6 +1,7 @@ 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 @@ -25,7 +26,7 @@ class TagsDaoTest { InstrumentationRegistry.getInstrumentation().targetContext, AppDatabase::class.java, ), - ).build() + ).setDriver(AndroidSQLiteDriver()).build() } @After diff --git a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt index d8c9c596..cf441d07 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -1,6 +1,9 @@ package com.pasich.encly.data.backup -import androidx.room.withTransaction +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 @@ -26,26 +29,32 @@ class RoomVaultDataStore private constructor( 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.withTransaction { - VaultSnapshot( - notes = db.backupDao().allNotes(), - tags = db.backupDao().allTags(), - tasks = db.backupDao().allTasks(), - subtasks = db.backupDao().allSubtasks(), - ) + db.useReaderConnection { + it.deferredTransaction { + 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()) + db.useReaderConnection { + it.deferredTransaction { + 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 inTransaction(block: suspend () -> R): R = + database().useWriterConnection { it.immediateTransaction { block() } } override suspend fun deleteAll() { val dao = database().backupDao() diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt index fb735f05..f732371c 100644 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -1,15 +1,15 @@ package com.pasich.encly.data.database import android.content.Context +import androidx.room.useWriterConnection 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 io.github.pasichdev.sealite.SealiteDriver +import io.github.pasichdev.sealite.SealiteKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import java.io.File import java.security.MessageDigest import javax.inject.Inject @@ -39,12 +39,14 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat 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. + * 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 roomPassphrase: ByteArray? = null + private var driver: SealiteDriver? = null + + /** A copy of the open database's key, only to check a repeated [unlockDatabase]; wiped in [reset]. */ + private var openKey: ByteArray? = null fun isDatabaseUnlocked(): Boolean = isUnlocked @@ -62,9 +64,10 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat /** * 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. + * 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. @@ -78,17 +81,12 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat @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) - } + return openFile == file && openKey?.let { MessageDigest.isEqual(it, dek) } == true } - ensureNativeLoaded() var openedDatabase: AppDatabase? = null + var openedDriver: SealiteDriver? = null return try { when (SQLCipherUtils.getDatabaseState(context, file.fileName)) { SQLCipherUtils.State.DOES_NOT_EXIST -> if (!allowCreate) return false @@ -101,33 +99,40 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat // No destructive fallback: a schema change without a migration must fail to open // (and be caught in tests), never drop the user's notes. + // 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 opened = VaultSchema.databaseBuilder(context.applicationContext, file.fileName) - .openHelperFactory(SupportOpenHelperFactory(passphrase, VaultConnectionHook, false)) + .setDriver(newDriver) + .setQueryCoroutineContext(Dispatchers.IO) .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 + runBlocking { opened.useWriterConnection { } } database = opened - roomPassphrase = passphrase + driver = newDriver + openKey = dek.copyOf() isUnlocked = true openFile = file lease = DatabaseLease() true } catch (e: Exception) { openedDatabase?.close() + openedDriver?.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) } } @@ -148,10 +153,13 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat lease = null database?.close() database = null + // After the database: closing the driver wipes its copy of the key. + driver?.close() + driver = null isUnlocked = false openFile = null - roomPassphrase?.let(SensitiveDataCleaner::clear) - roomPassphrase = null + openKey?.let(SensitiveDataCleaner::clear) + openKey = null } /** Closes and deletes both databases: the real vault's and the decoy vault's. */ @@ -179,75 +187,5 @@ class SecureDatabaseManager @Inject constructor(@param:ApplicationContext privat 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 index cd398ce7..f5fecaa3 100644 --- a/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt +++ b/app/src/main/java/com/pasich/encly/data/database/VaultSchema.kt @@ -4,7 +4,8 @@ import android.content.Context import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL /** * Schema pieces Room cannot express in entity annotations. @@ -46,21 +47,24 @@ object VaultSchema { "UPDATE `$table` SET uid = OLD.uid WHERE id = NEW.id; END", ) - fun createTriggers(db: SupportSQLiteDatabase) { - TABLES.flatMap(::triggers).forEach(db::execSQL) + /** The uid triggers of every table in the current schema. */ + internal fun triggerStatements(): List = TABLES.flatMap(::triggers) + + fun createTriggers(connection: SQLiteConnection) { + triggerStatements().forEach(connection::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) { + override fun migrate(connection: SQLiteConnection) { 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`)") + 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(db::execSQL) + V2_UID_TABLES.flatMap(::triggers).forEach(connection::execSQL) } } @@ -72,21 +76,21 @@ object VaultSchema { * 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) { + override fun migrate(connection: SQLiteConnection) { val columns = "`id`, `title`, `description`, `isCompleted`, `createdDate`, " + "`completedDate`, `priority`, `categoryId`, `position`, `uid`" - db.execSQL( + 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 '')", ) - 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) + 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) } } @@ -96,17 +100,17 @@ object VaultSchema { * 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( + 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 )", ) - 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) + 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) } } @@ -116,10 +120,10 @@ object VaultSchema { * 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'") + 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'") } } @@ -130,9 +134,9 @@ object VaultSchema { * 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") + override fun migrate(connection: SQLiteConnection) { + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `textSize` INTEGER") + connection.execSQL("ALTER TABLE `notes` ADD COLUMN `lineSpacing` INTEGER") } } @@ -154,8 +158,8 @@ object VaultSchema { /** Installs the triggers on a freshly created database. */ val CALLBACK = object : RoomDatabase.Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - createTriggers(db) + override fun onCreate(connection: SQLiteConnection) { + createTriggers(connection) } } } 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 index 308474f2..cc59eba4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -45,7 +45,9 @@ 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("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("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"), diff --git a/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt b/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt index d2ba21f3..45639d2b 100644 --- a/app/src/test/java/com/pasich/encly/data/database/SecureDatabaseManagerTest.kt +++ b/app/src/test/java/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]) @@ -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/VaultMigrationTest.kt b/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt index c66e2f5e..5dbad712 100644 --- a/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt +++ b/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt @@ -3,9 +3,11 @@ 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 com.google.gson.JsonParser import kotlinx.coroutines.flow.first @@ -65,7 +67,7 @@ class VaultMigrationTest { object : SupportSQLiteOpenHelper.Callback(version) { override fun onCreate(db: SupportSQLiteDatabase) { statements.forEach(db::execSQL) - VaultSchema.createTriggers(db) + VaultSchema.triggerStatements().forEach(db::execSQL) db.execSQL(noteSql) } @@ -88,7 +90,9 @@ class VaultMigrationTest { "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() + 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) @@ -96,7 +100,7 @@ class VaultMigrationTest { assertEquals("red", old.color) assertNull(old.textSize) assertNull(old.lineSpacing) - assertEquals(DB_VERSION, db.openHelper.readableDatabase.version) + assertEquals(DB_VERSION, db.userVersion()) } finally { db.close() } @@ -111,7 +115,9 @@ class VaultMigrationTest { "VALUES (5, 'Old note', '[]', 'd', 20, 10, NULL, 1, 'old-uid')", ) - val db = VaultSchema.install(Room.databaseBuilder(context, AppDatabase::class.java, name)).build() + 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)) @@ -123,7 +129,7 @@ class VaultMigrationTest { assertEquals("none", old.color) assertNull(old.textSize) assertNull(old.lineSpacing) - assertEquals(DB_VERSION, db.openHelper.readableDatabase.version) + 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")) @@ -138,6 +144,13 @@ class VaultMigrationTest { } } + 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_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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c308635..ad3162df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,7 @@ gson = "2.14.0" datastore = "1.2.1" serializationJson = "1.11.0" sqlcipher = "4.19.1" +sealite = "1.0.0" sqliteKtx = "2.7.1" bip39 = "1.0.9" iconsLucide = "1.1.0" @@ -63,7 +64,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = 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" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", 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" } @@ -73,6 +74,7 @@ hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.re 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" } +sealite = { module = "io.github.pasichdev:sealite", version.ref = "sealite" } kotlin-bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" } icons-lucide = { module = "com.composables:icons-lucide", version.ref = "iconsLucide" } compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRulesDetekt" } From 189083eb3c7fe3729a715766ae30c14887638fd0 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:50:41 +0200 Subject: [PATCH 02/75] refactor: kotlinx-datetime and kotlin.uuid instead of java.time, Date 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). --- app/build.gradle.kts | 3 + .../pasich/encly/data/export/MarkdownNotes.kt | 4 +- .../pasich/encly/data/importer/ImportTime.kt | 67 ++++-- .../encly/domain/model/ItemListBlock.kt | 4 +- .../com/pasich/encly/dynamicBlocks/Block.kt | 4 +- .../components/editNote/NoteHeader.kt | 4 +- .../presentation/components/tiles/NoteItem.kt | 4 +- .../presentation/components/tiles/TaskItem.kt | 7 +- .../dialogs/NoteCardBottomSheet.kt | 4 +- .../screen/backup/BackupScreen.kt | 9 +- .../screen/importnotes/ImportReviewScreen.kt | 4 +- .../viewmodel/MarkdownExportViewModel.kt | 7 +- .../com/pasich/encly/utils/DataFormater.kt | 67 +++--- .../com/pasich/encly/utils/LocalizedDate.kt | 66 ++++++ .../com/pasich/encly/utils/RelativeDay.kt | 43 ++-- .../encly/data/export/MarkdownNotesTest.kt | 10 + .../encly/data/importer/ImportTimeTest.kt | 201 ++++++++++++++++++ .../pasich/encly/dynamicBlocks/BlockIdTest.kt | 20 ++ .../pasich/encly/utils/LocalizedDateTest.kt | 92 ++++++++ .../com/pasich/encly/utils/RelativeDayTest.kt | 61 ++++-- gradle/libs.versions.toml | 2 + 21 files changed, 562 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/utils/LocalizedDate.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/ImportTimeTest.kt create mode 100644 app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt create mode 100644 app/src/test/java/com/pasich/encly/utils/LocalizedDateTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 571b6076..32eba7bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -329,6 +329,9 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + // Date and time + implementation(libs.kotlinx.datetime) + // Cipher: SQLCipher through the androidx.sqlite driver API implementation(libs.sealite) implementation(libs.kotlin.bip39) diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt index 2af0ed71..da81183e 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt @@ -6,7 +6,7 @@ import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.dynamicBlocks.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 @@ -74,5 +74,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/importer/ImportTime.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt index 85bc34da..5c688b7f 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTime.kt @@ -1,12 +1,13 @@ 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 +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, @@ -37,11 +38,15 @@ internal object ImportTime { 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).toEpochMilli() }, - { OffsetDateTime.parse(it).toInstant().toEpochMilli() }, - { LocalDateTime.parse(it).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() }, - { LocalDate.parse(it).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() }, + { 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. */ @@ -60,11 +65,35 @@ internal object ImportTime { return millis?.takeIf { it in 1..MAX_MILLIS } } - private fun iso(text: String): Long? = ISO_FORMS.firstNotNullOfOrNull { form -> - try { - form(text) - } catch (_: DateTimeParseException) { - null + /** + * 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 + } } } @@ -98,9 +127,9 @@ internal object ImportTime { "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) { + LocalDateTime(year, month, day, hour24, clock[MINUTE].toIntOrNull() ?: 0, clock[SECOND].toIntOrNull() ?: 0) + .toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds() + } catch (_: IllegalArgumentException) { null } 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 index 40277476..493270b0 100644 --- a/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt +++ b/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt @@ -1,6 +1,6 @@ package com.pasich.encly.domain.model -import java.util.UUID +import kotlin.uuid.Uuid /** * One list/checklist row. [id] keys the row's field state in Compose, so deleting or inserting @@ -10,5 +10,5 @@ import java.util.UUID data class ItemListBlock( val value: String = "", val isCheck: Boolean = false, - @Transient val id: String = UUID.randomUUID().toString(), + @Transient val id: String = Uuid.random().toString(), ) diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt b/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt index 2d1a677b..169a142c 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt +++ b/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt @@ -5,7 +5,7 @@ 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 +import kotlin.uuid.Uuid enum class BlockType { TEXT, @@ -22,7 +22,7 @@ enum class BlockType { } /** A fresh editor-session identity for a block or list item. Never persisted. */ -fun newBlockId(): String = UUID.randomUUID().toString() +fun newBlockId(): String = Uuid.random().toString() /** * Every block carries [id], a stable identity for the editor session: Compose keys its 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 index 3bc77be8..bdfb8532 100644 --- 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 @@ -23,7 +23,7 @@ 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 +import com.pasich.encly.utils.noteInstant /** * The note's overline (design spec §4.4): "PERSONAL · TODAY 9:40 AM", the tag and when the note @@ -32,7 +32,7 @@ import java.util.Date */ @Composable fun NoteOverline(tagName: String?, date: Long, notSaved: Boolean, modifier: Modifier = Modifier) { - val edited = formatNoteDate(if (date == 0L) Date() else Date(date)) + val edited = formatNoteDate(noteInstant(date)) val style = MaterialTheme.typography.labelSmall Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( 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 index 3cbd0ab8..fbb3257a 100644 --- 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 @@ -19,7 +19,7 @@ import com.pasich.encly.utils.NotesTextFormatter import com.pasich.encly.utils.formatCardDate import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.util.Date +import kotlin.time.Instant /** * A note in a list: the design-system note card with its tag and date. Long-press opens the @@ -45,7 +45,7 @@ fun NoteItem( excerpt = preview, meta = NoteCardMeta( tag = item?.tag?.nameTag, - date = note.date.takeIf { it > 0L }?.let { formatCardDate(Date(it)) }, + date = note.date.takeIf { it > 0L }?.let { formatCardDate(Instant.fromEpochMilliseconds(it)) }, ), excerptLines = excerptLines, highlight = highlight, 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 index b268ae4f..abd16759 100644 --- 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 @@ -33,9 +33,9 @@ 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 com.pasich.encly.utils.formatDateTime import kotlinx.coroutines.delay -import java.util.Date +import kotlin.time.Instant private const val TASK_REMOVAL_ANIMATION_MS = 400 private const val TASK_TRANSLATION_X = 100f @@ -173,11 +173,10 @@ private fun TaskRow( 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))) + stringResource(R.string.task_completed_at, formatDateTime(Instant.fromEpochMilliseconds(it))) } val done = subtasks.count { it.isCompleted } val summary = pluralStringResource(R.plurals.subtasks_done_summary, subtasks.size, done, subtasks.size) diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt index aefd4feb..6253408d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt @@ -48,7 +48,7 @@ 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 +import com.pasich.encly.utils.noteInstant sealed class NoteAction { object Edit : NoteAction() @@ -168,7 +168,7 @@ private fun NoteActionHeader(item: NoteWithTag) { overflow = TextOverflow.Ellipsis, ) Text( - text = formatNoteDate(if (item.note.date == 0L) Date() else Date(item.note.date)), + text = formatNoteDate(noteInstant(item.note.date)), style = EnclyTheme.typography.meta, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt index 70f0d125..9a45b17f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt @@ -49,10 +49,9 @@ 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 com.pasich.encly.utils.todayStamp import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +import kotlin.time.Instant /** * Settings → Backup: encrypted export and import. Files go through the Storage Access @@ -161,7 +160,7 @@ private fun BackupContent(state: BackupUiState, onStart: (BackupAction) -> Unit, @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_last_export, formatNoteDate(Instant.fromEpochMilliseconds(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) @@ -258,7 +257,7 @@ private fun launchPicker(onLaunched: () -> Unit, onUnavailable: () -> Unit, laun /** "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()) + val date = todayStamp() return "encly-backup-$date.${BackupFormat.FILE_EXTENSION}" } 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 index 645e245d..73121625 100644 --- 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 @@ -34,7 +34,7 @@ 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 +import kotlin.time.Instant /** * The review step before an import: the detected source with its counts and losses, then every @@ -208,5 +208,5 @@ private fun badges(note: ReviewNote): String = buildList { 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))) + add(formatNoteDate(Instant.fromEpochMilliseconds(note.date))) }.joinToString(" · ") 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 index 62b24f6b..0dfd4575 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -14,6 +14,7 @@ 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 com.pasich.encly.utils.todayStamp import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -23,9 +24,6 @@ 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. */ @@ -228,8 +226,7 @@ class MarkdownExportViewModel @Inject constructor( ) /** "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 fun datedName(prefix: String, extension: String): String = "$prefix-${todayStamp()}.$extension" private companion object { const val TAG = "MarkdownExport" diff --git a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt b/app/src/main/java/com/pasich/encly/utils/DataFormater.kt index 041b5395..c899fca4 100644 --- a/app/src/main/java/com/pasich/encly/utils/DataFormater.kt +++ b/app/src/main/java/com/pasich/encly/utils/DataFormater.kt @@ -1,43 +1,33 @@ 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 +import kotlinx.datetime.TimeZone +import kotlin.time.Clock +import kotlin.time.Instant -/** - * 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 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 format in the in-app language (e.g. "5/14/26, 3:04 PM"). */ +/** A short date-and-time 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) - } -} +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: 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) +fun formatNoteDate(date: Instant = Clock.System.now()): String { + val tag = currentLanguageTag() + val zone = TimeZone.currentSystemDefault() + return when (relativeDay(date, zone = zone)) { + RelativeDay.TODAY -> + stringResource(R.string.date_today_at, formatLocalized(date, LocalizedDate.TIME, tag, zone)) + + RelativeDay.YESTERDAY -> + stringResource(R.string.date_yesterday_at, formatLocalized(date, LocalizedDate.TIME, tag, zone)) + + RelativeDay.OTHER -> formatLocalized(date, LocalizedDate.DATE_TIME_MEDIUM, tag, zone) } } @@ -46,17 +36,16 @@ fun formatNoteDate(date: Date = Date()): String { * 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)) { +fun formatCardDate(date: Instant): String { + val zone = TimeZone.currentSystemDefault() + return when (relativeDay(date, zone = zone)) { 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) + + RelativeDay.OTHER -> { + val style = if (isSameYear(date, zone = zone)) LocalizedDate.DAY_MONTH else LocalizedDate.DAY_MONTH_YEAR + formatLocalized(date, style, currentLanguageTag(), zone) + } } } - -/** 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/LocalizedDate.kt b/app/src/main/java/com/pasich/encly/utils/LocalizedDate.kt new file mode 100644 index 00000000..c083cfd0 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/utils/LocalizedDate.kt @@ -0,0 +1,66 @@ +package com.pasich.encly.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalResources +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 + +/** + * The one platform-specific place of date formatting: localized month names, order and 12/24 hour + * clock come from java.text and 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 fun currentLanguageTag(): String = + (LocalResources.current.configuration.locales[0] ?: Locale.ENGLISH).toLanguageTag() + +/** [instant] in [zone], written as [style] in the language [languageTag]. */ +internal fun formatLocalized(instant: Instant, style: LocalizedDate, languageTag: String, zone: TimeZone): String { + val format = formats.getOrPut(style to languageTag) { newFormat(style, Locale.forLanguageTag(languageTag)) } + // 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): 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'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/RelativeDay.kt b/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt index 4c4d0c8e..9d2c20e2 100644 --- a/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt +++ b/app/src/main/java/com/pasich/encly/utils/RelativeDay.kt @@ -1,25 +1,36 @@ package com.pasich.encly.utils -import java.util.Calendar -import java.util.Date +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: 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 +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 + } } -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) -} +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/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt b/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt index 09374416..e59265e7 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt +++ b/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt @@ -17,6 +17,16 @@ class MarkdownNotesTest { private fun render(note: Note, tag: String? = null) = MarkdownNotes.render(note, 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() { val markdown = render( diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportTimeTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportTimeTest.kt new file mode 100644 index 00000000..1cb23874 --- /dev/null +++ b/app/src/test/java/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/dynamicBlocks/BlockIdTest.kt b/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt new file mode 100644 index 00000000..24725bcf --- /dev/null +++ b/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt @@ -0,0 +1,20 @@ +package com.pasich.encly.dynamicBlocks + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BlockIdTest { + @Test + fun anIdIsALowercaseHyphenatedUuid() { + val id = newBlockId() + assertTrue(id, Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").matches(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/app/src/test/java/com/pasich/encly/utils/LocalizedDateTest.kt b/app/src/test/java/com/pasich/encly/utils/LocalizedDateTest.kt new file mode 100644 index 00000000..7395a1c2 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/utils/LocalizedDateTest.kt @@ -0,0 +1,92 @@ +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 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/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt b/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt index a7f97b4b..f21d8ad6 100644 --- a/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt +++ b/app/src/test/java/com/pasich/encly/utils/RelativeDayTest.kt @@ -1,51 +1,74 @@ package com.pasich.encly.utils +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant 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 +import kotlin.time.Instant class RelativeDayTest { - private val now: Date = Calendar.getInstance().apply { - set(2026, Calendar.MARCH, 10, 12, 0, 0) - }.time + private val zone = TimeZone.of("Europe/Kyiv") - private fun at(day: Int, hour: Int): Date = Calendar.getInstance().apply { - set(2026, Calendar.MARCH, day, hour, 30, 0) - }.time + 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(at(10, 0), now)) - assertEquals(RelativeDay.TODAY, relativeDay(at(10, 23), now)) + 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(at(9, 8), now)) + 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(at(1, 8), now)) - assertEquals(RelativeDay.OTHER, relativeDay(at(11, 8), now)) + assertEquals(RelativeDay.OTHER, relativeDay(march(1, 8), now, zone)) + assertEquals(RelativeDay.OTHER, relativeDay(march(11, 8), now, zone)) } @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)) + 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 = Calendar.getInstance().apply { set(2025, Calendar.DECEMBER, 31, 22, 0, 0) }.time - assertTrue(isSameYear(at(1, 8), now)) - assertFalse(isSameYear(newYearsEve, now)) + 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index ad3162df..03c2a259 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ reorderable = "3.1.0" gson = "2.14.0" datastore = "1.2.1" serializationJson = "1.11.0" +datetime = "0.8.0" sqlcipher = "4.19.1" sealite = "1.0.0" sqliteKtx = "2.7.1" @@ -69,6 +70,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c 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" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } From e4dad7c4f9e7d6138a5a9bbad05d3d57313296db Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:51:00 +0200 Subject: [PATCH 03/75] refactor(serialization): note blocks through kotlinx.serialization instead 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. --- app/build.gradle.kts | 1 - app/proguard-rules.pro | 6 +- .../core/serialization/BlockConverter.kt | 36 +- .../encly/core/serialization/BlockJson.kt | 181 +++++++ .../core/serialization/BlockSerialization.kt | 141 ------ .../presentation/screen/LicensesScreen.kt | 1 - .../core/serialization/BlockConverterTest.kt | 4 +- .../core/serialization/BlockJsonFormatTest.kt | 447 ++++++++++++++++++ .../encly/data/database/VaultMigrationTest.kt | 19 +- gradle/libs.versions.toml | 2 - 10 files changed, 651 insertions(+), 187 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt delete mode 100644 app/src/main/java/com/pasich/encly/core/serialization/BlockSerialization.kt create mode 100644 app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32eba7bf..908d3687 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -323,7 +323,6 @@ dependencies { ksp(libs.hilt.compiler) // Other dependencies - implementation(libs.gson) implementation(libs.androidx.datastore.preferences) // Serialization diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 18be4e6b..80695740 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,9 +7,9 @@ # 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). +# - 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). 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 index 9a38edae..cc8b2a39 100644 --- a/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt +++ b/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt @@ -1,26 +1,15 @@ 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 +import kotlinx.serialization.SerializationException /** * Converter for transforming blocks to JSON and back. - * Uses custom serializers and deserializers. + * The format is written and read by [BlockJson]. */ 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 @@ -30,23 +19,20 @@ object BlockConverter { * @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) + return BlockJson.encode(kept) } /** * 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 + * @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 */ 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() + return BlockJson.decode(json) } /** @@ -55,17 +41,9 @@ object BlockConverter { */ 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) { + // SerializationException (malformed JSON, a wrong shape, an unknown block) is one; so are + // BlockType.valueOf failures. logUnreadable(e) } diff --git a/app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt b/app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt new file mode 100644 index 00000000..4ddc68bd --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt @@ -0,0 +1,181 @@ +package com.pasich.encly.core.serialization + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.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 { + // 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() }.map { item -> + buildJsonObject { + put("value", item.value) + put("isCheck", item.isCheck) + } + }, + ), + ) + // 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) + } + } + } + + 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" -> Block.ListBlock( + items = MutableStateFlow( + json.array("items")?.map { item -> + val itemObject = item.asObject() + ItemListBlock( + value = itemObject.string("value").orEmpty(), + isCheck = itemObject.string("isCheck").toBoolean(), + ) + } ?: 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(json.string("isLocked").toBoolean()), + ) + + else -> throw SerializationException("Unknown block type: $blockType") + } + } + + 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%04x".format(c.code)) + else -> append(c) + } + } + } +} 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/presentation/screen/LicensesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt index cc59eba4..f7464e4f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -49,7 +49,6 @@ private val bundledLibraries = listOf( OpenSourceWork("SQLCipher Community Edition", "BSD 3-Clause", "https://github.com/sqlcipher/sqlcipher"), OpenSourceWork("OpenSSL", APACHE_2, "https://github.com/openssl/openssl"), 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"), diff --git a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt b/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt index 6ba6f97e..9650ab2b 100644 --- a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt +++ b/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt @@ -1,11 +1,11 @@ package com.pasich.encly.core.serialization -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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -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/app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt b/app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt new file mode 100644 index 00000000..74f79310 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt @@ -0,0 +1,447 @@ +package com.pasich.encly.core.serialization + +import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.domain.model.LinkDataBlock +import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.dynamicBlocks.BlockType +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 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(json, BlockConverter.jsonToBlocksOrNull(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) + } +} 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 index 5dbad712..5de781e0 100644 --- a/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt +++ b/app/src/test/java/com/pasich/encly/data/database/VaultMigrationTest.kt @@ -9,9 +9,12 @@ import androidx.sqlite.db.SupportSQLiteOpenHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.sqlite.driver.AndroidSQLiteDriver import androidx.test.core.app.ApplicationProvider -import com.google.gson.JsonParser 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 @@ -49,16 +52,16 @@ class VaultMigrationTest { * 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 schema = Json.parseToJsonElement(File(schemaPath).readText()).jsonObject.getValue("database").jsonObject 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.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.getAsJsonArray("setupQueries").forEach { add(it.asString) } + schema.getValue("setupQueries").jsonArray.forEach { add(it.jsonPrimitive.content) } } val helper = FrameworkSQLiteOpenHelperFactory().create( SupportSQLiteOpenHelper.Configuration.builder(context) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03c2a259..c31494aa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,6 @@ 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" datetime = "0.8.0" @@ -74,7 +73,6 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version. hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } 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" } sealite = { module = "io.github.pasichdev:sealite", version.ref = "sealite" } kotlin-bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" } From 225b295b335fff77711cfa4df4acd29369f6d7bd Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:51:26 +0200 Subject: [PATCH 04/75] refactor(security): own BIP-39 encoding instead of kotlin-bip39 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. --- README.md | 2 +- app/build.gradle.kts | 1 - app/proguard-rules.pro | 1 - .../pasich/encly/core/security/Mnemonic.kt | 14 ++ .../encly/core/security/RecoveryWords.kt | 13 +- .../encly/core/security/SecurityManager.kt | 2 +- .../encly/core/security/SeedPhraseManager.kt | 10 +- .../pasich/encly/core/security/bip39/Bip39.kt | 114 ++++++++++ .../core/security/bip39/Bip39Wordlist.kt | 214 ++++++++++++++++++ .../encly/core/security/bip39/JvmSha256.kt | 8 + .../encly/core/security/bip39/Sha256.kt | 6 + .../presentation/screen/LicensesScreen.kt | 6 +- .../encly/core/backup/BackupCipherTest.kt | 7 +- .../encly/core/security/Bip39GoldenTest.kt | 166 ++++++++++++++ .../encly/core/security/DuressPinTest.kt | 8 +- .../encly/core/security/RecoveryWordsTest.kt | 4 +- .../security/SecurityManagerLifecycleTest.kt | 8 +- .../core/security/SeedPhraseManagerTest.kt | 34 +-- .../pasich/encly/core/security/WipePinTest.kt | 4 +- .../core/security/bip39/Bip39WordlistTest.kt | 45 ++++ .../encly/data/backup/BackupImporterTest.kt | 7 +- .../domain/usecase/RestoreFromBackupTest.kt | 10 +- .../designsystem/RecoveryPhrasePasteTest.kt | 5 +- .../designsystem/RecoveryPhraseStateTest.kt | 5 +- .../presentation/viewmodel/BackupFlowsTest.kt | 7 +- .../viewmodel/OnboardingFailuresTest.kt | 5 +- .../viewmodel/OnboardingViewModelTest.kt | 12 +- .../encly/ui/screens/BackupScreenTest.kt | 5 +- .../encly/ui/screens/OnboardingScreenTest.kt | 5 +- .../encly/ui/screens/SecurityScreensTest.kt | 5 +- .../com/pasich/encly/ui/screens/TestApp.kt | 4 +- .../test/resources/bip39-english-vectors.txt | 26 +++ gradle/libs.versions.toml | 2 - 33 files changed, 669 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/bip39/Sha256.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/Bip39GoldenTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt create mode 100644 app/src/test/resources/bip39-english-vectors.txt diff --git a/README.md b/README.md index 72a76cfb..7320ea86 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ 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) +kotlinx.serialization · BIP39 (own implementation) Clean architecture: `presentation` → `domain` → `data`, with `core/security` coordinating the key vault. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 908d3687..bf9d8596 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -333,7 +333,6 @@ dependencies { // Cipher: SQLCipher through the androidx.sqlite driver API implementation(libs.sealite) - implementation(libs.kotlin.bip39) implementation(libs.icons.lucide) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 80695740..3cff0c5c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -13,7 +13,6 @@ # - 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/main/java/com/pasich/encly/core/security/Mnemonic.kt b/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt index 87618b45..633d9c7f 100644 --- a/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt +++ b/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt @@ -1,13 +1,27 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.security.bip39.Bip39 import java.nio.CharBuffer import java.security.MessageDigest +import java.security.SecureRandom /** * 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 { + private const val ENTROPY_BYTES = 16 + + /** A new 12-word recovery phrase from 128 bits of system randomness. */ + fun generate(): CharArray { + val entropy = ByteArray(ENTROPY_BYTES).also { SecureRandom().nextBytes(it) } + return try { + Bip39().encode(entropy) + } finally { + SensitiveDataCleaner.clear(entropy) + } + } + /** Lower-cases and collapses any whitespace between words to a single space. */ fun normalize(words: CharArray): CharArray { val out = CharArray(words.size) 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 index d3ec011f..47116c04 100644 --- a/app/src/main/java/com/pasich/encly/core/security/RecoveryWords.kt +++ b/app/src/main/java/com/pasich/encly/core/security/RecoveryWords.kt @@ -1,7 +1,7 @@ package com.pasich.encly.core.security -import cash.z.ecc.android.bip39.Mnemonics -import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode +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. */ object RecoveryWords { @@ -9,7 +9,7 @@ object RecoveryWords { const val COUNT = 12 /** Sorted, as BIP39 publishes it. */ - private val words: List by lazy { Mnemonics.getCachedWords(Mnemonics.DEFAULT_LANGUAGE_CODE) } + private val words: List by lazy { Bip39Wordlist.words } private val wordSet: Set by lazy { words.toHashSet() } fun isWord(word: String): Boolean = word in wordSet @@ -22,10 +22,5 @@ object RecoveryWords { } /** 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 - } + fun isValidPhrase(phrase: CharArray): Boolean = Bip39().isValid(phrase) } diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 7146b8bf..6f42bdf1 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -606,7 +606,7 @@ class SecurityManager @Inject constructor( .commit() } - fun generateMnemonicCode(): CharArray = seedPhraseManager.generateMnemonic().chars + fun generateMnemonicCode(): CharArray = seedPhraseManager.generateMnemonic() /** * Starts a brand-new vault with no legacy state. First-run setup is intentionally diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt index 3c2aeb07..7216ab88 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -1,8 +1,7 @@ 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 com.pasich.encly.core.security.bip39.Bip39 import java.security.GeneralSecurityException import java.security.MessageDigest import java.security.SecureRandom @@ -53,15 +52,12 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { @Volatile private var bootstrapDek: ByteArray? = null - fun generateMnemonic(): MnemonicCode = MnemonicCode(WordCount.COUNT_12) + fun generateMnemonic(): CharArray = Mnemonic.generate() 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) } diff --git a/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt new file mode 100644 index 00000000..e2133735 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt @@ -0,0 +1,114 @@ +package com.pasich.encly.core.security.bip39 + +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 Kotlin plus a [Sha256], so it can move to common code. Phrases are char arrays and every + * intermediate array is wiped; arrays handed back belong to the caller. + */ +internal class Bip39(private val sha256: Sha256 = JvmSha256) { + + /** + * The phrase for [entropy] (16, 20, 24, 28 or 32 bytes: 12 to 24 words), words joined by one + * space, lower case. + */ + 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`. + 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. */ + 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 + val ENTROPY_BYTES = listOf(16, 20, 24, 28, 32) + val WORD_COUNTS = listOf(12, 15, 18, 21, 24) + } +} diff --git a/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39Wordlist.kt new file mode 100644 index 00000000..a6409b17 --- /dev/null +++ b/app/src/main/java/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. + */ +internal object Bip39Wordlist { + 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. + */ + 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/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt new file mode 100644 index 00000000..8c6cb2b2 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt @@ -0,0 +1,8 @@ +package com.pasich.encly.core.security.bip39 + +import java.security.MessageDigest + +/** The only JVM-specific line of the BIP-39 code; swap it for a common SHA-256 when there is one. */ +internal object JvmSha256 : Sha256 { + override fun digest(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input) +} diff --git a/app/src/main/java/com/pasich/encly/core/security/bip39/Sha256.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/Sha256.kt new file mode 100644 index 00000000..0fea53c6 --- /dev/null +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt index f7464e4f..eec1f006 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -48,7 +48,11 @@ private val bundledLibraries = listOf( 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("kotlin-bip39", "MIT License", "https://github.com/Electric-Coin-Company/kotlin-bip39"), + 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"), diff --git a/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt b/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt index 23d5a4bf..fe717c80 100644 --- a/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt +++ b/app/src/test/java/com/pasich/encly/core/backup/BackupCipherTest.kt @@ -1,7 +1,6 @@ package com.pasich.encly.core.backup -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 java.nio.ByteBuffer class BackupCipherTest { - private val words = MnemonicCode(WordCount.COUNT_12).chars + private val words = Mnemonic.generate() private val plaintext = "{\"notes\":[\"secret note body\"]}".toByteArray() private fun phrase(chars: CharArray = words) = BackupSecret.RecoveryPhrase(chars.copyOf()) @@ -43,7 +42,7 @@ class BackupCipherTest { @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)) } } diff --git a/app/src/test/java/com/pasich/encly/core/security/Bip39GoldenTest.kt b/app/src/test/java/com/pasich/encly/core/security/Bip39GoldenTest.kt new file mode 100644 index 00000000..fece61fc --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/Bip39GoldenTest.kt @@ -0,0 +1,166 @@ +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.tempVaultFile +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 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(tempVaultFile())) + private val abandon = List(11) { "abandon" }.joinToString(" ") + " about" + + private fun vectors(): List> { + val text = checkNotNull(javaClass.classLoader!!.getResourceAsStream("bip39-english-vectors.txt")).use { + it.readBytes().decodeToString() + } + return text.lines().filter { it.isNotBlank() && !it.startsWith("#") }.map { + val (entropy, mnemonic) = it.split(' ', limit = 2) + entropy.chunked(2).map { byte -> byte.toInt(16).toByte() }.toByteArray() to mnemonic + } + } + + private fun encode(entropy: ByteArray): String = String(Bip39().encode(entropy)) + + 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) -> + assertArrayEquals(mnemonic, entropy, Bip39().decode(mnemonic.toCharArray())) + } + } + + @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, String(phrase).split(' ').size) + assertArrayEquals(entropy, Bip39().decode(phrase)) + } + } + + @Test + fun oddEntropySizesAreRefused() { + listOf(0, 15, 17, 33).forEach { size -> + assertThrows(IllegalArgumentException::class.java) { Bip39().encode(ByteArray(size)) } + } + } + + @Test + fun decodeWipesNothingItWasGiven() { + val phrase = abandon.toCharArray() + Bip39().decode(phrase) + assertEquals(abandon, String(phrase)) + } + + @Test + fun officialVectorsValidate() { + vectors().forEach { (_, mnemonic) -> + assertTrue(mnemonic, strict(mnemonic)) + assertTrue(mnemonic, lenient(mnemonic)) + } + } + + @Test + fun generatedPhrasesAreTwelveValidWords() { + repeat(20) { + val phrase = String(seeds.generateMnemonic()) + 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("$count", strict(phrase)) + assertFalse("$count", lenient(phrase)) + } + } + + @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()) + + assertArrayEquals(dek, seeds.unlockWithSeed(abandon.uppercase().toCharArray())) + } + + private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + + private companion object { + const val SEED_HASH = "c557eec878dfd852ba3f88087c4f350f09c55537ab5e549c3cd14320ec3cef38" + const val BACKUP_ROOT = "1a33cffc42964db0c0dd84e985bc0d0acfd3264809e289e8a85700cc38cfeb63" + } +} diff --git a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt index c7e06e27..4d36af1d 100644 --- a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt @@ -468,9 +468,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()) @@ -570,7 +570,7 @@ class DuressPinTest { 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) { 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 index 958edd9c..b133ee66 100644 --- a/app/src/test/java/com/pasich/encly/core/security/RecoveryWordsTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/RecoveryWordsTest.kt @@ -1,7 +1,5 @@ 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 @@ -26,7 +24,7 @@ class RecoveryWordsTest { @Test fun checksum() { - assertTrue(RecoveryWords.isValidPhrase(MnemonicCode(WordCount.COUNT_12).chars)) + assertTrue(RecoveryWords.isValidPhrase(Mnemonic.generate())) assertFalse(RecoveryWords.isValidPhrase(List(12) { "abandon" }.joinToString(" ").toCharArray())) } } 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 index b2f899f6..7d6eab94 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt @@ -80,7 +80,7 @@ class SecurityManagerLifecycleTest { /** 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 + val phrase = seed.generateMnemonic() assertTrue(manager.initializeNewVault(if (withRecoveryPhrase) phrase.copyOf() else null)) assertTrue(manager.configurePin(PIN.toCharArray())) assertTrue(manager.finishInitialSetup()) @@ -222,7 +222,7 @@ class SecurityManagerLifecycleTest { @Test fun aWrongRecoveryPhraseIsAnInvalidCredential() { committedVault() - val other = seed.generateMnemonic().chars + val other = seed.generateMnemonic() assertEquals(VaultUnlockResult.INVALID_CREDENTIAL, manager.unlockWithSeed(other)) assertFalse(manager.verifySeed(other)) @@ -262,8 +262,8 @@ class SecurityManagerLifecycleTest { assertEquals(InitialStatus.AUTH, manager.securityStatus) assertNull(manager.copyBackupRootKey()) - assertFalse(manager.addRecoverySeed(seed.generateMnemonic().chars)) - assertFalse(manager.replaceRecoverySeed(seed.generateMnemonic().chars)) + assertFalse(manager.addRecoverySeed(seed.generateMnemonic())) + assertFalse(manager.replaceRecoverySeed(seed.generateMnemonic())) assertFalse(manager.createBackupKey(phrase.copyOf())) assertFalse(manager.canResetPinWithoutCurrent()) verify(database).reset() diff --git a/app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt index 486e77c6..6af42ae5 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SeedPhraseManagerTest.kt +++ b/app/src/test/java/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/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index c29c112a..83afbd32 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -126,7 +126,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 { @@ -682,7 +682,7 @@ 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))) diff --git a/app/src/test/java/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt b/app/src/test/java/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt new file mode 100644 index 00000000..b94b7358 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/bip39/Bip39WordlistTest.kt @@ -0,0 +1,45 @@ +package com.pasich.encly.core.security.bip39 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest + +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 = MessageDigest.getInstance("SHA-256").digest((words.joinToString("\n") + "\n").toByteArray()) + + assertEquals(canonical, digest.joinToString("") { "%02x".format(it) }) + } + + @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(word, index, Bip39Wordlist.indexOf(padded, 2, 2 + word.length)) + } + } + + @Test + fun lookupRejectsNearMisses() { + listOf("", "a", "aban", "abandonn", "Abandon", "zoos", "zo", "~").forEach { + assertEquals(it, -1, Bip39Wordlist.indexOf(it.toCharArray(), 0, it.length)) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt index 050f65b7..8125e112 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -1,13 +1,12 @@ 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.BackupPayload import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret import com.pasich.encly.core.backup.assertError +import com.pasich.encly.core.security.Mnemonic import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.data.model.Note import com.pasich.encly.data.model.Subtask @@ -27,7 +26,7 @@ import org.junit.Test 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( @@ -301,7 +300,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)) } } diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt b/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt index 5ecb7ac5..a098f2c1 100644 --- a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt +++ b/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt @@ -1,13 +1,13 @@ 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.PendingRestore @@ -29,7 +29,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 +42,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. @@ -82,7 +82,7 @@ class RestoreFromBackupTest { @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/presentation/designsystem/RecoveryPhrasePasteTest.kt b/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt index 28a3db67..f6a8ed32 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhrasePasteTest.kt +++ b/app/src/test/java/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/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt index 60d92812..25bb7652 100644 --- a/app/src/test/java/com/pasich/encly/presentation/designsystem/RecoveryPhraseStateTest.kt +++ b/app/src/test/java/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/viewmodel/BackupFlowsTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 2960f8a7..96fbb030 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -1,11 +1,10 @@ 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.security.Mnemonic import com.pasich.encly.core.security.RecoveryWords import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager @@ -50,8 +49,8 @@ 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 phrase = String(Mnemonic.generate()) + private val otherPhrase = String(Mnemonic.generate()) private val uri: Uri = mock(Uri::class.java) private var written: ByteArray? = null 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 index b745f2c7..99c5f06e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt @@ -1,10 +1,9 @@ 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.Mnemonic import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager @@ -38,7 +37,7 @@ 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 val words = Mnemonic.generate() private lateinit var security: SecurityManager private val activity: FragmentActivity = mock(FragmentActivity::class.java) diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt index 14f13a24..6235952e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt @@ -1,14 +1,14 @@ 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.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 @@ -44,7 +44,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 +65,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) @@ -360,7 +360,7 @@ 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) @@ -374,7 +374,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() diff --git a/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt index a6e7ba44..325aac36 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/BackupScreenTest.kt @@ -12,9 +12,8 @@ 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.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 @@ -26,7 +25,7 @@ import org.junit.Test import org.mockito.Mockito.verifyNoInteractions class BackupScreenTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) + private val words = String(Mnemonic.generate()) private val app by lazy { TestApp(context).withWorkingVault(words) } private lateinit var backup: BackupViewModel 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 index 11b2d028..e2a14dce 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/OnboardingScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/OnboardingScreenTest.kt @@ -9,9 +9,8 @@ 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.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 @@ -22,7 +21,7 @@ import org.junit.Assert.assertTrue import org.junit.Test class OnboardingScreenTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) + private val words = String(Mnemonic.generate()) private val app by lazy { TestApp(context).withWorkingVault(words) } private lateinit var onboarding: OnboardingViewModel private var completed = false diff --git a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt index 002c75b2..bb1c25b3 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -8,13 +8,12 @@ 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.Mnemonic import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.presentation.navigation.NavRoutes @@ -34,7 +33,7 @@ import org.mockito.Mockito.verify import org.mockito.Mockito.`when` class SecurityScreensTest : ComposeScreenTest() { - private val words = String(MnemonicCode(WordCount.COUNT_12).chars) + private val words = String(Mnemonic.generate()) private val app by lazy { TestApp(context).withWorkingVault(words) } private lateinit var vault: BackupViewModel diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index 49ab236f..2df885ee 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -3,7 +3,6 @@ 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 @@ -18,6 +17,7 @@ import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.core.security.WipePinChange +import com.pasich.encly.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 @@ -115,7 +115,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)) } } diff --git a/app/src/test/resources/bip39-english-vectors.txt b/app/src/test/resources/bip39-english-vectors.txt new file mode 100644 index 00000000..6c5a685a --- /dev/null +++ b/app/src/test/resources/bip39-english-vectors.txt @@ -0,0 +1,26 @@ +# Official BIP-39 English test vectors (entropy hex, then mnemonic). +# Source: https://github.com/trezor/python-mnemonic/blob/master/vectors.json +00000000000000000000000000000000 abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about +7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f legal winner thank year wave sausage worth useful legal winner thank yellow +80808080808080808080808080808080 letter advice cage absurd amount doctor acoustic avoid letter advice cage above +ffffffffffffffffffffffffffffffff zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong +000000000000000000000000000000000000000000000000 abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent +7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will +808080808080808080808080808080808080808080808080 letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always +ffffffffffffffffffffffffffffffffffffffffffffffff zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when +0000000000000000000000000000000000000000000000000000000000000000 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 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 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 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 ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic +6610b25967cdcca9d59875f5cb50b0ea75433311869e930b gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog +68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c 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 scheme spot photo card baby mountain device kick cradle pact join borrow +6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3 horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave +9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863 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 cat swing flag economy stadium alone churn speed unique patch report train +8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0 light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access +066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad 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 vessel ladder alter error federal sibling chat ability sun glass valve picture +c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05 scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump +f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c31494aa..ed57c6d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,6 @@ datetime = "0.8.0" sqlcipher = "4.19.1" sealite = "1.0.0" sqliteKtx = "2.7.1" -bip39 = "1.0.9" iconsLucide = "1.1.0" junit = "4.13.2" @@ -75,7 +74,6 @@ hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.re reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } sealite = { module = "io.github.pasichdev:sealite", version.ref = "sealite" } -kotlin-bip39 = { module = "cash.z.ecc.android:kotlin-bip39", version.ref = "bip39" } icons-lucide = { module = "com.composables:icons-lucide", version.ref = "iconsLucide" } compose-rules-detekt = { module = "io.nlopez.compose.rules:detekt", version.ref = "composeRulesDetekt" } From f71c279848ddb9b8e7fa8e5174cc4df9afae363e Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:51:35 +0200 Subject: [PATCH 05/75] refactor(import): kotlinx-io streams for import, export and hand-off 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. --- app/build.gradle.kts | 4 + .../pasich/encly/core/backup/BackupFormat.kt | 45 ++--- .../encly/data/backup/BackupDocuments.kt | 27 +-- .../encly/data/export/MarkdownDocuments.kt | 10 +- .../encly/data/export/MarkdownExport.kt | 30 ++- .../encly/data/handoff/HandoffStaging.kt | 16 +- .../data/handoff/MyNotesHandoffReader.kt | 77 ++++---- .../encly/data/importer/DocumentTree.kt | 4 +- .../encly/data/importer/ImportReader.kt | 83 ++++----- .../pasich/encly/data/importer/ImportText.kt | 9 +- .../encly/data/importer/SafImportFiles.kt | 4 +- .../pasich/encly/data/importer/TarReader.kt | 40 ++-- .../encly/data/importer/ZipStreamReader.kt | 71 ++++--- .../java/com/pasich/encly/data/zip/JvmZip.kt | 98 ++++++++++ .../com/pasich/encly/data/zip/ZipPlatform.kt | 75 ++++++++ .../encly/core/backup/BackupFormatTest.kt | 99 ++++++++++ .../encly/data/backup/BackupDocumentsTest.kt | 13 +- .../encly/data/backup/BackupManagerTest.kt | 3 +- .../encly/data/export/MarkdownExportTest.kt | 113 +++++++++++- .../encly/data/handoff/HandoffFixtures.kt | 4 + .../data/handoff/MyNotesHandoffReaderTest.kt | 97 +++++++++- .../encly/data/importer/ImportFixtures.kt | 7 +- .../encly/data/importer/ImportReaderTest.kt | 11 +- .../data/importer/ZipStreamReaderTest.kt | 173 ++++++++++++++---- .../viewmodel/MarkdownExportViewModelTest.kt | 20 +- gradle/libs.versions.toml | 3 + 26 files changed, 860 insertions(+), 276 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt create mode 100644 app/src/main/java/com/pasich/encly/data/zip/ZipPlatform.kt create mode 100644 app/src/test/java/com/pasich/encly/core/backup/BackupFormatTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf9d8596..c581c4c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -327,6 +327,10 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.json.io) + + // Bytes and streams (Source / Sink), so import, export and hand-off do not depend on java.io + implementation(libs.kotlinx.io.core) // Date and time implementation(libs.kotlinx.datetime) 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 index 592a5908..2131b63b 100644 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupFormat.kt +++ b/app/src/main/java/com/pasich/encly/core/backup/BackupFormat.kt @@ -1,6 +1,7 @@ package com.pasich.encly.core.backup -import java.nio.ByteBuffer +import kotlinx.io.Buffer +import kotlinx.io.readByteArray /** Why a backup could not be written or read. Never carries secrets or plaintext. */ enum class BackupError { @@ -93,7 +94,7 @@ class ParsedBackup(val header: BackupHeader, val file: ByteArray) { * follow the ciphertext. */ object BackupFormat { - val MAGIC = "ENCLYBAK".toByteArray(Charsets.US_ASCII) + val MAGIC = "ENCLYBAK".encodeToByteArray() const val VERSION = 1 const val SALT_LENGTH = 32 const val NONCE_LENGTH = 12 @@ -110,16 +111,16 @@ object BackupFormat { 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() + 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() } /** @@ -150,19 +151,21 @@ object BackupFormat { 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 + // 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.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) } + 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.int.toLong() and U32_MASK + 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()) 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 index d34746c2..a50aa9a2 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt @@ -8,9 +8,11 @@ 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 kotlinx.io.Buffer +import kotlinx.io.IOException +import kotlinx.io.RawSource +import kotlinx.io.asSource +import kotlinx.io.readTo import javax.inject.Inject import javax.inject.Singleton @@ -22,7 +24,7 @@ import javax.inject.Singleton @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) } + context.contentResolver.openInputStream(uri).orFail().use { readCapped(it.asSource()) } } /** The document's display name, for showing which file was picked; null when unknown. */ @@ -47,7 +49,7 @@ class BackupDocuments @Inject constructor(@param:ApplicationContext private val 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) } + val written = resolver.openInputStream(uri).orFail().use { readCapped(it.asSource()) } if (!written.contentEquals(file)) throw BackupException(BackupError.IO) complete = true } finally { @@ -63,7 +65,7 @@ class BackupDocuments @Inject constructor(@param:ApplicationContext private val runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) } } - private fun T?.orFail(): T = this ?: throw BackupException(BackupError.IO) + private fun T?.orFail(): T = this ?: throw BackupException(BackupError.IO) /** * Maps every provider failure to [BackupError.IO]. Third-party DocumentsProviders also throw @@ -89,9 +91,10 @@ class BackupDocuments @Inject constructor(@param:ApplicationContext private val * 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. + * [BackupFormat.parse] to report as truncated. [input] is read unbuffered, so nothing + * past what was needed is taken from the provider. */ - fun readCapped(input: InputStream, limit: Int = BackupFormat.MAX_FILE_BYTES): ByteArray { + 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) { @@ -103,17 +106,19 @@ class BackupDocuments @Inject constructor(@param:ApplicationContext private val val filled = fill(input, file, headRead) return when { filled < file.size -> file.copyOf(filled) - input.read() >= 0 -> throw BackupException(BackupError.CORRUPTED) + 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: InputStream, target: ByteArray, from: Int): Int { + private fun fill(input: RawSource, target: ByteArray, from: Int): Int { + val chunk = Buffer() var position = from while (position < target.size) { - val read = input.read(target, position, minOf(BUFFER_BYTES, target.size - position)) + 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/export/MarkdownDocuments.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt index d8d4cf9c..6d2fda24 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt @@ -6,8 +6,10 @@ 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 kotlinx.io.IOException +import kotlinx.io.Sink +import kotlinx.io.asSink +import kotlinx.io.buffered import javax.inject.Inject import javax.inject.Singleton @@ -25,13 +27,13 @@ class MarkdownDocuments @Inject constructor(@param:ApplicationContext private va * 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 { + fun write(uri: Uri, block: (Sink) -> 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) + stream.asSink().buffered().use(block) complete = true } finally { if (!complete) discard(uri) 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 index fd08de0c..a8be5c9d 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownExport.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownExport.kt @@ -1,9 +1,9 @@ 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 +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 @@ -23,32 +23,30 @@ object MarkdownExport { 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) + fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, out: Sink) { + writeAll(snapshot, labels, zipWriter(out)) + } + + internal fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, zip: ZipEntrySink) { 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)) + 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.put(name, note.date, MarkdownNotes.render(note, tagNames[note.tagId], labels)) + zip.putText(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 { + fun writeNote(snapshot: VaultSnapshot, noteId: Long, labels: MarkdownLabels, out: Sink): 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)) + out.write(MarkdownNotes.render(note, tagName, labels).encodeToByteArray()) 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() - } + private fun ZipEntrySink.putText(name: String, time: Long?, text: String) = + put(name, time?.takeIf { it >= ZIP_EPOCH_MILLIS }, text.encodeToByteArray()) } 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 index 96aef425..fe26c95e 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt @@ -1,5 +1,9 @@ package com.pasich.encly.data.handoff +import com.pasich.encly.data.zip.openZipFile +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered import java.io.File import java.io.IOException import java.io.InputStream @@ -25,8 +29,12 @@ class HandoffStaging(private val dir: File, private val limits: HandoffLimits = 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)) + input.use { stream -> + zip.outputStream().asSink().buffered().use { + MyNotesHandoffReader.copyLimited(stream.asSource(), it, limits) + } + } + return MyNotesHandoffMapper.map(read(zip, limits)) } finally { release(dir, zip) } @@ -37,6 +45,10 @@ class HandoffStaging(private val dir: File, private val limits: HandoffLimits = private const val ZIP_PREFIX = "handoff-" private const val ZIP_SUFFIX = ".zip" + /** The hand-off in the ZIP file [zip], which is opened with the platform's ZIP support. */ + internal fun read(zip: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff = + MyNotesHandoffReader.read(zip.length(), limits) { openZipFile(zip) } + /** Paths of the files loads in this process are using; also the lock of the directory. */ private val inUse = mutableSetOf() 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 index 52c904df..8fe54cd7 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffReader.kt @@ -1,17 +1,18 @@ 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.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 +import kotlinx.serialization.json.io.decodeFromSource /** * Size limits of a hand-off. Every one is enforced on the bytes actually read, never on a @@ -58,24 +59,32 @@ object MyNotesHandoffReader { * 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()) { + fun copyLimited(input: RawSource, target: Sink, limits: HandoffLimits = HandoffLimits()) { try { - LimitedInputStream(input, limits.maxZipBytes).copyTo(target) + LimitedSource(input, limits.maxZipBytes).buffered().transferTo(target) } catch (_: LimitExceededException) { throw HandoffException(HandoffError.TOO_LARGE) } } - fun read(zip: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff { - ensure(zip.length() <= limits.maxZipBytes, 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 { - ZipFile(zip).use { archive -> - ensure(archive.size() == 1, HandoffError.INVALID_PAYLOAD) - val entry = archive.getEntry(ENTRY_NAME)?.takeUnless { it.isDirectory } + 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.size <= limits.maxJsonBytes, HandoffError.TOO_LARGE) - val open = { LimitedInputStream(archive.getInputStream(entry), limits.maxJsonBytes) } + 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) @@ -87,8 +96,8 @@ object MyNotesHandoffReader { return handoff } - private fun decode(deserializer: DeserializationStrategy, input: InputStream): T = - json.decodeFromStream(deserializer, input) + 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 { @@ -103,7 +112,11 @@ object MyNotesHandoffReader { /** 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, + if (generateSequence(e) { it.cause }.any { it is LimitExceededException }) { + HandoffError.TOO_LARGE + } else { + HandoffError.INVALID_PAYLOAD + }, ) private fun validate(handoff: MyNotesHandoff, limits: HandoffLimits) { @@ -138,28 +151,18 @@ object MyNotesHandoffReader { 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 class LimitedSource(private val upstream: RawSource, private val limit: Long) : RawSource { 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()) + 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 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() - } + override fun close() = upstream.close() } } diff --git a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt index 0c1e84dd..86ff1d27 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/DocumentTree.kt @@ -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/ImportReader.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt index ceea0094..0ac7aa9b 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt @@ -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 @@ -90,7 +91,7 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { return } try { - stream.use { process(file.path, Counting(it)) } + Counting(stream).buffered().use { process(file.path, it) } } catch (_: IOException) { unreadable++ } catch (_: IllegalArgumentException) { @@ -99,17 +100,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 +128,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 +160,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 +182,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 +198,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 +216,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/ImportText.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt index 3d4ee431..ae056df2 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt @@ -7,9 +7,6 @@ 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/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt index dd040046..95214dbe 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.provider.DocumentsContract import android.provider.OpenableColumns import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.io.asSource import javax.inject.Inject /** @@ -23,7 +24,8 @@ class SafImportFiles @Inject constructor(@param:ApplicationContext private val c fun tree(tree: Uri, limits: ImportLimits = ImportLimits()): ImportFileSource = TreeWalker(SafTree(tree, limits), limits).source() - private fun open(uri: Uri) = context.contentResolver.openInputStream(uri) + /** The platform stream as a kotlinx-io source: the import logic never sees an [java.io.InputStream]. */ + private fun open(uri: Uri) = context.contentResolver.openInputStream(uri)?.asSource() private fun displayName(uri: Uri): String? = try { context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { diff --git a/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt b/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt index 03ee8807..b36b3424 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/TarReader.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt index c46adc58..62b05bdf 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ZipStreamReader.kt @@ -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/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt b/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt new file mode 100644 index 00000000..fb8a252a --- /dev/null +++ b/app/src/main/java/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 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 Android/JVM side of ZipPlatform.kt: the one file that uses java.util.zip. A Kotlin + * Multiplatform build would turn these top-level functions into expect/actual declarations. + */ + +internal 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 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 fun decodeCp437(bytes: ByteArray): String = String(bytes, cp437) + +/** The ZIP at [file], opened with [ZipFile]; throws an [IOException] if it is not one. */ +internal fun openZipFile(file: File): ZipEntrySource = object : ZipEntrySource { + private val archive = ZipFile(file) + + 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 fun zipWriter(out: Sink, now: () -> Long = System::currentTimeMillis): 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/app/src/main/java/com/pasich/encly/data/zip/ZipPlatform.kt b/app/src/main/java/com/pasich/encly/data/zip/ZipPlatform.kt new file mode 100644 index 00000000..416f153a --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/zip/ZipPlatform.kt @@ -0,0 +1,75 @@ +package com.pasich.encly.data.zip + +import kotlinx.io.RawSource + +/* + * 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 Android/JVM implementation 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() +} diff --git a/app/src/test/java/com/pasich/encly/core/backup/BackupFormatTest.kt b/app/src/test/java/com/pasich/encly/core/backup/BackupFormatTest.kt new file mode 100644 index 00000000..14a63886 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/backup/BackupFormatTest.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.core.backup + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** 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) + assertArrayEquals("ENCLYBAK".toByteArray(Charsets.US_ASCII), bytes.copyOfRange(0, 8)) + assertArrayEquals(byteArrayOf(0, 1), bytes.copyOfRange(8, 10)) + assertEquals(1, bytes[10].toInt()) + assertEquals(BackupFormat.SALT_LENGTH, bytes[11].toInt()) + assertArrayEquals(salt, bytes.copyOfRange(12, 44)) + assertEquals(BackupFormat.NONCE_LENGTH, bytes[44].toInt()) + assertArrayEquals(nonce, bytes.copyOfRange(45, 57)) + assertArrayEquals(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) + assertArrayEquals(salt, parsed.header.salt) + assertArrayEquals(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/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt index 3f738018..4162bd64 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupDocumentsTest.kt @@ -8,6 +8,7 @@ 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 kotlinx.io.asSource import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Before @@ -33,14 +34,14 @@ class BackupDocumentsTest { fun readsABackupSizedFromItsHeader() { val input = CountingInputStream(backup) - assertArrayEquals(backup, BackupDocuments.readCapped(input, limit = backup.size)) + assertArrayEquals(backup, BackupDocuments.readCapped(input.asSource(), limit = backup.size)) } @Test fun rejectsAFileDeclaringMoreThanTheLimitWithoutReadingIt() { val input = CountingInputStream(backup) - assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(input, limit = backup.size - 1) } + assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(input.asSource(), limit = backup.size - 1) } assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) } @@ -49,19 +50,19 @@ class BackupDocumentsTest { val header = backup.copyOf(BackupFormat.HEADER_LENGTH) ByteBuffer.wrap(header).putInt(LENGTH_OFFSET, -1) // 0xFFFFFFFF - assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(ByteArrayInputStream(header)) } + assertError(BackupError.TOO_LARGE) { BackupDocuments.readCapped(ByteArrayInputStream(header).asSource()) } } @Test fun rejectsDataPastTheDeclaredEnd() { assertError(BackupError.CORRUPTED) { - BackupDocuments.readCapped(ByteArrayInputStream(backup + byteArrayOf(0))) + BackupDocuments.readCapped(ByteArrayInputStream(backup + byteArrayOf(0)).asSource()) } } @Test fun aShortFileIsLeftForParseToReportTruncated() { - val short = BackupDocuments.readCapped(ByteArrayInputStream(backup.copyOf(backup.size - 1))) + val short = BackupDocuments.readCapped(ByteArrayInputStream(backup.copyOf(backup.size - 1)).asSource()) assertEquals(backup.size - 1, short.size) assertError(BackupError.TRUNCATED) { BackupFormat.parse(short) } @@ -71,7 +72,7 @@ class BackupDocumentsTest { fun otherFilesAreRejectedAfterTheirFirstBytes() { val input = CountingInputStream(ByteArray(1_000_000)) - assertError(BackupError.NOT_A_BACKUP) { BackupDocuments.readCapped(input) } + assertError(BackupError.NOT_A_BACKUP) { BackupDocuments.readCapped(input.asSource()) } assertEquals(BackupFormat.HEADER_LENGTH, input.consumed) } diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt index 24f4fad1..55954021 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -9,6 +9,7 @@ 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 kotlinx.io.asSource import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.fail @@ -43,7 +44,7 @@ class BackupManagerTest { val file = manager.createBackup() assertEquals(size, file.size) - val read = BackupDocuments.readCapped(ByteArrayInputStream(file), limit = size) + val read = BackupDocuments.readCapped(ByteArrayInputStream(file).asSource(), limit = size) assertEquals(size, BackupFormat.parse(read).file.size) } 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 index 0344aeba..341f35a2 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt +++ b/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt @@ -6,18 +6,30 @@ 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.data.zip.zipWriter 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 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 { @@ -48,9 +60,9 @@ class MarkdownExportTest { } private fun exportAll(snapshot: VaultSnapshot): Map { - val out = ByteArrayOutputStream() + val out = Buffer() MarkdownExport.writeAll(snapshot, labels, out) - return unzip(out.toByteArray()) + return unzip(out.readByteArray()) } @Test @@ -185,16 +197,16 @@ class MarkdownExportTest { tags = emptyList(), tasks = emptyList(), ) - val out = ByteArrayOutputStream() + val out = Buffer() assertTrue(MarkdownExport.writeNote(snapshot, 2, labels, out)) - val markdown = out.toString(Charsets.UTF_8) + 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, ByteArrayOutputStream())) - assertFalse(MarkdownExport.writeNote(snapshot, 99, labels, ByteArrayOutputStream())) + assertFalse(MarkdownExport.writeNote(snapshot, 3, labels, Buffer())) + assertFalse(MarkdownExport.writeNote(snapshot, 99, labels, Buffer())) } @Test @@ -217,4 +229,93 @@ class MarkdownExportTest { 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")) { tagId = 7 }, + 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) + } + + 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.tags.firstOrNull { it.id == note.tagId }?.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(), null, 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/handoff/HandoffFixtures.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt index e81e8861..a0849f81 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt +++ b/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt @@ -90,3 +90,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(zip, limits) 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 index f231cf9b..1d5c0adf 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt +++ b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffReaderTest.kt @@ -7,6 +7,10 @@ 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 @@ -17,10 +21,7 @@ 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 { @@ -162,15 +163,93 @@ class MyNotesHandoffReaderTest { @Test fun copyStopsAtTheZipLimit() { - val endless = object : InputStream() { - override fun read(): Int = 0 + 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.copyLimited(endless, ByteArrayOutputStream(), HandoffLimits(maxZipBytes = 4096)) + MyNotesHandoffReader.read(11, HandoffLimits(maxZipBytes = 10)) { error("opened") } } - val out = ByteArrayOutputStream() - MyNotesHandoffReader.copyLimited(ByteArrayInputStream(ByteArray(4096)), out, HandoffLimits(maxZipBytes = 4096)) - assertEquals(4096, out.size()) } } diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt index 0e741e57..24a95c0a 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt @@ -2,7 +2,8 @@ package com.pasich.encly.data.importer import com.pasich.encly.dynamicBlocks.Block import com.pasich.encly.dynamicBlocks.BlockType -import java.io.ByteArrayInputStream +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. */ diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt index 26962aaa..68db7beb 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt +++ b/app/src/test/java/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 { @@ -139,7 +138,7 @@ class ImportReaderTest { 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) @@ -306,7 +305,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 +326,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/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt index 4042e531..0ba4ab02 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/ZipStreamReaderTest.kt @@ -1,5 +1,12 @@ 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 @@ -7,33 +14,25 @@ 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 source(bytes: ByteArray) = Buffer().apply { write(bytes) } + + private fun ZipStreamReader.bytes(): ByteArray = data().buffered().readByteArray() + private fun readAll(zip: ByteArray): List> { - val reader = ZipStreamReader(zip.inputStream()) + val reader = ZipStreamReader(source(zip)) val out = ArrayList>() while (true) { val entry = reader.next() ?: return out - out += entry.name to reader.data().readBytes() + out += entry.name to reader.bytes() } } - @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() + private fun storedZip(data: ByteArray): ByteArray { val out = ByteArrayOutputStream() ZipOutputStream(out).use { zip -> val entry = ZipEntry("s.txt").apply { @@ -46,38 +45,49 @@ class ZipStreamReaderTest { zip.write(data) zip.closeEntry() } - assertArrayEquals(data, readAll(out.toByteArray()).single().second) + 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(zip.inputStream()) + val reader = ZipStreamReader(source(zip)) assertEquals("a.txt", reader.next()?.name) assertEquals("b.txt", reader.next()?.name) - assertEquals("b", String(reader.data().readBytes())) + assertEquals("b", String(reader.bytes())) 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() + 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() - assertThrows(IOException::class.java) { readAll(bytes) } + assertEquals("zip checksum mismatch", failure(bytes)) } @Test @@ -107,7 +117,7 @@ class ZipStreamReaderTest { zip.write(1) zip.closeEntry() } - val reader = ZipStreamReader(out.toByteArray().inputStream()) + val reader = ZipStreamReader(source(out.toByteArray())) assertEquals("d/", reader.next()?.name) assertThrows(IOException::class.java) { reader.next() } } @@ -120,7 +130,7 @@ class ZipStreamReaderTest { bytes[at + 2] == 3.toByte() && bytes[at + 3] == 4.toByte() } bytes[second + 2] = 9 - val reader = ZipStreamReader(bytes.inputStream()) + val reader = ZipStreamReader(source(bytes)) assertEquals("a.txt", reader.next()?.name) assertThrows(IOException::class.java) { reader.next() } } @@ -129,13 +139,13 @@ class ZipStreamReaderTest { 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 + 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(ByteArray(0).inputStream()).next()) + assertNull(ZipStreamReader(source(ByteArray(0))).next()) } @Test @@ -157,4 +167,93 @@ class ZipStreamReaderTest { 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/presentation/viewmodel/MarkdownExportViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt index 8e3d0483..9505e06e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt @@ -18,6 +18,9 @@ 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 @@ -32,7 +35,6 @@ 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) @@ -54,7 +56,7 @@ class MarkdownExportViewModelTest { documents = mock(MarkdownDocuments::class.java) doAnswer { invocation -> @Suppress("UNCHECKED_CAST") - (invocation.getArgument(1) as (OutputStream) -> Unit)(written) + writeTo(invocation.getArgument(1) as (Sink) -> Unit) }.`when`(documents).write(any(Uri::class.java) ?: uri, anyBlock()) store = InMemoryVaultDataStore().apply { notes += Note(id = 1, title = "Groceries", value = "[]", uid = "n1", date = 5L) @@ -77,7 +79,17 @@ class MarkdownExportViewModelTest { strings = mock(AppStrings::class.java) { "label" }, ) - private fun anyBlock(): (OutputStream) -> Unit = any() ?: {} + 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 @@ -364,7 +376,7 @@ class MarkdownExportViewModelTest { // 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) } + runCatching { writeTo(invocation.getArgument(1) as (Sink) -> Unit) } .getOrElse { throw it } }.`when`(documents).write(any(Uri::class.java) ?: uri, anyBlock()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed57c6d2..021af707 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ reorderable = "3.1.0" datastore = "1.2.1" serializationJson = "1.11.0" datetime = "0.8.0" +kotlinxIo = "0.9.1" sqlcipher = "4.19.1" sealite = "1.0.0" sqliteKtx = "2.7.1" @@ -69,6 +70,8 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine 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" } 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-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } From 6efad424b25492f392bab79871661dfa23eeaff8 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 08:58:12 +0200 Subject: [PATCH 06/75] refactor(security): one internal crypto API in front of JCA 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. --- .../pasich/encly/core/backup/BackupCipher.kt | 38 +- .../com/pasich/encly/core/crypto/Crypto.kt | 83 ++++ .../encly/core/crypto/CryptoException.kt | 4 + .../com/pasich/encly/core/crypto/JcaCrypto.kt | 98 ++++ .../core/security/AuthenticationManager.kt | 30 +- .../pasich/encly/core/security/DuressSlots.kt | 29 +- .../com/pasich/encly/core/security/Hkdf.kt | 21 +- .../pasich/encly/core/security/Mnemonic.kt | 18 +- .../pasich/encly/core/security/PinSlots.kt | 59 +-- .../encly/core/security/SecurityManager.kt | 4 +- .../encly/core/security/SeedPhraseManager.kt | 32 +- .../com/pasich/encly/core/security/Utf8.kt | 17 + .../pasich/encly/core/security/VaultStore.kt | 8 +- .../pasich/encly/core/security/bip39/Bip39.kt | 8 +- .../encly/core/security/bip39/JvmSha256.kt | 8 - .../pasich/encly/core/crypto/CryptoTest.kt | 232 +++++++++ .../encly/core/security/CryptoGoldenTest.kt | 466 ++++++++++++++++++ .../encly/core/security/GoldenVectors.kt | 117 +++++ 18 files changed, 1094 insertions(+), 178 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/crypto/Crypto.kt create mode 100644 app/src/main/java/com/pasich/encly/core/crypto/CryptoException.kt create mode 100644 app/src/main/java/com/pasich/encly/core/crypto/JcaCrypto.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/Utf8.kt delete mode 100644 app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt create mode 100644 app/src/test/java/com/pasich/encly/core/crypto/CryptoTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/CryptoGoldenTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/GoldenVectors.kt 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 index 69c81e9c..ca46e65b 100644 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupCipher.kt +++ b/app/src/main/java/com/pasich/encly/core/backup/BackupCipher.kt @@ -1,11 +1,7 @@ package com.pasich.encly.core.backup +import com.pasich.encly.core.crypto.Crypto 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 @@ -24,12 +20,9 @@ sealed class BackupSecret { * 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 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, @@ -38,10 +31,7 @@ object BackupCipher { ) 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) + header + Crypto.aesGcmSeal(key, nonce, header, plaintext) } finally { SensitiveDataCleaner.clear(key) } @@ -58,21 +48,21 @@ object BackupCipher { 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) + // 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 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) diff --git a/app/src/main/java/com/pasich/encly/core/crypto/Crypto.kt b/app/src/main/java/com/pasich/encly/core/crypto/Crypto.kt new file mode 100644 index 00000000..af7815a6 --- /dev/null +++ b/app/src/main/java/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 [JcaCrypto], + * 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. + */ +internal object Crypto { + const val KEY_BYTES = 32 + const val NONCE_BYTES = 12 + const val TAG_BYTES = 16 + const val SHA256_BYTES = 32 + + /** [size] bytes from the platform CSPRNG. */ + fun randomBytes(size: Int): ByteArray = JcaCrypto.randomBytes(size) + + /** A uniformly random `Long` from the platform CSPRNG. */ + 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. */ + fun sha256(data: ByteArray, startIndex: Int = 0, endIndex: Int = data.size): ByteArray = + JcaCrypto.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. + */ + fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(salt.isNotEmpty()) + require(length in 1..SHA256_BYTES) + return JcaCrypto.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]. + */ + fun pbkdf2HmacSha256(password: CharArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + require(iterations > 0 && length > 0) + return JcaCrypto.pbkdf2HmacSha256(password, salt, iterations, length) + } + + /** [pbkdf2HmacSha256] of [password] read as UTF-8 text (the characters made from it are wiped). */ + fun pbkdf2HmacSha256(password: ByteArray, salt: ByteArray, iterations: Int, length: Int): ByteArray { + require(iterations > 0 && length > 0) + return JcaCrypto.pbkdf2HmacSha256(password, salt, iterations, length) + } + + /** AES-256-GCM: `ciphertext ‖ tag` of [plaintext] under [key] and [nonce], with [aad] authenticated. */ + fun aesGcmSeal(key: ByteArray, nonce: ByteArray, aad: ByteArray, plaintext: ByteArray): ByteArray { + requireKeyAndNonce(key, nonce) + return JcaCrypto.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). + */ + fun aesGcmOpen(key: ByteArray, nonce: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray? { + requireKeyAndNonce(key, nonce) + return JcaCrypto.aesGcmOpen(key, nonce, aad, ciphertext) + } + + /** True when [a] and [b] are equal; the time taken depends on their lengths, not on where they differ. */ + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean = JcaCrypto.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/app/src/main/java/com/pasich/encly/core/crypto/CryptoException.kt b/app/src/main/java/com/pasich/encly/core/crypto/CryptoException.kt new file mode 100644 index 00000000..a1081402 --- /dev/null +++ b/app/src/main/java/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. */ +internal class CryptoException(cause: Throwable) : RuntimeException(cause) diff --git a/app/src/main/java/com/pasich/encly/core/crypto/JcaCrypto.kt b/app/src/main/java/com/pasich/encly/core/crypto/JcaCrypto.kt new file mode 100644 index 00000000..6f11f813 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/crypto/JcaCrypto.kt @@ -0,0 +1,98 @@ +package com.pasich.encly.core.crypto + +import com.pasich.encly.core.security.SensitiveDataCleaner +import java.nio.ByteBuffer +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) + if (buffer.hasArray()) SensitiveDataCleaner.clear(buffer.array()) + return try { + pbkdf2HmacSha256(chars, salt, iterations, length) + } finally { + SensitiveDataCleaner.clear(chars) + } + } + + 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 +} diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index 3aba81ae..687fc10f 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -1,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,10 +18,6 @@ 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 @@ -177,7 +175,7 @@ class AuthenticationManager @Inject constructor( 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 +212,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) @@ -652,7 +650,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). @@ -678,7 +676,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) @@ -759,7 +757,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 +775,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 +785,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 { @@ -895,14 +893,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 { diff --git a/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt index c394f28b..51d3391b 100644 --- a/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt +++ b/app/src/main/java/com/pasich/encly/core/security/DuressSlots.kt @@ -1,16 +1,10 @@ package com.pasich.encly.core.security -import com.pasich.encly.core.security.PinSlots.AES_GCM +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.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 @@ -33,13 +27,12 @@ internal object DuressSlots { /** `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) + val iv = Crypto.randomBytes(IV_LENGTH) + val encrypted = Crypto.aesGcmSeal(kek, iv, aad, dek) return try { - byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + byteArrayOf(SLOT_VERSION) + iv + encrypted } finally { + SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(encrypted) } } @@ -49,12 +42,7 @@ internal object DuressSlots { 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 + Crypto.aesGcmOpen(kek, iv, aad, ciphertext) } finally { SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(ciphertext) @@ -64,8 +52,5 @@ internal object DuressSlots { 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 - } + fun randomSlot(): ByteArray = Crypto.randomBytes(DECOY_SLOT_LENGTH).also { 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 index 8e191d72..4a870327 100644 --- a/app/src/main/java/com/pasich/encly/core/security/Hkdf.kt +++ b/app/src/main/java/com/pasich/encly/core/security/Hkdf.kt @@ -1,7 +1,6 @@ package com.pasich.encly.core.security -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec +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 @@ -9,27 +8,11 @@ import javax.crypto.spec.SecretKeySpec * 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) - } + return Crypto.hkdfSha256(ikm, salt, info, length) } } 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 index 633d9c7f..0293ba97 100644 --- a/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt +++ b/app/src/main/java/com/pasich/encly/core/security/Mnemonic.kt @@ -1,9 +1,7 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.core.security.bip39.Bip39 -import java.nio.CharBuffer -import java.security.MessageDigest -import java.security.SecureRandom /** * The one normalization every recovery-phrase key goes through: the vault's recovery KEK and @@ -14,7 +12,7 @@ internal object Mnemonic { /** A new 12-word recovery phrase from 128 bits of system randomness. */ fun generate(): CharArray { - val entropy = ByteArray(ENTROPY_BYTES).also { SecureRandom().nextBytes(it) } + val entropy = Crypto.randomBytes(ENTROPY_BYTES) return try { Bip39().encode(entropy) } finally { @@ -48,20 +46,12 @@ internal object Mnemonic { /** 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) + val utf8 = Utf8.encode(normalized) SensitiveDataCleaner.clear(normalized) return try { - MessageDigest.getInstance("SHA-256").digest(utf8) + Crypto.sha256(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/PinSlots.kt b/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt index 3561791c..52533290 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PinSlots.kt @@ -1,10 +1,6 @@ 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 +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: @@ -17,27 +13,23 @@ 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 + 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 - 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 + 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) } } @@ -52,33 +44,25 @@ internal object PinSlots { 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 - } + /** 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 = 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 marker = Crypto.randomBytes(WIPE_MARKER_LENGTH) + val iv = Crypto.randomBytes(IV_LENGTH) val encrypted = try { - cipher.doFinal(marker) + Crypto.aesGcmSeal(wipeKek, iv, wipeAad(salt), marker) } finally { SensitiveDataCleaner.clear(marker) } return try { - byteArrayOf(SLOT_VERSION) + cipher.iv + encrypted + byteArrayOf(SLOT_VERSION) + iv + encrypted } finally { + SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(encrypted) } } @@ -89,13 +73,9 @@ internal object PinSlots { 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 + val marker = Crypto.aesGcmOpen(wipeKek, iv, wipeAad(salt), ciphertext) + if (marker != null) SensitiveDataCleaner.clear(marker) + marker != null } finally { SensitiveDataCleaner.clear(iv) SensitiveDataCleaner.clear(ciphertext) @@ -105,10 +85,7 @@ internal object PinSlots { 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 - } + 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() { diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 6f42bdf1..48a82487 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -3,11 +3,11 @@ 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.core.crypto.Crypto 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 @@ -97,7 +97,7 @@ class SecurityManager @Inject constructor( // (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()) + private val eraseEpoch = AtomicLong(Crypto.randomLong()) var securityStatus = InitialStatus.NO diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt index 7216ab88..9da63d44 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -1,13 +1,8 @@ package com.pasich.encly.core.security import com.pasich.encly.core.backup.BackupKeys +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.core.security.bip39.Bip39 -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 @@ -37,7 +32,6 @@ 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 @@ -77,7 +71,7 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { 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 { @@ -128,8 +122,6 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { val kek = deriveRecoveryKek(phrase) return try { unwrap(wrapped, kek, RECOVERY_AAD) - } catch (_: GeneralSecurityException) { - null } finally { SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(wrapped) @@ -155,8 +147,6 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { val kek = deriveBackupSlotKek(dek) return try { unwrap(wrapped, kek, BACKUP_SLOT_AAD) - } catch (_: GeneralSecurityException) { - null } finally { SensitiveDataCleaner.clear(kek) SensitiveDataCleaner.clear(wrapped) @@ -182,7 +172,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) } @@ -264,27 +254,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) diff --git a/app/src/main/java/com/pasich/encly/core/security/Utf8.kt b/app/src/main/java/com/pasich/encly/core/security/Utf8.kt new file mode 100644 index 00000000..2043ac35 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/Utf8.kt @@ -0,0 +1,17 @@ +package com.pasich.encly.core.security + +import java.nio.CharBuffer + +internal object Utf8 { + /** + * [chars] in UTF-8, in a fresh array the caller wipes. No String copy is made, and the + * encoder's intermediate buffer is zeroized before returning. + */ + fun encode(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/VaultStore.kt b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt index 65805572..bc14102a 100644 --- a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt +++ b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt @@ -1,5 +1,6 @@ package com.pasich.encly.core.security +import com.pasich.encly.core.crypto.Crypto import java.io.ByteArrayOutputStream import java.io.DataInputStream import java.io.DataOutputStream @@ -10,7 +11,6 @@ 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. @@ -198,7 +198,7 @@ class VaultStore(private val file: File) { } } val body = buffer.toByteArray() - val digest = MessageDigest.getInstance("SHA-256").digest(body) + val digest = Crypto.sha256(body) return try { body + digest } finally { @@ -212,8 +212,8 @@ class VaultStore(private val file: File) { 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 expected = Crypto.sha256(bytes, 0, bodyLength) + if (!Crypto.constantTimeEquals(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 diff --git a/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt index e2133735..6e7f4b19 100644 --- a/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt +++ b/app/src/main/java/com/pasich/encly/core/security/bip39/Bip39.kt @@ -1,5 +1,6 @@ package com.pasich.encly.core.security.bip39 +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.core.security.SensitiveDataCleaner /** @@ -7,10 +8,11 @@ import com.pasich.encly.core.security.SensitiveDataCleaner * 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 Kotlin plus a [Sha256], so it can move to common code. Phrases are char arrays and every - * intermediate array is wiped; arrays handed back belong to the caller. + * Plain Kotlin plus a [Sha256] (the app's [Crypto] by default), so it can move to common code. + * Phrases are char arrays and every intermediate array is wiped; arrays handed back belong to + * the caller. */ -internal class Bip39(private val sha256: Sha256 = JvmSha256) { +internal class Bip39(private val sha256: Sha256 = Sha256 { Crypto.sha256(it) }) { /** * The phrase for [entropy] (16, 20, 24, 28 or 32 bytes: 12 to 24 words), words joined by one diff --git a/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt b/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt deleted file mode 100644 index 8c6cb2b2..00000000 --- a/app/src/main/java/com/pasich/encly/core/security/bip39/JvmSha256.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.pasich.encly.core.security.bip39 - -import java.security.MessageDigest - -/** The only JVM-specific line of the BIP-39 code; swap it for a common SHA-256 when there is one. */ -internal object JvmSha256 : Sha256 { - override fun digest(input: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(input) -} diff --git a/app/src/test/java/com/pasich/encly/core/crypto/CryptoTest.kt b/app/src/test/java/com/pasich/encly/core/crypto/CryptoTest.kt new file mode 100644 index 00000000..d946f005 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/crypto/CryptoTest.kt @@ -0,0 +1,232 @@ +package com.pasich.encly.core.crypto + +import com.pasich.encly.core.security.GoldenVectors +import com.pasich.encly.core.security.PinSlots +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The internal crypto API against published vectors (RFC 5869, RFC 7914, McGrew and Viega + * AES-GCM test cases 15 and 16, FIPS 180 SHA-256) and against the exact bytes the app's formats + * hold on disk, sealed again here with the nonces of those fixtures. + */ +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) + private val version = byteArrayOf(3) + + // --- 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) + + assertArrayEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) + assertArrayEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) + } + + @Test + fun hkdfRefusesWhatTheAppNeverUses() { + assertThrows(IllegalArgumentException::class.java) { Crypto.hkdfSha256(dek, ByteArray(0), salt, 32) } + assertThrows(IllegalArgumentException::class.java) { Crypto.hkdfSha256(dek, salt, salt, 33) } + assertThrows(IllegalArgumentException::class.java) { Crypto.hkdfSha256(dek, salt, salt, 0) } + } + + @Test + fun pbkdf2MatchesRfc7914() { + // RFC 7914 section 11, first 32 bytes of the 64-byte outputs. + assertArrayEquals( + GoldenVectors.rfc7914C1, + Crypto.pbkdf2HmacSha256("passwd".toByteArray(), "salt".toByteArray(), 1, 32), + ) + assertArrayEquals( + GoldenVectors.rfc7914C80000, + Crypto.pbkdf2HmacSha256("Password".toByteArray(), "NaCl".toByteArray(), 80_000, 32), + ) + } + + @Test + fun pbkdf2KeepsTheAppsPinParameters() { + val stretched = Crypto.pbkdf2HmacSha256("123456".toByteArray(), salt, 600_000, 32) + + assertArrayEquals(GoldenVectors.pinSw, stretched) + } + + @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) + assertArrayEquals(GoldenVectors.gcmTc15, tc15) + assertArrayEquals(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)) + assertArrayEquals(GoldenVectors.gcmTc16, tc16) + assertArrayEquals(plaintext.copyOf(60), Crypto.aesGcmOpen(key, nonce, aad, tc16)) + } + + @Test + fun sha256MatchesFips180() { + assertArrayEquals(GoldenVectors.sha256Abc, Crypto.sha256("abc".toByteArray())) + assertArrayEquals(GoldenVectors.sha256Abc, Crypto.sha256("xxabcyy".toByteArray(), 2, 5)) + assertEquals(32, Crypto.sha256(ByteArray(0)).size) + } + + // --- AES-GCM behaviour ----------------------------------------------------------------- + + @Test + fun aGcmOpenIsNullForAnythingThatDoesNotAuthenticate() { + val sealed = Crypto.aesGcmSeal(kek, iv, salt, dek) + + assertArrayEquals(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) + assertArrayEquals(ByteArray(0), Crypto.aesGcmOpen(kek, iv, salt, sealed)) + } + + @Test + fun theKeyAndTheNonceMustHaveTheSizesTheFormatsUse() { + assertThrows(IllegalArgumentException::class.java) { Crypto.aesGcmSeal(ByteArray(16), iv, salt, dek) } + assertThrows(IllegalArgumentException::class.java) { Crypto.aesGcmSeal(kek, ByteArray(16), salt, dek) } + assertThrows(IllegalArgumentException::class.java) { Crypto.aesGcmOpen(ByteArray(31), iv, salt, dek) } + assertThrows(IllegalArgumentException::class.java) { Crypto.aesGcmOpen(kek, ByteArray(11), salt, dek) } + } + + // --- The formats on disk, sealed again with the fixtures' nonces --------------------------- + + @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), + ) + } + + // --- 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/app/src/test/java/com/pasich/encly/core/security/CryptoGoldenTest.kt b/app/src/test/java/com/pasich/encly/core/security/CryptoGoldenTest.kt new file mode 100644 index 00000000..8c2ef3b2 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/CryptoGoldenTest.kt @@ -0,0 +1,466 @@ +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.BackupSecret +import com.pasich.encly.testutil.FakeLockoutClock +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.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * 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\"]}".toByteArray() + + // --- 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, + ) + + assertArrayEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) + assertArrayEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) + } + + @Test + fun hkdfShorterOutputIsThePrefixOfTheLongerOne() { + val full = Hkdf.sha256(dek, salt, "info".toByteArray(), 32) + val short = Hkdf.sha256(dek, salt, "info".toByteArray(), 16) + + assertArrayEquals(full.copyOf(16), short) + } + + @Test + fun hkdfRefusesAnEmptySaltAndAnOutputLongerThanOneBlock() { + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, ByteArray(0), ByteArray(1), 32) } + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, salt, ByteArray(1), 33) } + assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, salt, ByteArray(1), 0) } + } + + @Test + fun theKeyHierarchyDerivesTheGoldenKeys() { + val stretchedAndHardware = GoldenVectors.pinSw + GoldenVectors.pinHw + + assertArrayEquals(GoldenVectors.decoyKeyKek, DuressSlots.decoyKeyKek(dek)) + assertArrayEquals(GoldenVectors.pinKek, pinKey(stretchedAndHardware, "encly/pin/kek/v3")) + assertArrayEquals(GoldenVectors.pinWipeKek, pinKey(stretchedAndHardware, "encly/pin/wipe/v3")) + assertArrayEquals(GoldenVectors.pinDecoyKek, pinKey(stretchedAndHardware, "encly/pin/decoy/v3")) + } + + private fun pinKey(ikm: ByteArray, info: String) = Hkdf.sha256(ikm, salt, info.toByteArray(), 32) + + // --- Recovery phrase, SHA-256 --------------------------------------------------------- + + @Test + fun seedHashIsSha256OfTheNormalizedWords() { + // FIPS 180-2 "abc". + assertArrayEquals(GoldenVectors.sha256Abc, Mnemonic.seedHash("abc".toCharArray())) + assertArrayEquals(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" + + assertArrayEquals(GoldenVectors.seedHash, Mnemonic.seedHash(messy.toCharArray())) + } + + @Test + fun seedHashOfNonAsciiWordsIsSha256OfTheirUtf8() { + val words = "café über" + + assertArrayEquals( + MessageDigest.getInstance("SHA-256").digest(words.toByteArray(Charsets.UTF_8)), + Mnemonic.seedHash(words.toCharArray()), + ) + } + + @Test + fun backupKeysDeriveTheGoldenRootAndFileKey() { + val root = BackupKeys.rootFromMnemonic(PHRASE.toCharArray()) + + assertArrayEquals(GoldenVectors.backupRoot, root) + assertArrayEquals(GoldenVectors.backupFileKey, BackupKeys.fileKeyFromRoot(root, backupSalt)) + } + + // --- PIN, wipe, duress and decoy-key slots -------------------------------------------- + + @Test + fun theGoldenPinSlotOpensWithItsKek() { + val parsed = PinSlots.parseSlot(GoldenVectors.pinSlot)!! + + assertArrayEquals(salt, parsed.salt) + assertArrayEquals(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()) + assertArrayEquals(salt, slot.copyOfRange(1, 17)) + val aad = "encly/pin/slot/v3".toByteArray() + byteArrayOf(3) + salt + assertArrayEquals(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".toByteArray() + 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) + + assertArrayEquals("encly/pin/decoy-slot/v3".toByteArray() + byteArrayOf(3) + salt, aad) + assertArrayEquals(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 + + assertArrayEquals(decoyDek, DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_AAD)) + assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_OFF_AAD)) + assertArrayEquals( + decoyDek, + DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_OFF_AAD), + ) + assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_AAD)) + assertArrayEquals("encly/decoy/key/v1".toByteArray() + byteArrayOf(3), DuressSlots.DECOY_KEY_AAD) + assertArrayEquals("encly/decoy/key/off/v1".toByteArray() + 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()) + assertArrayEquals(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, + ) + + assertArrayEquals(dek, manager.unlockWithSeed(PHRASE.toCharArray())) + assertArrayEquals(dek, manager.unlockWithSeed(MESSY_PHRASE.toCharArray())) + assertNull(manager.unlockWithSeed(OTHER_PHRASE.toCharArray())) + assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(dek)) + assertNull(manager.unwrapBackupRoot(seq(32, 1, 1))) + } + + @Test + fun theSlotsAVaultWritesOpenWithAnIndependentImplementation() { + val store = VaultStore(tempVaultFile()) + 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) + assertArrayEquals( + bootstrap, + Reference.gcmOpen( + GoldenVectors.recoveryKek, + recovery.copyOf(12), + "encly/recovery/slot/v3".toByteArray(), + recovery.copyOfRange(12, recovery.size), + ), + ) + val slotKek = Reference.hkdf(bootstrap, "encly/backup/slot/salt/v1", "encly/backup/slot/kek/v1") + assertArrayEquals( + GoldenVectors.backupRoot, + Reference.gcmOpen( + slotKek, + backup.copyOf(12), + "encly/backup/slot/v1".toByteArray(), + backup.copyOfRange(12, backup.size), + ), + ) + } + + @Test + fun aVaultOpensItsOwnBackupKeyAndAddsOneForTheRightPhraseOnly() { + val store = VaultStore(tempVaultFile()) + val manager = SeedPhraseManager(store) + assertTrue(manager.initializeVault(PHRASE.toCharArray())) + val bootstrap = manager.copyBootstrapKey()!! + + assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + assertFalse(manager.createBackupKey(OTHER_PHRASE.toCharArray(), bootstrap)) + assertTrue(manager.createBackupKey(PHRASE.toCharArray(), bootstrap)) + assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + } + + // --- Backup file ---------------------------------------------------------------------- + + @Test + fun theGoldenBackupFileOpensWithThePhraseAndWithTheRoot() { + assertArrayEquals( + payload, + BackupCipher.open(GoldenVectors.backupFile.copyOf(), BackupSecret.RecoveryPhrase(PHRASE.toCharArray())), + ) + assertArrayEquals( + payload, + BackupCipher.open(goldenFile(), BackupSecret.RecoveryPhrase(MESSY_PHRASE.toCharArray())), + ) + assertArrayEquals( + 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) + assertArrayEquals("ENCLYBAK".toByteArray(), 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)) + assertArrayEquals(payload, opened) + val root = BackupSecret.RecoveryRoot(GoldenVectors.backupRoot.copyOf()) + assertArrayEquals(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 file = tempVaultFile() + val store = VaultStore(file) + + assertTrue( + store.edit { + putBytes("a", byteArrayOf(1, 2, 3)) + putBytes("bc", ByteArray(0)) + }, + ) + + assertArrayEquals(GoldenVectors.storeFile, file.readBytes()) + } + + @Test + fun theGoldenSlotFileIsReadBackAndAChangedByteMakesItCorrupt() { + val intact = tempVaultFile().also { it.writeBytes(GoldenVectors.storeFile) } + val store = VaultStore(intact) + + assertFalse(store.isCorrupt()) + assertArrayEquals(byteArrayOf(1, 2, 3), store.getBytes("a")) + assertArrayEquals(ByteArray(0), store.getBytes("bc")) + + for (index in intArrayOf(10, GoldenVectors.storeFile.size - 1)) { + val damaged = tempVaultFile().also { + it.writeBytes(GoldenVectors.storeFile.copyOf().also { bytes -> bytes[index] = flipped(bytes[index]) }) + } + assertTrue(VaultStore(damaged).isCorrupt()) + } + } + + // --- The whole PIN chain: PBKDF2 (600 000) -> HKDF -> AES-GCM ---------------------------- + + @Test + fun theGoldenPinAndDuressSlotsUnlockThroughTheFullKdfChain() { + val store = VaultStore(tempVaultFile()) + 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()) + + assertArrayEquals(dek, (real as PinUnlock.Success).dek) + assertArrayEquals(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 = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + + override fun delete(slot: PinKeySlot) = Unit + } + + // --- Helpers -------------------------------------------------------------------------- + + private fun vaultWith(vararg slots: Pair): SeedPhraseManager { + val store = VaultStore(tempVaultFile()) + 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 = assertThrows(BackupException::class.java) { block() } + assertEquals(BackupError.WRONG_SECRET, e.error) + } + + /** Plain JCA, to open what the code under test sealed. */ + private object Reference { + fun gcmOpen(key: ByteArray, iv: ByteArray, aad: ByteArray, ciphertext: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + cipher.updateAAD(aad) + return cipher.doFinal(ciphertext) + } + + fun hkdf(ikm: ByteArray, salt: String, info: String): ByteArray = hkdfBytes(ikm, salt.toByteArray(), info) + + fun hkdfBytes(ikm: ByteArray, salt: ByteArray, info: String): ByteArray { + val prk = hmac(salt, ikm) + return hmac(prk, info.toByteArray() + byteArrayOf(1)) + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray = Mac.getInstance("HmacSHA256").run { + init(SecretKeySpec(key, "HmacSHA256")) + doFinal(data) + } + } + + 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/app/src/test/java/com/pasich/encly/core/security/GoldenVectors.kt b/app/src/test/java/com/pasich/encly/core/security/GoldenVectors.kt new file mode 100644 index 00000000..c181794f --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/GoldenVectors.kt @@ -0,0 +1,117 @@ +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", + ) + + 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() + } +} From 92ffa59cecb91f1626c63f3e8e65c8bee5fcac3e Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 09:49:11 +0200 Subject: [PATCH 07/75] refactor(di): Metro instead of Hilt 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. --- .github/workflows/android.yml | 2 +- CONTRIBUTING.md | 2 +- README.md | 2 +- app/build.gradle.kts | 34 +-- app/detekt-baseline.xml | 30 +-- app/proguard-rules.pro | 4 +- .../pasich/encly/ImportFromMyNotesActivity.kt | 37 ++- .../java/com/pasich/encly/MainActivity.kt | 29 +- .../java/com/pasich/encly/MyApplication.kt | 16 +- .../pasich/encly/core/common/AppStrings.kt | 12 +- .../java/com/pasich/encly/core/di/AppGraph.kt | 69 +++++ .../pasich/encly/core/di/CoroutineModule.kt | 18 +- .../pasich/encly/core/di/DatabaseModule.kt | 21 +- .../pasich/encly/core/di/SecurityModule.kt | 39 ++- .../core/security/AuthenticationManager.kt | 10 +- .../pasich/encly/core/security/AutoLock.kt | 10 +- .../encly/core/security/BiometricManager.kt | 15 +- .../encly/core/security/DeviceLockWatcher.kt | 13 +- .../encly/core/security/KeyboardPrivacy.kt | 10 +- .../encly/core/security/LockoutClock.kt | 14 +- .../encly/core/security/PinHardwareFactor.kt | 10 +- .../encly/core/security/SecurityManager.kt | 12 +- .../encly/core/security/SeedPhraseManager.kt | 10 +- .../encly/core/security/SessionLockManager.kt | 10 +- .../encly/data/backup/BackupDocuments.kt | 12 +- .../pasich/encly/data/backup/BackupManager.kt | 10 +- .../encly/data/backup/BackupPhraseSetup.kt | 10 +- .../encly/data/backup/PendingRestore.kt | 10 +- .../encly/data/backup/RoomVaultDataStore.kt | 2 +- .../data/database/SecureDatabaseManager.kt | 13 +- .../local/SettingsLocalDataSource.kt | 10 +- .../pasich/encly/data/di/LocalDataModule.kt | 18 +- .../pasich/encly/data/di/RepositoryModule.kt | 22 +- .../encly/data/export/MarkdownDocuments.kt | 12 +- .../handoff/CacheHandoffStagingSweeper.kt | 13 +- .../encly/data/importer/SafImportFiles.kt | 7 +- .../data/repository/NotesRepositoryImpl.kt | 5 +- .../data/repository/SettingsRepositoryImpl.kt | 11 +- .../data/repository/TagsRepositoryImpl.kt | 5 +- .../data/repository/TasksRepositoryImpl.kt | 5 +- .../encly/domain/usecase/OnboardingUseCase.kt | 10 +- .../usecase/note/CleanTrashNotesUseCase.kt | 5 +- .../usecase/note/ObserveNotesUseCase.kt | 5 +- .../note/UpdateNoteArchiveStatusUseCase.kt | 5 +- .../usecase/note/UpdateNoteColorUseCase.kt | 5 +- .../note/UpdateNoteDescriptionUseCase.kt | 5 +- .../note/UpdateNotePinStatusUseCase.kt | 5 +- .../usecase/note/UpdateNoteTagUseCase.kt | 5 +- .../note/UpdateNoteTrashStatusUseCase.kt | 5 +- .../domain/usecase/tag/ReorderTagsUseCase.kt | 5 +- .../usecase/task/UpdateTaskStatusUseCase.kt | 5 +- .../presentation/components/HomeTaskWidget.kt | 4 +- .../components/editNote/NoteBottomBar.kt | 4 +- .../encly/presentation/di/EditorModule.kt | 12 +- .../dialogs/EditNoteBottomSheet.kt | 4 +- .../dialogs/NoteCardBottomSheet.kt | 4 +- .../dialogs/NotesSortBottomSheet.kt | 4 +- .../encly/presentation/drawer/MainDrawer.kt | 4 +- .../encly/presentation/navigation/NavHost.kt | 6 +- .../presentation/screen/EditTagScreen.kt | 4 +- .../presentation/screen/LicensesScreen.kt | 2 +- .../encly/presentation/screen/LockScreen.kt | 6 +- .../encly/presentation/screen/MainScreen.kt | 12 +- .../presentation/screen/NotesListScreen.kt | 4 +- .../screen/PinCodeConfigScreen.kt | 4 +- .../presentation/screen/TagsListScreen.kt | 4 +- .../encly/presentation/screen/TasksScreen.kt | 4 +- .../screen/archive/ArchiveScreen.kt | 6 +- .../screen/backup/BackupScreen.kt | 6 +- .../screen/backup/MarkdownExportHost.kt | 4 +- .../screen/editnote/EditNoteScreen.kt | 20 +- .../screen/editnote/EditorEffects.kt | 14 +- .../screen/editnote/EditorFindBar.kt | 4 +- .../screen/editnote/EditorSheets.kt | 8 +- .../screen/editnote/TemplateChooser.kt | 4 +- .../screen/handoff/ImportFromMyNotesScreen.kt | 4 +- .../screen/importnotes/ImportNotesScreen.kt | 4 +- .../screen/onboarding/OnboardingScreen.kt | 6 +- .../screen/settings/AppearanceScreen.kt | 4 +- .../screen/settings/DuressPinScreen.kt | 4 +- .../screen/settings/SecuritySettingsScreen.kt | 6 +- .../screen/settings/SettingsScreen.kt | 4 +- .../screen/settings/WipePinScreen.kt | 4 +- .../screen/trash/TrashNotesList.kt | 4 +- .../presentation/screen/trash/TrashScreen.kt | 4 +- .../viewmodel/AppearanceViewModel.kt | 13 +- .../viewmodel/ArchiveViewModel.kt | 13 +- .../viewmodel/AuthSetupViewModel.kt | 13 +- .../presentation/viewmodel/BackupViewModel.kt | 13 +- .../viewmodel/EditNoteViewModel.kt | 31 ++- .../viewmodel/ImportFromMyNotesViewModel.kt | 29 +- .../viewmodel/ImportNotesViewModel.kt | 13 +- .../viewmodel/LockNowViewModel.kt | 13 +- .../presentation/viewmodel/LockViewModel.kt | 13 +- .../viewmodel/LossRecoveryViewModel.kt | 13 +- .../viewmodel/MainListStateViewModel.kt | 13 +- .../viewmodel/MarkdownExportViewModel.kt | 13 +- .../viewmodel/NoteListViewModel.kt | 15 +- .../viewmodel/NoteSearchViewModel.kt | 17 +- .../viewmodel/OnboardingViewModel.kt | 13 +- .../viewmodel/PreloadedViewModelOwner.kt | 26 -- .../viewmodel/SecuritySettingsViewModel.kt | 13 +- .../viewmodel/SelectedTagHolder.kt | 10 +- .../viewmodel/SettingsViewModel.kt | 13 +- .../viewmodel/StatisticViewModel.kt | 14 +- .../viewmodel/TagListViewModel.kt | 13 +- .../presentation/viewmodel/TasksViewModel.kt | 27 +- .../presentation/viewmodel/ThemeViewModel.kt | 13 +- .../presentation/viewmodel/TrashViewModel.kt | 13 +- .../java/com/pasich/encly/ui/theme/Theme.kt | 4 +- .../pasich/encly/utils/DeviceCapabilities.kt | 12 +- .../com/pasich/encly/core/di/AppGraphTest.kt | 252 ++++++++++++++++++ .../encly/core/di/NavigationViewModelsTest.kt | 96 +++++++ .../encly/ui/screens/ComposeScreenTest.kt | 20 +- .../com/pasich/encly/ui/screens/TestApp.kt | 2 +- build.gradle.kts | 2 +- docs/architecture.md | 4 +- gradle/libs.versions.toml | 10 +- 118 files changed, 1118 insertions(+), 507 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/di/AppGraph.kt delete mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/PreloadedViewModelOwner.kt create mode 100644 app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/di/NavigationViewModelsTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b7c5c6aa..086c04e5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -66,7 +66,7 @@ jobs: :app:detekt :app:assembleFdroidRelease - # Separate invocation: lint running alongside Hilt code generation for another variant has + # 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1b98767..6e6142b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,7 +61,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 diff --git a/README.md b/README.md index 7320ea86..f76b49ab 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ 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 · +Kotlin · Jetpack Compose · Material 3 · Metro · Room · SQLCipher · Coroutines · kotlinx.serialization · BIP39 (own implementation) Clean architecture: `presentation` → `domain` → `data`, with `core/security` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c581c4c8..48ae2435 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,7 +38,7 @@ val hasReleaseSigning = listOf( plugins { alias(libs.plugins.android.application) - alias(libs.plugins.hilt) + alias(libs.plugins.metro) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) @@ -164,10 +165,6 @@ android { } } - hilt { - enableAggregatingTask = true - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 @@ -210,6 +207,14 @@ android { } } +// 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 { reportsDestination = layout.buildDirectory.dir("compose_compiler") } @@ -258,17 +263,13 @@ kover { "*.R", "*.R$*", "*ComposableSingletons*", - "*Hilt_*", - "*_HiltModules*", - "*_Factory", - "*_Factory$*", - "*_MembersInjector", - "*_GeneratedInjector", + "*MetroFactory*", + "*MetroContributionTo*", + "*MetroMembersInjector*", + "*AppGraph\$Impl*", "*_Impl", "*_Impl$*", - "*Dagger*", ) - packages("dagger.hilt", "hilt_aggregated_deps") } } verify { @@ -300,7 +301,6 @@ dependencies { 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 @@ -318,9 +318,9 @@ 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.androidx.datastore.preferences) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 680b67fb..7cddd569 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -4,35 +4,35 @@ 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: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: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:NoteSubTitle.kt$@Composable fun NoteSubTitle( statusSaveNote: SaveStatusNote, note: Note, modifier: Modifier = Modifier, tagButtonPadding: PaddingValues = PaddingValues(0.dp), editTagListViewModel: TagListViewModel = metroViewModel(), 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(), ) + CyclomaticComplexMethod:TasksScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun TasksScreen( navController: NavHostController, modifier: Modifier = Modifier, viewModel: TasksViewModel = metroViewModel(), ) 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: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 = hiltViewModel(), ) + LongMethod:EditTagScreen.kt$@OptIn(ExperimentalFoundationApi::class) @SuppressLint("MutableCollectionMutableState") @Composable private fun ListTagsEdit( globalFocusManager: FocusManager, keyboardController: SoftwareKeyboardController?, modifier: Modifier = Modifier, viewModel: TagListViewModel = metroViewModel(), ) 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: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: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: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: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:NoteSubTitle.kt$@Composable fun NoteSubTitle( statusSaveNote: SaveStatusNote, note: Note, modifier: Modifier = Modifier, tagButtonPadding: PaddingValues = PaddingValues(0.dp), editTagListViewModel: TagListViewModel = metroViewModel(), 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 = 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 = 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(), ) + 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:AuthenticationManager.kt$AuthenticationManager$15 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3cff0c5c..8b903a60 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,8 +5,8 @@ # - 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. +# - 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). diff --git a/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt b/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt index 337e80b2..38efac25 100644 --- a/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt +++ b/app/src/main/java/com/pasich/encly/ImportFromMyNotesActivity.kt @@ -15,12 +15,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import com.pasich.encly.core.di.appGraph import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.data.handoff.AndroidPackageSignatures @@ -35,12 +37,13 @@ import com.pasich.encly.presentation.screen.handoff.ImportFromMyNotesScreen import com.pasich.encly.presentation.viewmodel.HandoffStep import com.pasich.encly.presentation.viewmodel.ImportFromMyNotesViewModel import com.pasich.encly.ui.theme.AppTheme -import dagger.hilt.android.AndroidEntryPoint +import dev.zacsweers.metro.Inject +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException -import javax.inject.Inject /** * Receives the one-way hand-off from My Notes (pasichDev/Encly#46): My Notes starts it with @@ -59,7 +62,6 @@ import javax.inject.Inject * [EXTRA_SKIPPED]; or `RESULT_CANCELED` with the string extra [EXTRA_REASON] * ([HandoffError.reason]). */ -@AndroidEntryPoint class ImportFromMyNotesActivity : AppCompatActivity() { @Inject @@ -71,9 +73,14 @@ class ImportFromMyNotesActivity : AppCompatActivity() { @Inject lateinit var keyboardPrivacy: KeyboardPrivacy - private val viewModel: ImportFromMyNotesViewModel by viewModels() + @Inject + lateinit var viewModelFactory: MetroViewModelFactory + + // Not the default factory: BiometricPrompt and others create their own ViewModels from it. + private val viewModel: ImportFromMyNotesViewModel by viewModels { viewModelFactory } override fun onCreate(savedInstanceState: Bundle?) { + appGraph.inject(this) // As in MainActivity: protected before the first frame. window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) super.onCreate(savedInstanceState) @@ -109,16 +116,18 @@ class ImportFromMyNotesActivity : AppCompatActivity() { 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) + CompositionLocalProvider(LocalMetroViewModelFactory provides viewModelFactory) { + AppTheme { + SecureTextInputBoundary(strict = strictKeyboard) { + SensitiveClipboardBoundary { + 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..ec9e557a 100644 --- a/app/src/main/java/com/pasich/encly/MainActivity.kt +++ b/app/src/main/java/com/pasich/encly/MainActivity.kt @@ -35,6 +35,7 @@ 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.security.InitialStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager @@ -51,19 +52,19 @@ 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 dev.zacsweers.metro.Inject +import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory 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 +79,11 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var keyboardPrivacy: KeyboardPrivacy + @Inject + lateinit var viewModelFactory: MetroViewModelFactory + 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. @@ -137,14 +142,16 @@ class MainActivity : AppCompatActivity() { if (locked) navController.showLockScreen() } - App( - navController = navController, - startDestination = destination.name, - eraseEpoch = securityManager::eraseEpoch, - shielded = shielded, - locked = locked, - strictKeyboard = strictKeyboard, - ) + CompositionLocalProvider(LocalMetroViewModelFactory provides viewModelFactory) { + App( + navController = navController, + startDestination = destination.name, + eraseEpoch = securityManager::eraseEpoch, + shielded = shielded, + locked = locked, + strictKeyboard = strictKeyboard, + ) + } } } diff --git a/app/src/main/java/com/pasich/encly/MyApplication.kt b/app/src/main/java/com/pasich/encly/MyApplication.kt index eab4a26e..b0426a3d 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.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/common/AppStrings.kt b/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt index f1f2bd0b..2b09d20d 100644 --- a/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt +++ b/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt @@ -5,17 +5,19 @@ 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 +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn /** * 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) { +@SingleIn(AppScope::class) +@Inject +class AppStrings(@param:ApplicationContext private val context: Context) { fun get(@StringRes id: Int, vararg args: Any): String = localizedContext().getString(id, *args) 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..e453fe0f --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/di/AppGraph.kt @@ -0,0 +1,69 @@ +package com.pasich.encly.core.di + +import android.app.Activity +import android.content.Context +import androidx.lifecycle.ViewModel +import com.pasich.encly.ImportFromMyNotesActivity +import com.pasich.encly.MainActivity +import com.pasich.encly.MyApplication +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.DependencyGraph +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.Qualifier +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ViewModelGraph +import kotlin.reflect.KClass + +/** 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 + } +} + +/** Creates the app's ViewModels from the graph's multibindings (see `MetroViewModelFactory`). */ +@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() + +/** 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 index 39e5b729..bedc435c 100644 --- a/app/src/main/java/com/pasich/encly/core/di/CoroutineModule.kt +++ b/app/src/main/java/com/pasich/encly/core/di/CoroutineModule.kt @@ -1,15 +1,15 @@ package com.pasich.encly.core.di -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent +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.Dispatchers import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier -import javax.inject.Singleton /** * Qualifier for the application-lifetime [CoroutineScope]. @@ -34,8 +34,8 @@ annotation class ApplicationScope annotation class IoDispatcher /** Provides coroutine infrastructure that lives for the whole application process. */ -@Module -@InstallIn(SingletonComponent::class) +@ContributesTo(AppScope::class) +@BindingContainer object CoroutineModule { /** @@ -44,7 +44,7 @@ object CoroutineModule { * application process), so work launched here survives ViewModel teardown. */ @Provides - @Singleton + @SingleIn(AppScope::class) @ApplicationScope fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + 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..e46dd3e1 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 @@ -4,19 +4,18 @@ 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 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 -@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 } 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..85c85bd7 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 @@ -15,17 +15,16 @@ 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 +33,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,7 +43,7 @@ object SecurityModule { * unreadable at startup. */ @Provides - @Singleton + @SingleIn(AppScope::class) fun provideAppFlags(@ApplicationContext context: Context): SharedPreferences = context.getSharedPreferences(FLAGS_FILE, Context.MODE_PRIVATE) @@ -52,24 +51,24 @@ object SecurityModule { 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 bindLockoutClock(clock: SystemLockoutClock): LockoutClock @Binds - abstract fun bindDeviceLockWatcher(watcher: SystemDeviceLockWatcher): DeviceLockWatcher + fun bindDeviceLockWatcher(watcher: SystemDeviceLockWatcher): DeviceLockWatcher @Binds - abstract fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents + fun bindVaultLockEvents(manager: SessionLockManager): VaultLockEvents @Binds - abstract fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy + fun bindAutoLockPolicy(autoLock: AutoLock): AutoLockPolicy @Binds - abstract fun bindHandoffStagingSweeper(sweeper: CacheHandoffStagingSweeper): HandoffStagingSweeper + fun bindHandoffStagingSweeper(sweeper: CacheHandoffStagingSweeper): HandoffStagingSweeper } diff --git a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index 687fc10f..ce7d857c 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -18,8 +18,9 @@ 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 javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn const val PIN_LENGTH = 6 @@ -146,9 +147,10 @@ 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 +@SingleIn(AppScope::class) @Suppress("TooManyFunctions") // The complete PIN slot, wipe slot and lockout lifecycle in one place. -class AuthenticationManager @Inject constructor( +@Inject +class AuthenticationManager( private val store: VaultStore, private val factor: PinHardwareFactor, private val clock: LockoutClock, diff --git a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt index f27f51b8..fdd97b7a 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt @@ -1,11 +1,12 @@ package com.pasich.encly.core.security import android.content.SharedPreferences +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 -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. @@ -38,8 +39,9 @@ interface AutoLockPolicy { * 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 { +@SingleIn(AppScope::class) +@Inject +class AutoLock(private val flags: SharedPreferences) : AutoLockPolicy { private val _delay = MutableStateFlow(read()) val delay: StateFlow = _delay.asStateFlow() 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 index 334af522..f5c2ede4 100644 --- a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt @@ -9,14 +9,15 @@ 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 com.pasich.encly.core.di.ApplicationContext +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 javax.inject.Inject -import javax.inject.Singleton import androidx.biometric.BiometricManager as AndroidBiometricManager /** @@ -26,12 +27,10 @@ import androidx.biometric.BiometricManager as AndroidBiometricManager * unwrapped by the Cipher instance returned from a successful BIOMETRIC_STRONG CryptoObject * prompt. A plain "prompt succeeded" boolean never releases the database key. */ -@Singleton +@SingleIn(AppScope::class) @Suppress("TooManyFunctions") // Centralizes the complete auth-bound biometric slot lifecycle. -class BiometricManager @Inject constructor( - @param:ApplicationContext private val context: Context, - private val store: VaultStore, -) { +@Inject +class BiometricManager(@param:ApplicationContext private val context: Context, private val store: VaultStore) { enum class BiometricType { APP_UNLOCK, MASTER_KEY_ACCESS, 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 index 6948fa62..4ce5a546 100644 --- a/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt +++ b/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt @@ -6,9 +6,10 @@ 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 +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn /** Screen-off and keyguard signals for [SessionLockManager]; an interface so tests can drive it. */ interface DeviceLockWatcher { @@ -25,9 +26,9 @@ interface DeviceLockWatcher { } } -@Singleton -class SystemDeviceLockWatcher @Inject constructor(@param:ApplicationContext private val context: Context) : - DeviceLockWatcher { +@SingleIn(AppScope::class) +@Inject +class SystemDeviceLockWatcher(@param:ApplicationContext private val context: Context) : DeviceLockWatcher { override fun isDeviceLocked(): Boolean = context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true 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 index 38b989a0..b4ec4726 100644 --- a/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt +++ b/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt @@ -1,11 +1,12 @@ package com.pasich.encly.core.security import android.content.SharedPreferences +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 -import javax.inject.Inject -import javax.inject.Singleton /** * "Strict keyboard privacy" (Settings → Security, off by default). When on, every text field @@ -14,8 +15,9 @@ import javax.inject.Singleton * 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) { +@SingleIn(AppScope::class) +@Inject +class KeyboardPrivacy(private val flags: SharedPreferences) { private val _strict = MutableStateFlow(flags.getBoolean(STRICT_KEY, false)) val strict: StateFlow = _strict.asStateFlow() 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 index 4a970b1e..2ae6f872 100644 --- a/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt +++ b/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt @@ -3,9 +3,10 @@ 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 +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn /** * Time for the PIN lockout. Monotonic, so changing the wall clock does not end a lockout. @@ -19,8 +20,9 @@ interface LockoutClock { fun bootCount(): Int } -@Singleton -class SystemLockoutClock @Inject constructor(@param:ApplicationContext private val context: Context) : LockoutClock { +@SingleIn(AppScope::class) +@Inject +class SystemLockoutClock(@param:ApplicationContext private val context: Context) : LockoutClock { override fun elapsedRealtime(): Long = SystemClock.elapsedRealtime() override fun bootCount(): Int = try { @@ -32,7 +34,7 @@ class SystemLockoutClock @Inject constructor(@param:ApplicationContext private v /** * 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. + * count deep sleep, so the app itself always gets [SystemLockoutClock] from the graph. */ object JvmMonotonicClock : LockoutClock { private const val NANOS_PER_MILLI = 1_000_000L 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 index 5042e4d3..2670e74f 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt @@ -5,14 +5,15 @@ 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 -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 @@ -70,8 +71,9 @@ class PinFactorException(val lost: Boolean, cause: Throwable? = null) : Exceptio // 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 { +@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 { diff --git a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 48a82487..71fb6f8e 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -8,10 +8,11 @@ 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 dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong -import javax.inject.Inject -import javax.inject.Singleton enum class InitialStatus { NO, @@ -50,15 +51,16 @@ enum class VaultUnlockResult { * 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( +@SingleIn(AppScope::class) +@Inject +class SecurityManager( 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. */ + /** The graph always passes the real one; the default only spares tests that never erase. */ private val handoffStaging: HandoffStagingSweeper = HandoffStagingSweeper {}, ) { companion object { diff --git a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt index 9da63d44..e15946b6 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -3,8 +3,9 @@ package com.pasich.encly.core.security import com.pasich.encly.core.backup.BackupKeys import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.core.security.bip39.Bip39 -import javax.inject.Inject -import javax.inject.Singleton +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn /** * Encly vault bootstrap and recovery manager. @@ -21,8 +22,9 @@ 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) { +@SingleIn(AppScope::class) +@Inject +class SeedPhraseManager(private val store: VaultStore) { companion object { private const val VAULT_PREFIX = "vault." internal const val RECOVERY_PREFIX = "recovery." diff --git a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt index 4828c702..070d79d3 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt @@ -5,11 +5,12 @@ import android.os.Looper import androidx.annotation.MainThread import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +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 -import javax.inject.Inject -import javax.inject.Singleton /** * Whether the open session was closed. ViewModels that hold decrypted content observe it and @@ -43,9 +44,10 @@ object NeverLocked : VaultLockEvents { * the moment the screen turns off or the device locks, and are re-checked when the app comes * back. */ -@Singleton +@SingleIn(AppScope::class) @Suppress("TooManyFunctions") // The whole session lifecycle: lifecycle hooks, picker grace, unlock publishing. -class SessionLockManager @Inject constructor( +@Inject +class SessionLockManager( private val securityManager: SecurityManager, private val deviceLock: DeviceLockWatcher = DeviceLockWatcher.None, systemClock: LockoutClock = JvmMonotonicClock, 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 index a50aa9a2..52ca3aba 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupDocuments.kt @@ -7,22 +7,24 @@ 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 com.pasich.encly.core.di.ApplicationContext +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.asSource import kotlinx.io.readTo -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) { +@SingleIn(AppScope::class) +@Inject +class BackupDocuments(@param:ApplicationContext private val context: Context) { fun read(uri: Uri): ByteArray = io { context.contentResolver.openInputStream(uri).orFail().use { readCapped(it.asSource()) } } diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt index 69fdc484..12b19fa5 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt @@ -14,17 +14,19 @@ 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.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, diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt index 5af1a6be..4f664cc0 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupPhraseSetup.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt b/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt index 825a74fc..d04ab3b3 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/PendingRestore.kt @@ -2,8 +2,9 @@ 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 /** * A backup decrypted by onboarding's "Restore from backup", held in memory until first-run @@ -11,8 +12,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/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt index cf441d07..182f41fd 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -10,7 +10,7 @@ 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 +import dev.zacsweers.metro.Inject /** [VaultDataStore] over a single SQLCipher/Room opening. */ @Suppress("TooManyFunctions") // One function per vault table operation of the contract. diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt index f732371c..d3812b4f 100644 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -3,17 +3,18 @@ package com.pasich.encly.data.database import android.content.Context import androidx.room.useWriterConnection import com.pasich.encly.core.AppLogger +import com.pasich.encly.core.di.ApplicationContext import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.cipher.SQLCipherUtils -import dagger.hilt.android.qualifiers.ApplicationContext +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.coroutines.Dispatchers import kotlinx.coroutines.runBlocking 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 @@ -30,9 +31,9 @@ enum class VaultFile(internal val fileName: String) { * 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 { +@SingleIn(AppScope::class) +@Inject +class SecureDatabaseManager(@param:ApplicationContext private val context: Context) : DatabaseProvider { private var database: AppDatabase? = null private var isUnlocked = false private var lease: DatabaseLease? = null diff --git a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt b/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt index 8b44dc00..69a76dc0 100644 --- a/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt +++ b/app/src/main/java/com/pasich/encly/data/datasource/local/SettingsLocalDataSource.kt @@ -14,13 +14,14 @@ 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 const val IS_DYNAMIC_THEME = "is_dynamic_theme" const val THEME_TYPE = "theme_type" @@ -39,9 +40,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/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 index 35ba06da..9d0ab771 100644 --- a/app/src/main/java/com/pasich/encly/data/di/RepositoryModule.kt +++ b/app/src/main/java/com/pasich/encly/data/di/RepositoryModule.kt @@ -8,23 +8,23 @@ 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 +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.Binds +import dev.zacsweers.metro.ContributesTo -@Module -@InstallIn(SingletonComponent::class) -abstract class RepositoryModule { +@ContributesTo(AppScope::class) +@BindingContainer +interface RepositoryModule { @Binds - abstract fun bindNotesRepository(impl: NotesRepositoryImpl): NotesRepository + fun bindNotesRepository(impl: NotesRepositoryImpl): NotesRepository @Binds - abstract fun bindTagsRepository(impl: TagsRepositoryImpl): TagsRepository + fun bindTagsRepository(impl: TagsRepositoryImpl): TagsRepository @Binds - abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository + fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository @Binds - abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository + fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository } 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 index 6d2fda24..ceaf4744 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownDocuments.kt @@ -5,21 +5,23 @@ 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 com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import kotlinx.io.IOException import kotlinx.io.Sink import kotlinx.io.asSink import kotlinx.io.buffered -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) { +@SingleIn(AppScope::class) +@Inject +class MarkdownDocuments(@param:ApplicationContext private val context: Context) { /** * Streams what [block] writes into the document at [uri]. A document that already holds data 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..f9413a28 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,15 @@ package com.pasich.encly.data.handoff import android.content.Context -import dagger.hilt.android.qualifiers.ApplicationContext +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn import java.io.File -import javax.inject.Inject -import javax.inject.Singleton /** [HandoffStagingSweeper] for the staging directory in the app's cache. */ -@Singleton -class CacheHandoffStagingSweeper @Inject constructor(@param:ApplicationContext private val context: Context) : - HandoffStagingSweeper { +@SingleIn(AppScope::class) +@Inject +class CacheHandoffStagingSweeper(@param:ApplicationContext private val context: Context) : HandoffStagingSweeper { override fun sweep() = HandoffStaging.sweep(File(context.cacheDir, HandoffStaging.DIR_NAME)) } 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 index 95214dbe..47a6fba5 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt @@ -5,9 +5,9 @@ import android.content.Context import android.net.Uri import android.provider.DocumentsContract import android.provider.OpenableColumns -import dagger.hilt.android.qualifiers.ApplicationContext +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.Inject import kotlinx.io.asSource -import javax.inject.Inject /** * The Storage Access Framework side of an import: the user's picked files or folder as an @@ -15,7 +15,8 @@ import javax.inject.Inject * 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) { +@Inject +class SafImportFiles(@param:ApplicationContext private val context: Context) { fun files(uris: List): ImportFileSource = ImportFileSource { uris.asSequence().map { uri -> SourceFile(displayName(uri) ?: FALLBACK_NAME) { open(uri) } } diff --git a/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt index 979c0079..5c84019a 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -5,14 +5,15 @@ 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 dev.zacsweers.metro.Inject 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 { +@Inject +class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : NotesRepository { private val lease: DatabaseLease? = databaseProvider.captureLease() private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).notesDao() diff --git a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt index 9d769f7d..04db559e 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/SettingsRepositoryImpl.kt @@ -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/TagsRepositoryImpl.kt b/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt index 0241be9e..08071c46 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TagsRepositoryImpl.kt @@ -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/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt index 4167bfdd..06f55168 100644 --- a/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt +++ b/app/src/main/java/com/pasich/encly/data/repository/TasksRepositoryImpl.kt @@ -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/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt index b074870a..4bc13ae4 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt +++ b/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt @@ -8,11 +8,13 @@ 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, diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/note/CleanTrashNotesUseCase.kt b/app/src/main/java/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/app/src/main/java/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/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 index f9550bbd..3d3b391c 100644 --- 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 @@ -6,12 +6,12 @@ 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 dev.zacsweers.metro.Inject 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 @@ -19,7 +19,8 @@ import javax.inject.Inject * 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( +@Inject +class ObserveNotesUseCase( private val repository: NotesRepository, @IoDispatcher private val dispatcher: CoroutineDispatcher, ) { 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 index 865851cc..b7c01051 100644 --- 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 @@ -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 /** Moves the note to the archive, or back to the list. Archiving unpins it. */ -class UpdateNoteArchiveStatusUseCase @Inject constructor(private val repository: NotesRepository) { +@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/app/src/main/java/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/app/src/main/java/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/app/src/main/java/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/app/src/main/java/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/app/src/main/java/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/app/src/main/java/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/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 index b50d290b..a85198fd 100644 --- 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 @@ -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 /** Moves the note to the tag [tagId] (0: no tag). Writes the tag only. */ -class UpdateNoteTagUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class UpdateNoteTagUseCase(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 index e9d9bdf2..3ff801e4 100644 --- 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 @@ -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 /** Moves the note to the trash, or back out of it. */ -class UpdateNoteTrashStatusUseCase @Inject constructor(private val repository: NotesRepository) { +@Inject +class UpdateNoteTrashStatusUseCase(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/tag/ReorderTagsUseCase.kt b/app/src/main/java/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/app/src/main/java/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/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 index 401d9688..f318ab3d 100644 --- 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 @@ -1,10 +1,11 @@ package com.pasich.encly.domain.usecase.task import com.pasich.encly.domain.repository.TasksRepository -import javax.inject.Inject +import dev.zacsweers.metro.Inject /** Completes or reopens a task; a completed task records when it was completed. */ -class UpdateTaskStatusUseCase @Inject constructor(private val tasksRepository: TasksRepository) { +@Inject +class UpdateTaskStatusUseCase(private val tasksRepository: TasksRepository) { suspend operator fun invoke( id: Long, isCompleted: Boolean, diff --git a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt b/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt index 778dbbab..63b9a0aa 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/HomeTaskWidget.kt @@ -18,9 +18,9 @@ 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 /** * The tasks card on the notes screen (design spec §4.3): "TASKS · N OPEN", a link to all tasks @@ -32,7 +32,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) } diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt index 231346a5..6c1847ff 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt @@ -52,8 +52,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch /** @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch @Composable fun NoteBottomBar( modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), simpleEdit: Boolean = false, ) { var showMore by rememberSaveable { mutableStateOf(false) } 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 index 65bd8881..b9a471d0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/di/EditorModule.kt +++ b/app/src/main/java/com/pasich/encly/presentation/di/EditorModule.kt @@ -3,13 +3,13 @@ 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 +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides -@Module -@InstallIn(SingletonComponent::class) +@ContributesTo(AppScope::class) +@BindingContainer object EditorModule { @Provides diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt index 573f82f2..8c663f46 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt @@ -20,8 +20,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel enum class EditNoteBottomSheetAction { CLOSE_NO_SAVE, @@ -44,7 +44,7 @@ fun EditNoteBottomSheet( isVisible: Boolean, onDismiss: () -> Unit, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), onAction: (EditNoteBottomSheetAction) -> Unit = {}, ) { val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt index 6253408d..ae89a3bf 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt @@ -45,10 +45,10 @@ 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 com.pasich.encly.utils.noteInstant +import dev.zacsweers.metrox.viewmodel.metroViewModel sealed class NoteAction { object Edit : NoteAction() @@ -180,7 +180,7 @@ private fun NoteActionHeader(item: NoteWithTag) { private fun TagChips( selectedTagId: Long, onSelect: (Long) -> Unit, - tagListViewModel: TagListViewModel = enclyViewModel(), + tagListViewModel: TagListViewModel = metroViewModel(), ) { val state by tagListViewModel.state.collectAsState() val gutter = EnclyTheme.spacing.gutter diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt b/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt index 135d7fdd..f1b29f51 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/presentation/dialogs/NotesSortBottomSheet.kt @@ -15,7 +15,7 @@ import com.pasich.encly.presentation.designsystem.EnclyRadio import com.pasich.encly.presentation.designsystem.EnclySheetRow import com.pasich.encly.presentation.viewmodel.NoteListEvent import com.pasich.encly.presentation.viewmodel.NoteListViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel /** View options of the notes list: the sort order, and the switch between list and grid. */ @OptIn(ExperimentalMaterial3Api::class) @@ -26,7 +26,7 @@ fun NotesSortBottomSheet( isGrid: Boolean, onToggleView: () -> Unit, isBottomSheetVisible: Boolean = false, - noteListViewModel: NoteListViewModel = enclyViewModel(), + noteListViewModel: NoteListViewModel = metroViewModel(), ) { val state by noteListViewModel.state.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt b/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt index e926e8ec..ecadf699 100644 --- a/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt +++ b/app/src/main/java/com/pasich/encly/presentation/drawer/MainDrawer.kt @@ -32,8 +32,8 @@ 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 @@ -47,7 +47,7 @@ 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() diff --git a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt index 41aad8c5..47fcec32 100644 --- a/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/navigation/NavHost.kt @@ -40,7 +40,7 @@ import com.pasich.encly.presentation.screen.settings.WipePinScreen import com.pasich.encly.presentation.screen.trash.TrashScreen import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.presentation.viewmodel.LossRecoveryViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel @Composable fun AppNavHost(navController: NavHostController, startDestination: String = NavRoutes.HomeRoute.name) { @@ -209,7 +209,7 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR private fun EditNoteDestination( navController: NavHostController, entry: NavBackStackEntry, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val noteState by viewModel.state.collectAsState() val eraseEpoch = LocalEraseEpoch.current @@ -231,7 +231,7 @@ private fun LossRoute( navController: NavHostController, route: NavRoutes, reason: LossReason, - recoveryViewModel: LossRecoveryViewModel = enclyViewModel(), + recoveryViewModel: LossRecoveryViewModel = metroViewModel(), ) { LossRecoveryScreen( reason = reason, diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt index 2494c46a..501e58c8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt @@ -79,10 +79,10 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem @@ -102,7 +102,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() 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 index eec1f006..8eafbc18 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -44,7 +44,7 @@ private val bundledFonts = listOf( 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("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"), diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt index 983ac589..fed0a146 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockScreen.kt @@ -40,14 +40,14 @@ import com.pasich.encly.presentation.screen.pincode.pinLockoutText import com.pasich.encly.presentation.viewmodel.LockViewModel import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult -import com.pasich.encly.presentation.viewmodel.enclyViewModel import com.pasich.encly.ui.theme.EnclyTheme +import dev.zacsweers.metrox.viewmodel.metroViewModel /** 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 canReopenNote = metroViewModel()::canReopenNote val unlockReveal = LocalUnlockReveal.current val exits = remember(navController, unlockReveal) { LockExits( @@ -71,7 +71,7 @@ fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) } @Composable -fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = enclyViewModel()) { +fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = metroViewModel()) { val activity = LocalActivity.current as? FragmentActivity val busy by viewModel.busy.collectAsState() // Above the loading view below: the forms' input and errors survive the credential check. diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt index ed45e916..04d887ef 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/MainScreen.kt @@ -53,8 +53,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch /** The scrim over the notes while the drawer is open. */ @@ -103,11 +103,11 @@ fun MainScreen( navController: NavHostController, drawerState: DrawerState, modifier: Modifier = Modifier, - mainListStateViewModel: MainListStateViewModel = enclyViewModel(), - settingsViewModel: SettingsViewModel = enclyViewModel(), - noteListViewModel: NoteListViewModel = enclyViewModel(), - searchViewModel: NoteSearchViewModel = enclyViewModel(), - lockNowViewModel: LockNowViewModel = enclyViewModel(), + 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() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt index 195ecf4a..7dc275f5 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt @@ -58,8 +58,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -86,7 +86,7 @@ fun NotesList( swipe: SwipeSettings = SwipeSettings.Off, onSecondActionClick: (Note) -> Unit = {}, header: @Composable () -> Unit = {}, - noteListViewModel: NoteListViewModel = enclyViewModel(), + noteListViewModel: NoteListViewModel = metroViewModel(), ) { val state by noteListViewModel.state.collectAsStateWithLifecycle() val sheet = remember { NoteSheetState() } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt index be632cc9..d99796ba 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -53,8 +53,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.delay private const val PIN_SUCCESS_DELAY_MS = 1_000L @@ -170,7 +170,7 @@ private class PinChangeState(val isReset: Boolean) { fun PinCodeConfigScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), + 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. diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt index 1b4fb222..034bdb44 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt @@ -20,15 +20,15 @@ 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 /** * 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 diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt index cbe6dff2..f6f2c020 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TasksScreen.kt @@ -42,7 +42,7 @@ import com.pasich.encly.presentation.dialogs.tasks.AddTaskDialog import com.pasich.encly.presentation.viewmodel.TaskOperationFailure import com.pasich.encly.presentation.viewmodel.TasksUiState import com.pasich.encly.presentation.viewmodel.TasksViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -57,7 +57,7 @@ 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() } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt index 9585e62a..a2dc0537 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt @@ -33,8 +33,8 @@ 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 private const val SKELETON_CARDS = 3 @@ -46,8 +46,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() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt index 9a45b17f..6fa615f7 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupScreen.kt @@ -46,10 +46,10 @@ 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 com.pasich.encly.utils.todayStamp +import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch import kotlin.time.Instant @@ -62,8 +62,8 @@ import kotlin.time.Instant fun BackupScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: BackupViewModel = enclyViewModel(), - markdownViewModel: MarkdownExportViewModel = enclyViewModel(), + viewModel: BackupViewModel = metroViewModel(), + markdownViewModel: MarkdownExportViewModel = metroViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } 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 index 5659cfe5..e98af08d 100644 --- 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 @@ -17,7 +17,7 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel /** * The screen side of the Markdown export, drawn by the Backup screen and by the editor: the @@ -26,7 +26,7 @@ import com.pasich.encly.presentation.viewmodel.enclyViewModel * Nothing else leaves the app: no share sheet, no temp file. */ @Composable -internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: MarkdownExportViewModel = enclyViewModel()) { +internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: MarkdownExportViewModel = metroViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() val resources = LocalResources.current val noteLauncher = rememberLauncherForActivityResult( diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt index 82755b85..abdff209 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt @@ -71,9 +71,9 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch /** Items above the blocks: the overline and the title. */ @@ -84,7 +84,7 @@ private const val HEADER_ITEMS = 2 fun EditNoteScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current @@ -182,7 +182,7 @@ private fun EditorScaffold( listState: LazyListState, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), content: @Composable (PaddingValues) -> Unit, ) { val lockEditor by viewModel.lockEditor.collectAsState() @@ -224,7 +224,7 @@ private fun EditorScaffold( * state, so a title keystroke does not recompose the editor; a colour change fades in. */ @Composable -private fun rememberPageColor(viewModel: EditNoteViewModel = enclyViewModel()): Color { +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 @@ -261,8 +261,8 @@ private fun EditorOverlays( exits: EditorExits, onManageTags: () -> Unit, onMessage: (String) -> Unit, - viewModel: EditNoteViewModel = enclyViewModel(), - markdownViewModel: MarkdownExportViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), + markdownViewModel: MarkdownExportViewModel = metroViewModel(), ) { val context = LocalContext.current val lockEditor by viewModel.lockEditor.collectAsState() @@ -324,7 +324,7 @@ private fun EditorContent( listState: LazyListState, titleFocus: FocusRequester, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val spacing = LocalNoteLineSpacing.current val headerGap = if (spacing.isCompact) EnclyTheme.spacing.labelGap else 0.dp @@ -392,7 +392,7 @@ private fun EditorContent( private fun EditorTitle( readOnly: Boolean, modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { TitleField( title = { viewModel.state.value.note.title }, @@ -408,8 +408,8 @@ private fun EditorTitle( @Composable private fun EditorOverline( modifier: Modifier = Modifier, - viewModel: EditNoteViewModel = enclyViewModel(), - tagViewModel: TagListViewModel = enclyViewModel(), + 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() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt index b0f6e4a6..f26f9d63 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt @@ -31,7 +31,7 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -43,7 +43,7 @@ private const val INITIAL_FOCUS_FRAMES = 5 * closes SQLCipher later in the background transition, so this closes the autosave window first. */ @Composable -internal fun SaveWhenPaused(viewModel: EditNoteViewModel = enclyViewModel()) { +internal fun SaveWhenPaused(viewModel: EditNoteViewModel = metroViewModel()) { val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, viewModel) { val observer = LifecycleEventObserver { _, event -> @@ -64,7 +64,7 @@ internal fun CarryOutFocusRequests( registry: BlockFocusRegistry, listState: LazyListState, blocksStart: Int, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val currentBlocksStart by rememberUpdatedState(blocksStart) DisposableEffect(viewModel, registry) { @@ -105,7 +105,7 @@ private const val REVEAL_FRACTION = 4 /** A new note starts in its title with the keyboard up: once, not again after a restore. */ @Composable -internal fun FocusNewNoteTitle(titleFocus: FocusRequester, viewModel: EditNoteViewModel = enclyViewModel()) { +internal fun FocusNewNoteTitle(titleFocus: FocusRequester, viewModel: EditNoteViewModel = metroViewModel()) { val keyboardController = LocalSoftwareKeyboardController.current var done by rememberSaveable { mutableStateOf(!viewModel.isNewNote) } LaunchedEffect(done) { @@ -147,7 +147,7 @@ internal fun rememberBlockSheetState(): BlockSheetState { /** Whether the find bar is open. */ @Composable -internal fun rememberFindOpen(viewModel: EditNoteViewModel = enclyViewModel()): State { +internal fun rememberFindOpen(viewModel: EditNoteViewModel = metroViewModel()): State { val open = remember(viewModel) { viewModel.find.map { it.isOpen }.distinctUntilChanged() } return open.collectAsState(initial = false) } @@ -160,7 +160,7 @@ internal fun rememberFindOpen(viewModel: EditNoteViewModel = enclyViewModel()): internal fun ScrollToFindMatch( listState: LazyListState, blocksStart: Int, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val current = remember(viewModel) { viewModel.find.map { it.currentMatch }.distinctUntilChanged() } val match by current.collectAsState(initial = null) @@ -174,7 +174,7 @@ internal fun ScrollToFindMatch( /** Back closes the find bar, else an open slash picker, before it leaves the note. */ @Composable -internal fun CloseFindOrSlashOnBack(viewModel: EditNoteViewModel = enclyViewModel()) { +internal fun CloseFindOrSlashOnBack(viewModel: EditNoteViewModel = metroViewModel()) { val findOpen by rememberFindOpen() val slashMenu by viewModel.slashPicker.menu.collectAsState() val slashShown = slashMenu?.isShown == true diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt index d908531b..d3d8aa70 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt @@ -31,8 +31,8 @@ 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 private const val FOCUS_FRAMES = 3 @@ -42,7 +42,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) { diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt index 1fb14a02..b85b2f38 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt @@ -49,8 +49,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch /** @@ -62,7 +62,7 @@ import kotlinx.coroutines.launch internal fun EditorBlockSheets( sheet: BlockSheetState, canEdit: Boolean, - viewModel: EditNoteViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), ) { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -115,8 +115,8 @@ internal fun EditorBlockSheets( internal fun EditorTagSheet( onManageTags: () -> Unit, onDismiss: () -> Unit, - viewModel: EditNoteViewModel = enclyViewModel(), - tagViewModel: TagListViewModel = enclyViewModel(), + viewModel: EditNoteViewModel = metroViewModel(), + tagViewModel: TagListViewModel = metroViewModel(), ) { val tagState by tagViewModel.state.collectAsState() val noteState by viewModel.state.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TemplateChooser.kt index 843f6b15..3ee7e9b2 100644 --- 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 @@ -16,8 +16,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel /** * "Start from template" under a brand-new, still empty note: Blank (what a new note is anyway, @@ -25,7 +25,7 @@ import com.pasich.encly.ui.theme.EnclyTheme * 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()) { +internal fun TemplateChooser(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel()) { val resources = LocalResources.current Column( verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt index 634e7fc2..9789ea42 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/handoff/ImportFromMyNotesScreen.kt @@ -33,8 +33,8 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel /** * Import from My Notes: the lock screen while the vault is closed, then the hand-off's steps. @@ -44,7 +44,7 @@ import com.pasich.encly.ui.theme.EnclyTheme fun ImportFromMyNotesScreen( onClose: () -> Unit, modifier: Modifier = Modifier, - viewModel: ImportFromMyNotesViewModel = enclyViewModel(), + viewModel: ImportFromMyNotesViewModel = metroViewModel(), ) { val locked by viewModel.locked.collectAsState() val step by viewModel.step.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt index 3ff58e89..3e433782 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/importnotes/ImportNotesScreen.kt @@ -25,8 +25,8 @@ 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 /** * Settings → Import notes. Works in a decoy session too: it imports into whichever vault is @@ -36,7 +36,7 @@ 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( diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt index a0a263a0..b855951d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt @@ -55,8 +55,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel private const val SLIDE_MS = 500 private const val FADE_IN_MS = 300 @@ -73,8 +73,8 @@ private const val FADE_OUT_MS = 300 fun OnboardingScreen( onComplete: () -> Unit, modifier: Modifier = Modifier, - viewModel: OnboardingViewModel = enclyViewModel(), - setupViewModel: AuthSetupViewModel = enclyViewModel(), + viewModel: OnboardingViewModel = metroViewModel(), + setupViewModel: AuthSetupViewModel = metroViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val finishing by setupViewModel.busy.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt index c51ad3b7..aa3423ea 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/AppearanceScreen.kt @@ -45,10 +45,10 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel /** Swatches render at this opacity while dynamic colour overrides the palette accent. */ private const val DYNAMIC_SWATCH_ALPHA = 0.38f @@ -57,7 +57,7 @@ private const val DYNAMIC_SWATCH_ALPHA = 0.38f fun AppearanceScreen( navController: NavHostController, modifier: Modifier = Modifier, - viewModel: AppearanceViewModel = enclyViewModel(), + viewModel: AppearanceViewModel = metroViewModel(), ) { val settings by viewModel.themeSettings.collectAsStateWithLifecycle() val fontSize by viewModel.defaultFontSize.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt index c5151d5b..60a40e77 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt @@ -28,8 +28,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel private val DuressPinTexts = PinFlowTexts( title = R.string.duress_pin_title, @@ -49,7 +49,7 @@ private val DuressPinTexts = PinFlowTexts( fun DuressPinScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), + securityViewModel: SecuritySettingsViewModel = metroViewModel(), ) { val state = remember { PinFlowState() } val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index b6696dea..6e148cc0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt @@ -44,16 +44,16 @@ 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 dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch @Composable fun SecuritySettingsScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), - vaultViewModel: BackupViewModel = enclyViewModel(), + securityViewModel: SecuritySettingsViewModel = metroViewModel(), + vaultViewModel: BackupViewModel = metroViewModel(), ) { val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() val strictKeyboard by securityViewModel.strictKeyboard.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt index 9b62bc3d..bc2c75a5 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SettingsScreen.kt @@ -28,14 +28,14 @@ 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 @Composable fun SettingsScreen( navController: NavHostController?, modifier: Modifier = Modifier, - settingViewState: SettingsViewModel = enclyViewModel(), + settingViewState: SettingsViewModel = metroViewModel(), securityDisable: Boolean = false, ) { val showTasks by settingViewState.showTasksFlow.collectAsState() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt index 35bf71cc..8198cdfc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/WipePinScreen.kt @@ -25,8 +25,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel private val WipePinTexts = PinFlowTexts( title = R.string.wipe_pin_title, @@ -46,7 +46,7 @@ private val WipePinTexts = PinFlowTexts( fun WipePinScreen( navController: NavHostController, modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = enclyViewModel(), + securityViewModel: SecuritySettingsViewModel = metroViewModel(), ) { val state = remember { PinFlowState() } val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt index 28e31d65..2e87746b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt @@ -27,8 +27,8 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel private const val SKELETON_CARDS = 3 @@ -41,7 +41,7 @@ private const val SKELETON_CARDS = 3 fun TrashNotesList( onItemClick: (Int, Note) -> Unit, modifier: Modifier = Modifier, - trashViewModel: TrashViewModel = enclyViewModel(), + trashViewModel: TrashViewModel = metroViewModel(), ) { val listScrollState = rememberLazyListState() val state by trashViewModel.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt index 66fa7c97..5850c746 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashScreen.kt @@ -26,7 +26,7 @@ import com.pasich.encly.presentation.dialogs.ConfirmDialog import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.viewmodel.TrashListEvent import com.pasich.encly.presentation.viewmodel.TrashViewModel -import com.pasich.encly.presentation.viewmodel.enclyViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel enum class DialogTrashAction { DELETE_PERMANENTLY, @@ -38,7 +38,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) } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt index 3406ccf8..90959fb0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AppearanceViewModel.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt index 18b1d17c..72ebf167 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt +++ b/app/src/main/java/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, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt index 2a940b4e..ed703a89 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt @@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope 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 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.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow @@ -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, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index 3958ca97..f7ec8fcd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -8,14 +8,17 @@ 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 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.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,8 +26,10 @@ 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, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index 6452122d..f9966f28 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -2,7 +2,9 @@ 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 @@ -58,7 +60,13 @@ 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 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 @@ -75,20 +83,17 @@ 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( +@Suppress("LongParameterList") // Injected dependencies; grouping them would only obscure wiring. +@AssistedInject +class EditNoteViewModel( notesRepository: NotesRepository, - private val savedStateHandle: SavedStateHandle, + @Assisted private val savedStateHandle: SavedStateHandle, updateNoteTrashStatusUseCase: UpdateNoteTrashStatusUseCase, updateNoteArchiveStatusUseCase: UpdateNoteArchiveStatusUseCase, private val settingsRepository: SettingsRepository, @@ -599,6 +604,16 @@ constructor( 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 diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt index 0a18e909..8f01c947 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt @@ -4,8 +4,11 @@ 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.ApplicationContext import com.pasich.encly.core.di.IoDispatcher import com.pasich.encly.core.security.InitialStatus import com.pasich.encly.core.security.SecurityManager @@ -19,8 +22,13 @@ 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 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 @@ -30,7 +38,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.IOException -import javax.inject.Inject /** Where a My Notes hand-off is. */ sealed interface HandoffStep { @@ -57,14 +64,14 @@ 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( +@AssistedInject +class ImportFromMyNotesViewModel( @param:ApplicationContext private val context: Context, 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)) @@ -187,6 +194,16 @@ class ImportFromMyNotesViewModel @Inject constructor( } } +/** 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/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt index 8a5f04a6..5aa40564 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportNotesViewModel.kt @@ -23,7 +23,11 @@ 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 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 @@ -36,7 +40,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException -import javax.inject.Inject /** Why an import ended without writing anything. */ enum class ImportFailure { @@ -112,8 +115,10 @@ 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( +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class ImportNotesViewModel( private val files: SafImportFiles, private val store: VaultDataStore, private val sessionLockManager: SessionLockManager, 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 index 5c73b9b3..d7185970 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockNowViewModel.kt @@ -2,12 +2,17 @@ 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 +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. */ -@HiltViewModel -class LockNowViewModel @Inject constructor(private val sessionLockManager: SessionLockManager) : ViewModel() { +@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/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index e5f95df9..ca2f3573 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -9,7 +9,11 @@ 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 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 @@ -21,7 +25,6 @@ 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 @@ -31,9 +34,11 @@ import javax.inject.Inject enum class PinUnlockResult { SUCCESS, WRONG_PIN, LOCKED_OUT, KEY_LOST, DB_ERROR, BACKGROUNDED } enum class SeedUnlockResult { SUCCESS, WRONG_SEED, DB_ERROR, BACKGROUNDED } -@HiltViewModel +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey @Suppress("TooManyFunctions") // One small entry point per unlock factor and capability. -class LockViewModel @Inject constructor( +@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. */ 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 index d7d76988..9f63121b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LossRecoveryViewModel.kt @@ -2,14 +2,19 @@ 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 +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. */ -@HiltViewModel -class LossRecoveryViewModel @Inject constructor(private val securityManager: SecurityManager) : ViewModel() { +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class LossRecoveryViewModel(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 index 7e738469..8a7655cb 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/MainListStateViewModel.kt @@ -3,15 +3,20 @@ 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 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 -@HiltViewModel -class MainListStateViewModel @Inject constructor(private val settingsRepository: SettingsRepository) : ViewModel() { +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class MainListStateViewModel(private val settingsRepository: SettingsRepository) : ViewModel() { val isGridNoteList: StateFlow = settingsRepository.isGridNoteList.stateIn( 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 index 0dfd4575..c0497e9c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -15,7 +15,11 @@ 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.utils.todayStamp -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.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -24,7 +28,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject /** What a Markdown export writes. */ sealed interface MarkdownExportTarget { @@ -47,9 +50,11 @@ sealed interface MarkdownExportTarget { * - 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 +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey @Suppress("TooManyFunctions") // One function per step of the flow, and the helpers of the write. -class MarkdownExportViewModel @Inject constructor( +@Inject +class MarkdownExportViewModel( private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, private val store: VaultDataStore, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt index 4d2fdbd9..229ccf4f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt +++ b/app/src/main/java/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, 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 index de7e95f3..41ab03c9 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt @@ -8,7 +8,11 @@ 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 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.flow.MutableStateFlow @@ -20,18 +24,17 @@ 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() { +@ContributesIntoMap(AppScope::class, binding()) +@ViewModelKey +@Inject +class NoteSearchViewModel(observeNotesUseCase: ObserveNotesUseCase, @IoDispatcher dispatcher: CoroutineDispatcher) : + ViewModel() { private val _query = MutableStateFlow("") val query: StateFlow = _query.asStateFlow() diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 2d84416e..9ac73068 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -14,7 +14,11 @@ 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.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 +26,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 +80,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() { 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/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index a4968932..39d87aca 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -15,18 +15,23 @@ import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.WipePinChange -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 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, 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 index a0750fe6..ee12464b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt @@ -1,15 +1,17 @@ package com.pasich.encly.presentation.viewmodel import com.pasich.encly.data.model.Tag +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn 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() { +@SingleIn(AppScope::class) +@Inject +class SelectedTagHolder { private val _selectedTagFlow = MutableSharedFlow(replay = 1) val selectedTagFlow: SharedFlow = _selectedTagFlow.asSharedFlow() diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt index 9321c237..3f4ed9f9 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SettingsViewModel.kt @@ -8,16 +8,21 @@ 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 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) : ViewModel() { val languageDialogVisible = MutableStateFlow(false) diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt index 372c6d1e..7def31d2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/StatisticViewModel.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt index 35b72777..c935fb3c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt @@ -9,7 +9,11 @@ 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 @@ -24,7 +28,6 @@ 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,8 +60,10 @@ 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, diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 6f60dc4f..0941fc42 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -6,7 +6,9 @@ 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 +18,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 +37,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import javax.inject.Inject private const val TAG = "TasksViewModel" @@ -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()) @@ -710,6 +717,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/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt index 8ee0678c..539e9cfb 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ThemeViewModel.kt @@ -4,14 +4,19 @@ 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 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 javax.inject.Inject -@HiltViewModel -class ThemeViewModel @Inject constructor(settingsRepository: SettingsRepository) : ViewModel() { +@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 diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt index 509221d3..23fde1fa 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt @@ -9,15 +9,20 @@ 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.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, diff --git a/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt b/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt index eccfaa47..49ca08b7 100644 --- a/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt +++ b/app/src/main/java/com/pasich/encly/ui/theme/Theme.kt @@ -22,10 +22,10 @@ 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 +import dev.zacsweers.metrox.viewmodel.metroViewModel @Composable -fun AppTheme(themeViewModel: ThemeViewModel = enclyViewModel(), content: @Composable () -> Unit) { +fun AppTheme(themeViewModel: ThemeViewModel = metroViewModel(), content: @Composable () -> Unit) { val themeSettings by themeViewModel.themeSettingsFlow.collectAsStateWithLifecycle() val isDarkTheme = themeSettings.isDark(isSystemInDarkTheme()) diff --git a/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt b/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt index 343fb823..94eaabb6 100644 --- a/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt +++ b/app/src/main/java/com/pasich/encly/utils/DeviceCapabilities.kt @@ -2,12 +2,14 @@ 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 +import com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn -@Singleton -class DeviceCapabilities @Inject constructor(@param:ApplicationContext private val context: Context) { +@SingleIn(AppScope::class) +@Inject +class DeviceCapabilities(@param:ApplicationContext private val context: Context) { fun supportsDynamicColors(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } 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..bbfda6d3 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -0,0 +1,252 @@ +package com.pasich.encly.core.di + +import android.content.Context +import android.content.SharedPreferences +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.security.AuthenticationManager +import com.pasich.encly.core.security.AutoLock +import com.pasich.encly.core.security.BiometricManager +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.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.NoteSearchViewModel +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.StatisticViewModel +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.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 backupManager: BackupManager + val notesRepository: NotesRepository + val settingsRepository: SettingsRepository + val appFlags: SharedPreferences + + @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.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 unscopedBindingsAreMadeAnewEachTime() { + assertNotSame(graph.trashStatusUseCase, graph.trashStatusUseCase) + assertNotSame(graph.noteCopyTitle, graph.noteCopyTitle) + } + + @Test + fun theViewModelsAreTheTwentyTwoOfTheApp() { + assertEquals(PLAIN_VIEW_MODELS, graph.viewModelProviders.keys) + assertEquals(ASSISTED_VIEW_MODELS, graph.assistedFactoryProviders.keys) + assertEquals(22, 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 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(main.viewModelFactory, handoff.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, + NoteSearchViewModel::class, + OnboardingViewModel::class, + SecuritySettingsViewModel::class, + SettingsViewModel::class, + StatisticViewModel::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..aa7b5d94 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/di/NavigationViewModelsTest.kt @@ -0,0 +1,96 @@ +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.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import com.pasich.encly.ui.screens.TestRoutes +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(TestRoutes.EDIT_NOTE, arguments = TestRoutes.argumentsFor(TestRoutes.EDIT_NOTE)) { + 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(TestRoutes.EDIT_NOTE, arguments = TestRoutes.argumentsFor(TestRoutes.EDIT_NOTE)) { + 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) + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt index 40968275..675346fd 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/ComposeScreenTest.kt @@ -33,8 +33,9 @@ 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 dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory +import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory import kotlinx.coroutines.flow.StateFlow import org.junit.After import org.junit.Assert.assertTrue @@ -46,7 +47,7 @@ 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 + * 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) @@ -72,7 +73,7 @@ abstract class ComposeScreenTest { protected fun viewModels(vararg viewModels: ViewModel): FakeViewModels = FakeViewModels(*viewModels).also(owners::add) - /** [content] in the app theme, with [owner] answering every `hiltViewModel()`. */ + /** [content] in the app theme, with [owner] answering every `metroViewModel()`. */ protected fun setScreen( owner: FakeViewModels = viewModels(), settings: ThemeSettings = ThemeSettings(), @@ -241,9 +242,9 @@ abstract class ComposeScreenTest { /** * A [ViewModelStoreOwner] holding ready-made ViewModels. It deliberately has no default factory: - * `hiltViewModel()` then asks the store directly and gets the instance put here. + * `metroViewModel()` then asks the store directly and gets the instance put here. */ -class FakeViewModels(vararg viewModels: ViewModel) : PreloadedViewModelOwner { +class FakeViewModels(vararg viewModels: ViewModel) : ViewModelStoreOwner { override val viewModelStore = ViewModelStore() init { @@ -259,7 +260,14 @@ class FakeViewModels(vararg viewModels: ViewModel) : PreloadedViewModelOwner { } } +/** 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, content = content) + CompositionLocalProvider( + LocalViewModelStoreOwner provides owner, + LocalMetroViewModelFactory provides NoViewModelFactory, + content = content, + ) } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index 2df885ee..fbd512a8 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -71,7 +71,7 @@ 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) { diff --git a/build.gradle.kts b/build.gradle.kts index a18cf670..fbecfb81 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { 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.metro) apply false alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) apply false alias(libs.plugins.spotless) diff --git a/docs/architecture.md b/docs/architecture.md index 822f4063..319651be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,8 +6,8 @@ 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 021af707..a61789a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ agp = "9.4.1" kotlin = "2.4.21" ksp = "2.3.12" -hilt = "2.60.1" +metro = "1.5.0" detekt = "1.23.8" kover = "0.9.11" spotless = "8.10.4" @@ -21,7 +21,6 @@ lifecycle = "2.11.0" activityCompose = "1.13.0" appcompat = "1.8.0" navigationCompose = "2.10.2" -hiltNavigationCompose = "1.4.0" coreSplashscreen = "1.2.0" coreKtx = "1.19.1" room = "2.8.5" @@ -56,7 +55,6 @@ 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" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } @@ -72,8 +70,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa 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-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +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" } sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher" } sealite = { module = "io.github.pasichdev:sealite", version.ref = "sealite" } @@ -95,7 +93,7 @@ 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" } +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" } From 861c457aeb7323fffff99c3ede33a8ef6a5f1fa6 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 11:33:29 +0200 Subject: [PATCH 08/75] refactor(editor): the block editor as a self-contained package 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. --- app/build.gradle.kts | 66 ++ .../encly/data/export/MarkdownBlocks.kt | 10 +- .../pasich/encly/data/export/MarkdownNotes.kt | 6 +- .../pasich/encly/data/handoff/EditorJson.kt | 7 +- .../encly/data/handoff/MyNotesBlockMapper.kt | 7 +- .../encly/data/importer/ImportMapper.kt | 6 +- .../encly/data/importer/ImportReview.kt | 6 +- .../pasich/encly/data/importer/ImportText.kt | 2 +- .../pasich/encly/data/importer/ImportTypes.kt | 2 +- .../encly/data/importer/JoplinParser.kt | 2 + .../pasich/encly/data/importer/KeepParser.kt | 7 +- .../encly/data/importer/LexicalBlocks.kt | 7 +- .../data/importer/MarkdownNotesParser.kt | 2 + .../data/importer/StandardNotesParser.kt | 6 +- .../domain/enums/BottomSheetsOpenType.kt | 8 - .../pasich/encly/domain/model/NoteListItem.kt | 2 +- .../encly/dynamicBlocks/TextualBlock.kt | 23 - .../com/pasich/encly/editor/EditorServices.kt | 106 ++++ .../com/pasich/encly/editor/EditorStrings.kt | 75 +++ .../codec}/BlockConverter.kt | 21 +- .../codec}/BlockJson.kt | 17 +- .../importer => editor/codec}/ImportBlocks.kt | 10 +- .../handoff => editor/codec}/InlineHtml.kt | 11 +- .../codec}/MarkdownBlockMapper.kt | 17 +- .../editor/focus/BlockFocusRegistry.kt | 38 +- .../editor/focus/CaretGeometry.kt | 2 +- .../editor/focus/KeyboardUtils.kt | 2 +- .../{dynamicBlocks => editor/model}/Block.kt | 12 +- .../factory => editor/model}/BlockFactory.kt | 5 +- .../utils => editor/model}/BlockUtils.kt | 5 +- .../{domain => editor}/model/ItemListBlock.kt | 2 +- .../{domain => editor}/model/LinkDataBlock.kt | 2 +- .../pasich/encly/editor/model/TextualBlock.kt | 14 + .../editor/state/BlockDuplicate.kt | 11 +- .../editor/state/BlockEditing.kt | 23 +- .../editor/state/BlockEditorState.kt | 31 +- .../editor/state/BlockEditorToolbar.kt | 10 +- .../editor/state/BlockOperations.kt | 21 +- .../editor/state/BlockSelection.kt | 12 +- .../editor/state/BlockTools.kt | 25 +- .../editor/state/CaretMove.kt | 2 +- .../editor/state/CaretTarget.kt | 18 +- .../editor/state/ListEditing.kt | 8 +- .../editor/state/NoteFind.kt | 4 +- .../editor/state/NoteTemplates.kt | 62 +- .../editor/state/PasteEditing.kt | 14 +- .../editor/state/Shortcut.kt | 12 +- .../editor/state/SlashCommands.kt | 38 +- .../editor/state/SmartPaste.kt | 27 +- .../editor/state/TextEdits.kt | 2 +- .../pasich/encly/editor/theme/EditorIcons.kt | 153 +++++ .../pasich/encly/editor/theme/EditorTheme.kt | 103 +++ .../theme}/NoteSpacing.kt | 38 +- .../editor => editor/ui}/BlockActions.kt | 24 +- .../editor => editor/ui}/BlockActionsImpl.kt | 81 ++- .../pasich/encly/editor/ui/BlockFocusHooks.kt | 25 + .../editor => editor/ui}/EditorBlocksHost.kt | 92 +-- .../encly/editor/ui/EditorController.kt | 107 ++++ .../editor => editor/ui}/FindHighlights.kt | 10 +- .../editnote => editor/ui}/TapBelowBlocks.kt | 28 +- .../editor => editor/ui}/blocks/BlockInput.kt | 14 +- .../ui}/blocks/FieldHighlight.kt | 8 +- .../editor => editor/ui}/blocks/HBlock.kt | 32 +- .../pasich/encly/editor/ui/blocks/IdnShim.kt | 15 + .../ui}/blocks/LineMetrics.kt | 16 +- .../editor => editor/ui}/blocks/LinkBlock.kt | 85 +-- .../editor => editor/ui}/blocks/ListBlock.kt | 70 +-- .../editor => editor/ui}/blocks/NoteLink.kt | 9 +- .../editor => editor/ui}/blocks/QuoteBlock.kt | 69 +- .../ui}/blocks/SeparatorBlock.kt | 18 +- .../ui}/blocks/SlashMenuPopup.kt | 74 +-- .../ui}/blocks/StrikeSegment.kt | 2 +- .../editor => editor/ui}/blocks/TextBlock.kt | 27 +- .../ui}/blocks/TextBlockKeys.kt | 14 +- .../editor => editor/ui}/blocks/TextSync.kt | 6 +- .../ui/components}/CommandMenuItem.kt | 17 +- .../editor/ui/components/EditorControls.kt | 127 ++++ .../encly/editor/ui/components/EditorSheet.kt | 186 ++++++ .../editor/ui/components/EditorTextField.kt | 84 +++ .../ui/sheets}/ActionBlockDialog.kt | 2 +- .../ui/sheets}/ActionLinkBottomSheet.kt | 44 +- .../ui/sheets/ActionOtherBottomSheet.kt | 39 ++ .../encly/editor/ui/sheets/BlockEditRows.kt | 38 ++ .../encly/editor/ui/sheets/BlockSheetKind.kt | 81 +++ .../ui/sheets}/SettingsBlockDialog.kt | 4 +- .../editNote}/BlockTool.kt | 6 +- .../components/editNote/NoteBottomBar.kt | 7 +- .../components/editNote/TitleField.kt | 6 +- .../components/editNote/ToolbarState.kt | 4 +- .../encly/presentation/designsystem/Editor.kt | 15 - .../presentation/designsystem/TextFields.kt | 6 - .../dialogs/blocks/ActionOtherBottomSheet.kt | 41 -- .../dialogs/blocks/BlockEditRows.kt | 36 -- .../editor/persistence/NotePersistence.kt | 6 +- .../screen/editnote/EditNoteLocalProvider.kt | 15 +- .../screen/editnote/EditNoteScreen.kt | 39 +- .../editnote/EditNoteViewModelController.kt | 95 +++ .../screen/editnote/EditorBindings.kt | 207 ++++++ .../screen/editnote/EditorEffects.kt | 18 +- .../screen/editnote/EditorFindBar.kt | 2 +- .../screen/editnote/EditorSheets.kt | 72 +-- .../screen/editnote/HistoryShortcut.kt | 2 +- .../screen/editnote/LinkActions.kt | 10 +- .../screen/editnote/TemplateChooser.kt | 12 +- .../viewmodel/EditNoteViewModel.kt | 78 +-- .../com/pasich/encly/utils/FontSizeUtils.kt | 2 +- .../pasich/encly/utils/NotesTextFormatter.kt | 8 +- .../encly/data/backup/BackupImporterTest.kt | 10 +- .../encly/data/export/MarkdownBlocksTest.kt | 8 +- .../encly/data/export/MarkdownExportTest.kt | 8 +- .../encly/data/export/MarkdownNotesTest.kt | 6 +- .../data/handoff/MyNotesBlockMapperTest.kt | 6 +- .../data/handoff/MyNotesHandoffImportTest.kt | 4 +- .../encly/data/importer/ExpectedNote.kt | 2 +- .../encly/data/importer/ImportFixtures.kt | 4 +- .../encly/data/importer/ImportMapperTest.kt | 2 +- .../NotesnookAndMarkdownFixturesTest.kt | 2 +- .../encly/data/importer/RealExportsTest.kt | 2 +- .../encly/data/importer/TextParsersTest.kt | 2 +- .../pasich/encly/editor/EditorBoundaryTest.kt | 119 ++++ .../pasich/encly/editor/EditorServicesTest.kt | 118 ++++ .../codec}/BlockConverterTest.kt | 10 +- .../codec}/BlockJsonFormatTest.kt | 10 +- .../codec}/InlineHtmlTest.kt | 2 +- .../codec}/MarkdownBlockMapperTest.kt | 2 +- .../editor/focus/BlockFocusRegistryTest.kt | 8 +- .../editor/focus/CaretGeometryTest.kt | 10 +- .../encly/editor/focus/FocusBranchTest.kt | 140 +++++ .../encly/editor/focus/FocusGapsTest.kt | 165 +++++ .../editor/focus/KeyboardUtilsTest.kt | 2 +- .../model}/BlockFactoryTest.kt | 6 +- .../model}/BlockIdTest.kt | 2 +- .../editor/state/BlockActionsTest.kt | 10 +- .../editor/state/BlockEditingTest.kt | 10 +- .../editor/state/BlockEditorStateTest.kt | 10 +- .../editor/state/BlockOperationsTest.kt | 4 +- .../encly/editor/state/BranchGapsMoreTest.kt | 147 +++++ .../encly/editor/state/BranchGapsTest.kt | 591 ++++++++++++++++++ .../editor/state/ChecklistLockTest.kt | 10 +- .../encly/editor/state/ContentChangesTest.kt | 52 ++ .../editor/state/ConvertCaretTest.kt | 8 +- .../editor/state/MarkdownLinesTest.kt | 6 +- .../editor/state/NoteFindTest.kt | 12 +- .../editor/state/NoteJsonGoldenTest.kt | 4 +- .../editor/state/NoteTemplatesTest.kt | 13 +- .../editor/state/SlashCommandsTest.kt | 6 +- .../editor/state/SmartPasteTest.kt | 6 +- .../encly/editor/state/StateGapsTest.kt | 227 +++++++ .../editor/state/TextEditsTest.kt | 4 +- .../editor/state/UndoCaretTest.kt | 8 +- .../encly/editor/theme/NoteSpacingTest.kt | 36 ++ .../encly/editor/ui/BlockActionsImplTest.kt | 134 ++++ .../encly/editor/ui/EditorBlocksUiTest.kt | 383 ++++++++++++ .../encly/editor/ui/EditorGapsUiTest.kt | 216 +++++++ .../pasich/encly/editor/ui/StateController.kt | 172 +++++ .../ui}/TapBelowBlocksTest.kt | 3 +- .../ui}/blocks/ChecklistLayoutTest.kt | 43 +- .../encly/editor/ui/blocks/EditorDrawTest.kt | 167 +++++ .../ui}/blocks/LinkUrlTest.kt | 2 +- .../ui}/blocks/SeparatorBlockTest.kt | 7 +- .../ui}/blocks/TextSyncTest.kt | 2 +- .../editor/ui/sheets/EditorBlockSheetTest.kt | 180 ++++++ .../designsystem/NoteSpacingTest.kt | 37 -- .../presentation/dialogs/BlockSheetsTest.kt | 50 +- .../persistence/AutosaveDebounceTest.kt | 4 +- .../editor/persistence/NotePersistenceTest.kt | 4 +- .../viewmodel/EditNoteViewModelComfortTest.kt | 16 +- .../viewmodel/EditNoteViewModelEditorTest.kt | 14 +- .../EditNoteViewModelLifecycleTest.kt | 4 +- .../pasich/encly/release/NoDpLiteralsTest.kt | 8 +- .../encly/ui/screens/EditNoteScreenTest.kt | 8 +- .../encly/ui/screens/EditorWritingTest.kt | 6 +- .../pasich/encly/ui/screens/MainScreenTest.kt | 4 +- .../encly/ui/screens/ThemedScreensTest.kt | 8 +- .../pasich/encly/utils/FontSizeUtilsTest.kt | 2 +- .../utils/NotesTextFormatterBlocksTest.kt | 8 +- config/detekt/detekt.yml | 10 +- docs/architecture.md | 7 +- 178 files changed, 5385 insertions(+), 1185 deletions(-) delete mode 100644 app/src/main/java/com/pasich/encly/domain/enums/BottomSheetsOpenType.kt delete mode 100644 app/src/main/java/com/pasich/encly/dynamicBlocks/TextualBlock.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/EditorServices.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/EditorStrings.kt rename app/src/main/java/com/pasich/encly/{core/serialization => editor/codec}/BlockConverter.kt (76%) rename app/src/main/java/com/pasich/encly/{core/serialization => editor/codec}/BlockJson.kt (93%) rename app/src/main/java/com/pasich/encly/{data/importer => editor/codec}/ImportBlocks.kt (83%) rename app/src/main/java/com/pasich/encly/{data/handoff => editor/codec}/InlineHtml.kt (90%) rename app/src/main/java/com/pasich/encly/{data/importer => editor/codec}/MarkdownBlockMapper.kt (94%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/focus/BlockFocusRegistry.kt (79%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/focus/CaretGeometry.kt (98%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/focus/KeyboardUtils.kt (99%) rename app/src/main/java/com/pasich/encly/{dynamicBlocks => editor/model}/Block.kt (87%) rename app/src/main/java/com/pasich/encly/{dynamicBlocks/factory => editor/model}/BlockFactory.kt (87%) rename app/src/main/java/com/pasich/encly/{dynamicBlocks/utils => editor/model}/BlockUtils.kt (79%) rename app/src/main/java/com/pasich/encly/{domain => editor}/model/ItemListBlock.kt (91%) rename app/src/main/java/com/pasich/encly/{domain => editor}/model/LinkDataBlock.kt (79%) create mode 100644 app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockDuplicate.kt (84%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockEditing.kt (93%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockEditorState.kt (90%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockEditorToolbar.kt (88%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockOperations.kt (93%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockSelection.kt (93%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/BlockTools.kt (85%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/CaretMove.kt (96%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/CaretTarget.kt (90%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/ListEditing.kt (96%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/NoteFind.kt (97%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/NoteTemplates.kt (60%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/PasteEditing.kt (94%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/Shortcut.kt (93%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/SlashCommands.kt (76%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/SmartPaste.kt (65%) rename app/src/main/java/com/pasich/encly/{presentation => }/editor/state/TextEdits.kt (98%) create mode 100644 app/src/main/java/com/pasich/encly/editor/theme/EditorIcons.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/theme/EditorTheme.kt rename app/src/main/java/com/pasich/encly/{presentation/designsystem => editor/theme}/NoteSpacing.kt (66%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/BlockActions.kt (87%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/BlockActionsImpl.kt (50%) create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/BlockFocusHooks.kt rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/EditorBlocksHost.kt (78%) create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/EditorController.kt rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/FindHighlights.kt (65%) rename app/src/main/java/com/pasich/encly/{presentation/screen/editnote => editor/ui}/TapBelowBlocks.kt (55%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/BlockInput.kt (89%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/FieldHighlight.kt (91%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/HBlock.kt (76%) create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/blocks/IdnShim.kt rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/LineMetrics.kt (81%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/LinkBlock.kt (74%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/ListBlock.kt (90%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/NoteLink.kt (93%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/QuoteBlock.kt (68%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/SeparatorBlock.kt (70%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/SlashMenuPopup.kt (69%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/StrikeSegment.kt (97%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/TextBlock.kt (77%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/TextBlockKeys.kt (88%) rename app/src/main/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/TextSync.kt (96%) rename app/src/main/java/com/pasich/encly/{presentation/designsystem => editor/ui/components}/CommandMenuItem.kt (89%) create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/components/EditorControls.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/components/EditorSheet.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/components/EditorTextField.kt rename app/src/main/java/com/pasich/encly/{presentation/dialogs/blocks => editor/ui/sheets}/ActionBlockDialog.kt (85%) rename app/src/main/java/com/pasich/encly/{presentation/dialogs/blocks => editor/ui/sheets}/ActionLinkBottomSheet.kt (63%) create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt create mode 100644 app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt rename app/src/main/java/com/pasich/encly/{presentation/dialogs/blocks => editor/ui/sheets}/SettingsBlockDialog.kt (74%) rename app/src/main/java/com/pasich/encly/presentation/{editor => components/editNote}/BlockTool.kt (94%) delete mode 100644 app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionOtherBottomSheet.kt delete mode 100644 app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/BlockEditRows.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/EditorBoundaryTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/EditorServicesTest.kt rename app/src/test/java/com/pasich/encly/{core/serialization => editor/codec}/BlockConverterTest.kt (96%) rename app/src/test/java/com/pasich/encly/{core/serialization => editor/codec}/BlockJsonFormatTest.kt (98%) rename app/src/test/java/com/pasich/encly/{data/handoff => editor/codec}/InlineHtmlTest.kt (98%) rename app/src/test/java/com/pasich/encly/{data/importer => editor/codec}/MarkdownBlockMapperTest.kt (99%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/focus/BlockFocusRegistryTest.kt (95%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/focus/CaretGeometryTest.kt (91%) create mode 100644 app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt rename app/src/test/java/com/pasich/encly/{presentation => }/editor/focus/KeyboardUtilsTest.kt (99%) rename app/src/test/java/com/pasich/encly/{dynamicBlocks => editor/model}/BlockFactoryTest.kt (91%) rename app/src/test/java/com/pasich/encly/{dynamicBlocks => editor/model}/BlockIdTest.kt (93%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/BlockActionsTest.kt (96%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/BlockEditingTest.kt (98%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/BlockEditorStateTest.kt (98%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/BlockOperationsTest.kt (97%) create mode 100644 app/src/test/java/com/pasich/encly/editor/state/BranchGapsMoreTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/state/BranchGapsTest.kt rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/ChecklistLockTest.kt (91%) create mode 100644 app/src/test/java/com/pasich/encly/editor/state/ContentChangesTest.kt rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/ConvertCaretTest.kt (94%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/MarkdownLinesTest.kt (94%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/NoteFindTest.kt (94%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/NoteJsonGoldenTest.kt (97%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/NoteTemplatesTest.kt (91%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/SlashCommandsTest.kt (98%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/SmartPasteTest.kt (98%) create mode 100644 app/src/test/java/com/pasich/encly/editor/state/StateGapsTest.kt rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/TextEditsTest.kt (96%) rename app/src/test/java/com/pasich/encly/{presentation => }/editor/state/UndoCaretTest.kt (97%) create mode 100644 app/src/test/java/com/pasich/encly/editor/theme/NoteSpacingTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/BlockActionsImplTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/StateController.kt rename app/src/test/java/com/pasich/encly/{ui/screens => editor/ui}/TapBelowBlocksTest.kt (96%) rename app/src/test/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/ChecklistLayoutTest.kt (89%) create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt rename app/src/test/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/LinkUrlTest.kt (98%) rename app/src/test/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/SeparatorBlockTest.kt (81%) rename app/src/test/java/com/pasich/encly/{presentation/editor => editor/ui}/blocks/TextSyncTest.kt (96%) create mode 100644 app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt delete mode 100644 app/src/test/java/com/pasich/encly/presentation/designsystem/NoteSpacingTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48ae2435..8fa51033 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,9 @@ import java.util.Properties // Minimum line coverage (%) of the whole app (generated code excluded) enforced by koverVerify. val koverMinLineCoverage = 85 +// Minimum line and branch coverage (%) of the editor package, enforced by koverVerifyEditor. +val editorMinCoverage = 100 + // Derive the app version from the tracked root `version.properties` so versionName and // versionCode always stay in sync and only one file needs bumping per release. val versionProps = Properties().apply { @@ -227,10 +230,19 @@ tasks.configureEach { } } +// The app-wide gate also runs the editor's: one command checks both. +tasks.matching { it.name == "koverVerifyFdroidDebug" }.configureEach { + dependsOn("koverVerifyEditor", "koverVerifyEditorLogic") +} + // 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. tasks.withType().configureEach { + // EditorBoundaryTest reads the editor's sources. + inputs.dir(file("src/main/java/com/pasich/encly/editor")) + .withPropertyName("editorSources") + .withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootProject.file("fastlane/metadata/android")) .withPropertyName("storeMetadata") .withPathSensitivity(PathSensitivity.RELATIVE) @@ -253,7 +265,61 @@ tasks.withType().configureEach { // Coverage: `./gradlew :app:koverXmlReportFdroidDebug :app:koverVerifyFdroidDebug`. // The gate is scoped to core/security, the code that guards the key hierarchy. kover { + // A report variant of its own for the note editor (package com.pasich.encly.editor), built from + // the fdroid debug classes and tests, so its gate below can differ from the app-wide one. + currentProject { + createVariant("editor") { + add("fdroidDebug") + } + createVariant("editorLogic") { + add("fdroidDebug") + } + } reports { + // The editor is a self-contained library in waiting: every line of it is covered by its own + // tests (EditorBoundaryTest guards the other half of that contract). + variant("editor") { + filters { + includes { + packages("com.pasich.encly.editor") + } + excludes { + classes("*ComposableSingletons*") + } + } + verify { + rule("Editor line coverage") { + bound { + minValue = editorMinCoverage + coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.LINE + } + } + } + } + // Branches are gated on the editor's logic (model, codec, state, focus, theme). The branches + // of @Composable functions are not: the Compose compiler adds `$changed`/`$default` checks + // to every one of them, which no test can take both ways. + 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 = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.BRANCH + } + } + } + } variant("fdroidDebug") { filters { // Code the compilers and annotation processors write, not this project. 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 index a8fc8563..0f206a47 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt @@ -1,10 +1,10 @@ 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 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 java.util.Locale /** diff --git a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt index da81183e..40836942 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownNotes.kt @@ -1,9 +1,9 @@ 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 kotlin.time.Instant diff --git a/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt b/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt index 8f58a781..fae321e1 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/EditorJson.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt index e2edc4cf..11ea3d9c 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesBlockMapper.kt +++ b/app/src/main/java/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/importer/ImportMapper.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt index 622d1456..8e8d427e 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt @@ -4,10 +4,10 @@ import com.pasich.encly.core.backup.BackupNote import com.pasich.encly.core.backup.BackupPayload import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupTag -import com.pasich.encly.core.serialization.BlockConverter import com.pasich.encly.data.backup.BackupImporter -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.BlockType +import com.pasich.encly.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.BlockType import java.security.MessageDigest /** What the user confirms before anything is written: counts only, never content. */ diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt index e67442a3..a9143779 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReview.kt +++ b/app/src/main/java/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, diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt index ae056df2..40ea6b31 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportText.kt @@ -1,6 +1,6 @@ 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 diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt index ce97b006..58895898 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportTypes.kt @@ -2,7 +2,7 @@ package com.pasich.encly.data.importer import androidx.annotation.StringRes import com.pasich.encly.R -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block /** * The apps Encly can import from. [formats], [howTo] and [losses] are string resources shown on diff --git a/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt b/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt index f0fd8982..e964e5a9 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/JoplinParser.kt @@ -1,5 +1,7 @@ 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, diff --git a/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt b/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt index 5765993d..fd0dc161 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/KeepParser.kt +++ b/app/src/main/java/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 diff --git a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt index 8498c8db..7d658f2d 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/LexicalBlocks.kt @@ -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/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt index fb02fa68..c5db1d05 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -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]). diff --git a/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt b/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt index df411ac1..598affad 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/StandardNotesParser.kt +++ b/app/src/main/java/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 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/model/NoteListItem.kt b/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt index 68bffbcb..643b481f 100644 --- a/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt +++ b/app/src/main/java/com/pasich/encly/domain/model/NoteListItem.kt @@ -1,9 +1,9 @@ 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.editor.codec.BlockConverter import com.pasich.encly.utils.NotesTextFormatter /** 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/editor/EditorServices.kt b/app/src/main/java/com/pasich/encly/editor/EditorServices.kt new file mode 100644 index 00000000..91ba7325 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/EditorServices.kt @@ -0,0 +1,106 @@ +package com.pasich.encly.editor + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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. + */ +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. + */ + fun copy(label: String, text: String, sensitive: Boolean) + + /** Copies nothing: the default when the host provides no clipboard. */ + companion object None : EditorClipboard { + override fun copy(label: String, text: String, sensitive: Boolean) = 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. + */ +fun interface LinkOpener { + /** Opens the web or mail link [url] in another app. */ + fun open(url: String) + + /** Opens nothing: the default when the host provides no opener. */ + companion object None : LinkOpener { + override fun open(url: String) = 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. + */ +fun interface EditorLogger { + /** A recoverable problem: [event] says what failed in [tag]. */ + fun warn(tag: String, event: String) + + /** Logs nothing: the default when the host provides no logger. */ + companion object None : EditorLogger { + override fun warn(tag: String, event: String) = Unit + } +} + +/** + * Reads Markdown text pasted into a note as blocks (Smart Paste). [MarkdownBlockMapper] + * (`editor.codec`) is the editor's own reader and the default; a host may pass another. + */ +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. + */ + fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List +} + +/** The strings the editor shows; English until the host provides its own. */ +val LocalEditorStrings = staticCompositionLocalOf { EnglishEditorStrings } + +/** Where the editor copies text to; see [EditorClipboard]. */ +val LocalEditorClipboard = staticCompositionLocalOf { EditorClipboard.None } + +/** How the editor opens a link; see [LinkOpener]. */ +val LocalLinkOpener = staticCompositionLocalOf { LinkOpener.None } + +/** Where the editor reports failures; see [EditorLogger]. */ +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 +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/app/src/main/java/com/pasich/encly/editor/EditorStrings.kt b/app/src/main/java/com/pasich/encly/editor/EditorStrings.kt new file mode 100644 index 00000000..2635ef21 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/EditorStrings.kt @@ -0,0 +1,75 @@ +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. + */ +enum class EditorString(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"), + 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"), +} + +/** + * 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. + */ +interface EditorStrings { + /** The text for [key]. */ + fun text(key: EditorString): String + + /** The "Heading N" label for [level] (1 to 4): [EditorString.BLOCK_HEADING_LEVEL] with the level filled in. */ + fun heading(level: Int): String +} + +/** English texts: the default when the host provides none, and what the editor's tests read. */ +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()) +} diff --git a/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt b/app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt rename to app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt index cc8b2a39..146ae440 100644 --- a/app/src/main/java/com/pasich/encly/core/serialization/BlockConverter.kt +++ b/app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt @@ -1,8 +1,8 @@ -package com.pasich.encly.core.serialization +package com.pasich.encly.editor.codec -import com.pasich.encly.core.AppLogger -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.hasContent +import com.pasich.encly.editor.EditorLogger +import com.pasich.encly.editor.model.Block +import com.pasich.encly.editor.model.hasContent import kotlinx.serialization.SerializationException /** @@ -37,18 +37,15 @@ object BlockConverter { /** * Like [jsonToBlocks], but returns null when [json] cannot be read, so one unreadable note - * cannot crash a screen that lists or searches many notes. + * 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. */ - fun jsonToBlocksOrNull(json: String): List? = try { + 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. - logUnreadable(e) - } - - private fun logUnreadable(e: Exception): List? { - AppLogger.w("BlockConverter", "Unreadable note content: ${e.javaClass.simpleName}") - return null + logger.warn("BlockConverter", "Unreadable note content: ${e::class.simpleName}") + null } } diff --git a/app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt b/app/src/main/java/com/pasich/encly/editor/codec/BlockJson.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt rename to app/src/main/java/com/pasich/encly/editor/codec/BlockJson.kt index 4ddc68bd..10c3097f 100644 --- a/app/src/main/java/com/pasich/encly/core/serialization/BlockJson.kt +++ b/app/src/main/java/com/pasich/encly/editor/codec/BlockJson.kt @@ -1,9 +1,9 @@ -package com.pasich.encly.core.serialization +package com.pasich.encly.editor.codec -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.serialization.SerializationException import kotlinx.serialization.json.Json @@ -22,6 +22,9 @@ import kotlinx.serialization.json.put * 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 { @@ -173,7 +176,9 @@ internal object BlockJson { private fun String.escapeHtmlSensitive(): String = buildString(length) { for (c in this@escapeHtmlSensitive) { when (c) { - '<', '>', '&', '=', '\'', '\u2028', '\u2029' -> append("\\u%04x".format(c.code)) + '<', '>', '&', '=', '\'', '\u2028', '\u2029' -> + append("\\u").append(c.code.toString(HEX).padStart(UNICODE_ESCAPE_DIGITS, '0')) + else -> append(c) } } diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt b/app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt rename to app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt index a2c0f53f..ac7724b1 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportBlocks.kt +++ b/app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt @@ -1,12 +1,12 @@ -package com.pasich.encly.data.importer +package com.pasich.encly.editor.codec -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 /** Builders for the Encly blocks the parsers produce. */ -internal object ImportBlocks { +object ImportBlocks { private val PARAGRAPH_BREAK = Regex("""\n[ \t]*\n\s*""") private val HEADINGS = listOf(BlockType.H1, BlockType.H2, BlockType.H3, BlockType.H4) diff --git a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt b/app/src/main/java/com/pasich/encly/editor/codec/InlineHtml.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt rename to app/src/main/java/com/pasich/encly/editor/codec/InlineHtml.kt index 2b8275fb..c2417f55 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/InlineHtml.kt +++ b/app/src/main/java/com/pasich/encly/editor/codec/InlineHtml.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.data.handoff +package com.pasich.encly.editor.codec /** * Editor.js stores inline formatting as HTML inside a block's text (``, ``, ``, @@ -55,11 +55,14 @@ object InlineHtml { 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() + // 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 { String(Character.toChars(it)) } + return codePoint.takeIf(::isTextCodePoint)?.let { String(Character.toChars(it)) } } /** NUL and lone surrogates are not text; such an entity is kept as typed. */ diff --git a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt b/app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt rename to app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt index 040edffa..0e41b077 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/MarkdownBlockMapper.kt +++ b/app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -1,9 +1,9 @@ -package com.pasich.encly.data.importer +package com.pasich.encly.editor.codec -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 +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 /** * Markdown as Encly blocks. Line based and forgiving; only the markup it reads is removed. @@ -18,7 +18,7 @@ import com.pasich.encly.dynamicBlocks.BlockType * ([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 { +object MarkdownBlockMapper : MarkdownBlockParser { 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]*(.*)$""") @@ -52,6 +52,9 @@ object MarkdownBlockMapper { 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) + private class PasteRules(val claim: (String) -> Block?, var plainFirstLine: Boolean) /** [text] with HTML removed outside code spans. */ @@ -127,7 +130,7 @@ object MarkdownBlockMapper { val bullet = BULLET.matchEntire(raw) val number = NUMBER.matchEntire(raw) val quoted = QUOTE.matchEntire(raw) - val claimed = paste?.claim?.invoke(raw) + val claimed = paste?.run { claim(raw) } when { claimed != null -> { flushAll() diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistry.kt b/app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistry.kt rename to app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt index 9389a468..f4e34c93 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistry.kt +++ b/app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt @@ -1,13 +1,9 @@ -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 +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. */ @@ -21,13 +17,13 @@ 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 + * 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. */ -class BlockFocusRegistry(private val blocks: () -> List) { +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>() @@ -83,7 +79,7 @@ class BlockFocusRegistry(private val blocks: () -> List) { 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}") + logger.warn(TAG, "Focus request failed: ${e::class.simpleName}") } return true } @@ -96,7 +92,7 @@ class BlockFocusRegistry(private val blocks: () -> List) { 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") + 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. */ @@ -130,17 +126,3 @@ private fun MutableMap.register(blockId: String, action: T) 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/focus/CaretGeometry.kt b/app/src/main/java/com/pasich/encly/editor/focus/CaretGeometry.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/editor/focus/CaretGeometry.kt rename to app/src/main/java/com/pasich/encly/editor/focus/CaretGeometry.kt index 3e5a1485..23340ade 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/CaretGeometry.kt +++ b/app/src/main/java/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 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt b/app/src/main/java/com/pasich/encly/editor/focus/KeyboardUtils.kt similarity index 99% rename from app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt rename to app/src/main/java/com/pasich/encly/editor/focus/KeyboardUtils.kt index 972e7c35..681398e8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/focus/KeyboardUtils.kt +++ b/app/src/main/java/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 diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt b/app/src/main/java/com/pasich/encly/editor/model/Block.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt rename to app/src/main/java/com/pasich/encly/editor/model/Block.kt index 169a142c..498986e7 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/Block.kt +++ b/app/src/main/java/com/pasich/encly/editor/model/Block.kt @@ -1,9 +1,6 @@ -package com.pasich.encly.dynamicBlocks +package com.pasich.encly.editor.model -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 com.pasich.encly.editor.EditorString import kotlinx.coroutines.flow.MutableStateFlow import kotlin.uuid.Uuid @@ -35,7 +32,7 @@ sealed class Block { data class TextBlock( override val text: MutableStateFlow = MutableStateFlow(""), - val placeholder: Int = R.string.text, + val placeholder: EditorString = EditorString.TEXT, override val id: String = newBlockId(), ) : Block(), TextualBlock @@ -60,8 +57,7 @@ sealed class Block { override val id: String = newBlockId(), ) : Block() - data class SeparatorBlock(@Transient val color: Color = Color.Gray, override val id: String = newBlockId()) : - Block() + data class SeparatorBlock(override val id: String = newBlockId()) : Block() data class ListBlock( val items: MutableStateFlow> = MutableStateFlow( diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt b/app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt rename to app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt index 8ba38292..9151fbc7 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/factory/BlockFactory.kt +++ b/app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt @@ -1,7 +1,4 @@ -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. diff --git a/app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt b/app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt rename to app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt index 4a80f9db..9984b698 100644 --- a/app/src/main/java/com/pasich/encly/dynamicBlocks/utils/BlockUtils.kt +++ b/app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt @@ -1,7 +1,4 @@ -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. diff --git a/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt b/app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt rename to app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt index 493270b0..df9bac08 100644 --- a/app/src/main/java/com/pasich/encly/domain/model/ItemListBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.domain.model +package com.pasich.encly.editor.model import kotlin.uuid.Uuid diff --git a/app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt b/app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt similarity index 79% rename from app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt rename to app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt index 454ff652..83c36182 100644 --- a/app/src/main/java/com/pasich/encly/domain/model/LinkDataBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.domain.model +package com.pasich.encly.editor.model data class LinkDataBlock( var title: String = "", diff --git a/app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt b/app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt new file mode 100644 index 00000000..95efc636 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt @@ -0,0 +1,14 @@ +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. + */ +interface TextualBlock { + val text: MutableStateFlow +} + +/** Whether the block's text is empty. */ +fun TextualBlock.isEmpty(): Boolean = text.value.isEmpty() diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt index 3695a332..15ac90c3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockDuplicate.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt @@ -1,12 +1,12 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block -import com.pasich.encly.dynamicBlocks.newBlockId +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]). */ fun BlockEditorState.duplicateInteracted() { - blocks.getOrNull(selection.interactedIndex)?.let { duplicateBlockOf(it.id) } + duplicateBlockOf(blocks[selection.interactedIndex].id) } /** @@ -17,7 +17,8 @@ fun BlockEditorState.duplicateInteracted() { */ fun BlockEditorState.duplicateBlockOf(blockId: String): Boolean { val index = indexOf(blockId) - val copy = blocks.getOrNull(index)?.duplicate() ?: return false + 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 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockEditing.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockEditing.kt index ccb1d5c7..07471c08 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditing.kt +++ b/app/src/main/java/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 @@ -122,12 +122,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) } @@ -175,8 +179,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 } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt index 85df4c6b..51138b97 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorState.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt @@ -1,10 +1,12 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 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 @@ -26,7 +28,11 @@ enum class BlockRemoveAction { REMOVE, REMOVE_BACKSPACE, REMOVE_BACKSPACE_LIST } * 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) { +class BlockEditorState( + /** Reads pasted Markdown for Smart Paste. */ + internal val markdown: MarkdownBlockParser = MarkdownBlockMapper, + clock: () -> Long = ::currentTimeMillis, +) { private val _blocks = SnapshotStateList().apply { add(emptyNoteBlock()) } val blocks: List get() = _blocks @@ -112,12 +118,16 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { val old = _blocks[index] val caret = selection.caretOf(old.id) val item = selection.itemOf(old.id) - val target = carriedCaret(old, newBlock, caret, item) + // 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. - if (target != null && caret != null) attachCaretToLastStep(CaretTarget(old.id, item, caret), target) + carried?.let { (from, to) -> attachCaretToLastStep(from, to) } afterChange() - selection.focusAt(index, caret = target?.caret, itemId = target?.itemId) + val carriedTo = carried?.second + selection.focusAt(index, caret = carriedTo?.caret, itemId = carriedTo?.itemId) return true } @@ -195,7 +205,7 @@ class BlockEditorState(clock: () -> Long = System::currentTimeMillis) { } /** The block a note without content shows. */ -private fun emptyNoteBlock(): Block = Block.TextBlock(placeholder = R.string.press_to_edit) +private fun emptyNoteBlock(): Block = Block.TextBlock(placeholder = EditorString.PRESS_TO_EDIT) private fun List.isEmptyFirstTextBlock(index: Int): Boolean { val block = getOrNull(index) @@ -223,8 +233,7 @@ private fun Block.contentFlow(): Flow? = when (this) { * 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 +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 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt index d5128990..747f6d24 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockEditorToolbar.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block // Editor toolbar actions: they act on the block the user last worked on. @@ -8,7 +8,7 @@ import com.pasich.encly.dynamicBlocks.Block 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 + return to in blocks.indices } /** Moves the block the user works on one place [up] (or down). */ @@ -32,11 +32,11 @@ fun BlockEditorState.addBlockAfter(afterIndex: Int, block: Block) { 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) + if (from >= 0 && 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 +fun BlockEditorState.canRemoveInteracted(): Boolean = blocks.size > 1 /** Removes the block the user works on. */ fun BlockEditorState.removeInteracted() { diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockOperations.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockOperations.kt index 411ed391..1e8edfa0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockOperations.kt +++ b/app/src/main/java/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. @@ -20,7 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow */ 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() @@ -160,7 +164,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 /** @@ -178,11 +183,15 @@ class BlockOperations( /** 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. diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt b/app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt index b1be4d78..00f6d6a1 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockSelection.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -124,7 +124,8 @@ class BlockSelection(private val blocks: List) { * 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 + val block = blocks.getOrNull(index) ?: return + val blockId = block.id onFocused(blockId) caret?.let { carets[blockId] = it } // The item asked for is where the cursor now is, before its field reports it back. @@ -135,6 +136,9 @@ class BlockSelection(private val blocks: List) { } } +/** 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. */ fun Block.canTakeFocus(): Boolean = when (this) { is Block.TextBlock, is Block.HBlock, is Block.QuoteBlock -> true @@ -153,6 +157,6 @@ fun Block.canTakeFocus(): Boolean = when (this) { * 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() } + val above = (index - 1 downTo 0).firstOrNull { it < size && 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/editor/state/BlockTools.kt similarity index 85% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/BlockTools.kt rename to app/src/main/java/com/pasich/encly/editor/state/BlockTools.kt index e1dd56ca..e51be73f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/BlockTools.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/BlockTools.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.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.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. @@ -22,9 +22,10 @@ fun BlockEditorState.focusFirstBlock() { */ 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) { + // 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 } selection.focusAt(target, cursorToEnd = true, caret = caret) @@ -66,8 +67,8 @@ fun Block.toolType(): BlockType? = when (this) { 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) } + if ((blocks[index] as? Block.ListBlock)?.let { it.locked.value } == true) return + val converted = convertBlock(blocks[index], tool, exact) when { converted != null -> replaceBlock(index, converted) tool == BlockType.SEPARATOR -> addSeparator() @@ -101,7 +102,7 @@ private fun convertText(block: Block, text: String, tool: BlockType): Block? = w 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) } + val items = if (block is Block.ListBlock) block.items.value else text.split('\n').map { ItemListBlock(it) } Block.ListBlock(MutableStateFlow(items), blockType = tool) } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt b/app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt rename to app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt index 431fd745..7cca2120 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretMove.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt @@ -1,4 +1,4 @@ -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 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt b/app/src/main/java/com/pasich/encly/editor/state/CaretTarget.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt rename to app/src/main/java/com/pasich/encly/editor/state/CaretTarget.kt index 1aedcc28..5b20f569 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/CaretTarget.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/CaretTarget.kt @@ -1,8 +1,8 @@ -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.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) @@ -73,8 +73,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 +82,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 +102,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/app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt b/app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt rename to app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt index c72e9749..13d3975d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/ListEditing.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt @@ -1,7 +1,7 @@ -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.editor.model.Block +import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow // Enter, Backspace and a paste in a list's items: an item splits at the cursor, an empty item @@ -23,7 +23,7 @@ private data class ListItemAt(val index: Int, val list: Block.ListBlock, val at: 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 + 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 } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt b/app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt similarity index 97% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt rename to app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt index 007502a4..2dfa1479 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteFind.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt b/app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt similarity index 60% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt rename to app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt index cd003950..b3a73986 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/NoteTemplates.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt @@ -1,20 +1,19 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 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 resources, so they work offline and +// 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. */ 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 + /** A heading of [type] (H1-H4) reading the string [text]. */ + data class Heading(val type: BlockType, val text: EditorString) : TemplatePart /** An empty paragraph to write in. */ data object Paragraph : TemplatePart @@ -24,66 +23,66 @@ sealed interface 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) { +enum class NoteTemplate(val label: EditorString, val parts: List) { /** What a new note is today: nothing. */ - BLANK(R.string.template_blank, emptyList()), + BLANK(EditorString.TEMPLATE_BLANK, emptyList()), MEETING( - R.string.template_meeting, + EditorString.TEMPLATE_MEETING, listOf( - heading(R.string.template_attendees), + heading(EditorString.TEMPLATE_ATTENDEES), TemplatePart.ListOf(BlockType.LIST_BULLET), - heading(R.string.template_agenda), + heading(EditorString.TEMPLATE_AGENDA), TemplatePart.ListOf(BlockType.LIST_NUMBER), - heading(R.string.template_notes), + heading(EditorString.TEMPLATE_NOTES), TemplatePart.Paragraph, - heading(R.string.template_action_items), + heading(EditorString.TEMPLATE_ACTION_ITEMS), TemplatePart.ListOf(BlockType.LIST_CHECK), ), ), JOURNAL( - R.string.template_journal, + EditorString.TEMPLATE_JOURNAL, listOf( - heading(R.string.template_journal_today), + heading(EditorString.TEMPLATE_JOURNAL_TODAY), TemplatePart.Paragraph, - heading(R.string.template_journal_gratitude), + heading(EditorString.TEMPLATE_JOURNAL_GRATITUDE), TemplatePart.ListOf(BlockType.LIST_BULLET), - heading(R.string.template_journal_tomorrow), + heading(EditorString.TEMPLATE_JOURNAL_TOMORROW), TemplatePart.Paragraph, ), ), PROJECT( - R.string.template_project, + EditorString.TEMPLATE_PROJECT, listOf( - heading(R.string.template_project_goal), + heading(EditorString.TEMPLATE_PROJECT_GOAL), TemplatePart.Paragraph, - heading(R.string.template_project_tasks), + heading(EditorString.TEMPLATE_PROJECT_TASKS), TemplatePart.ListOf(BlockType.LIST_CHECK), - heading(R.string.template_project_decisions), + heading(EditorString.TEMPLATE_PROJECT_DECISIONS), TemplatePart.ListOf(BlockType.LIST_BULLET), - heading(R.string.template_project_questions), + heading(EditorString.TEMPLATE_PROJECT_QUESTIONS), TemplatePart.ListOf(BlockType.LIST_BULLET), ), ), CHECKLIST( - R.string.block_checklist, + EditorString.BLOCK_CHECKLIST, listOf( - heading(R.string.template_checklist_todo), + heading(EditorString.TEMPLATE_CHECKLIST_TODO), TemplatePart.ListOf(BlockType.LIST_CHECK), ), ), } -private fun heading(@StringRes text: Int) = TemplatePart.Heading(BlockType.H2, text) +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]. */ -fun NoteTemplate.blocks(resolve: (Int) -> String): List = parts.map { part -> +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) @@ -96,7 +95,7 @@ fun NoteTemplate.blocks(resolve: (Int) -> String): List = parts.map { par /** 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 + (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 @@ -110,8 +109,7 @@ fun BlockEditorState.applyTemplate(template: List): Boolean { 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) + selection.focusAt(target, caret = 0, itemId = template[target].firstItemId()) return true } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt b/app/src/main/java/com/pasich/encly/editor/state/PasteEditing.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt rename to app/src/main/java/com/pasich/encly/editor/state/PasteEditing.kt index d22c94c6..95c60c9a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/PasteEditing.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/PasteEditing.kt @@ -1,7 +1,7 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -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.TextualBlock import kotlinx.coroutines.flow.MutableStateFlow /** @@ -20,7 +20,13 @@ internal fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBr // 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()) + 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. diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt b/app/src/main/java/com/pasich/encly/editor/state/Shortcut.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt rename to app/src/main/java/com/pasich/encly/editor/state/Shortcut.kt index b4f3d18b..54cd1d13 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/Shortcut.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/Shortcut.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 // The markdown-like marks a writer types at the start of a paragraph ("# ", "- ", "1. ", diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt b/app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt rename to app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt index 083052e9..4045ea1b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/SlashCommands.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt @@ -1,10 +1,9 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 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 @@ -19,21 +18,21 @@ import kotlinx.coroutines.flow.asStateFlow */ enum class SlashCommand( val type: BlockType, - @param:StringRes val label: Int, + val label: EditorString, 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")), + 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")), } /** @@ -138,8 +137,7 @@ fun BlockEditorState.applySlash(blockId: String, command: SlashCommand): Boolean 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) + selection.focusAt(index, caret = 0, itemId = replacement.firstItemId()) } } 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/editor/state/SmartPaste.kt similarity index 65% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt rename to app/src/main/java/com/pasich/encly/editor/state/SmartPaste.kt index fd738a0c..91f102d2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/SmartPaste.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/SmartPaste.kt @@ -1,9 +1,10 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.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 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. @@ -19,19 +20,23 @@ fun trimBlankLines(lines: List): List { } /** - * 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 + * Pasted [lines] as blocks, read as Markdown by [markdown] (by default the editor's own + * [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 { +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 MarkdownBlockMapper.pasteBlocks(text, plainFirstLine, ::pastedAddress) + return markdown.parsePaste(text, plainFirstLine, ::pastedAddress) } /** [line] with the editor's own short marks ("[] ", "[x] ", "• ") written the way Markdown does. */ diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt b/app/src/main/java/com/pasich/encly/editor/state/TextEdits.kt similarity index 98% rename from app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt rename to app/src/main/java/com/pasich/encly/editor/state/TextEdits.kt index 6d097b7d..1eaba4dd 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/state/TextEdits.kt +++ b/app/src/main/java/com/pasich/encly/editor/state/TextEdits.kt @@ -1,4 +1,4 @@ -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) { diff --git a/app/src/main/java/com/pasich/encly/editor/theme/EditorIcons.kt b/app/src/main/java/com/pasich/encly/editor/theme/EditorIcons.kt new file mode 100644 index 00000000..56f7b055 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/theme/EditorIcons.kt @@ -0,0 +1,153 @@ +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. +class EditorIcons( + val text: ImageVector = DefaultIcons.Type, + val heading1: ImageVector = DefaultIcons.Heading1, + val heading2: ImageVector = DefaultIcons.Heading2, + val heading3: ImageVector = DefaultIcons.Heading3, + val heading4: ImageVector = DefaultIcons.Heading4, + val checklist: ImageVector = DefaultIcons.Checklist, + val bulletList: ImageVector = DefaultIcons.ListBullet, + val numberedList: ImageVector = DefaultIcons.ListNumbered, + val quote: ImageVector = DefaultIcons.Quote, + val link: ImageVector = DefaultIcons.Link, + val separator: ImageVector = DefaultIcons.Separator, + val globe: ImageVector = DefaultIcons.Globe, + val plus: ImageVector = DefaultIcons.Plus, + val check: ImageVector = DefaultIcons.Check, + val external: ImageVector = DefaultIcons.External, + val copy: ImageVector = DefaultIcons.Copy, + val edit: ImageVector = DefaultIcons.Edit, + val arrowUp: ImageVector = DefaultIcons.ArrowUp, + val arrowDown: ImageVector = DefaultIcons.ArrowDown, + val duplicate: ImageVector = DefaultIcons.Duplicate, + val trash: ImageVector = DefaultIcons.Trash, +) + +private const val STROKE = 1.75f +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", + ) + + 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/app/src/main/java/com/pasich/encly/editor/theme/EditorTheme.kt b/app/src/main/java/com/pasich/encly/editor/theme/EditorTheme.kt new file mode 100644 index 00000000..7c4f9fe4 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/theme/EditorTheme.kt @@ -0,0 +1,103 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Immutable +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 +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]. */ +val LocalEditorTheme = staticCompositionLocalOf { EditorTheme() } + +/** Spacing and sizes on a 4 dp base. */ +@Immutable +@Suppress("LongParameterList") // One named token each. +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 +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 +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. + */ +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. */ +val EditorToolbarReserve = 72.dp + +/** How faint a placeholder is drawn. */ +internal const val EDITOR_PLACEHOLDER_ALPHA = 0.38f diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt b/app/src/main/java/com/pasich/encly/editor/theme/NoteSpacing.kt similarity index 66% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt rename to app/src/main/java/com/pasich/encly/editor/theme/NoteSpacing.kt index 8f065081..6d6735c3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/NoteSpacing.kt +++ b/app/src/main/java/com/pasich/encly/editor/theme/NoteSpacing.kt @@ -1,10 +1,9 @@ -package com.pasich.encly.presentation.designsystem +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 -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. @@ -16,42 +15,45 @@ 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. */ +enum class EditorLineSpacing { COMPACT, DEFAULT, COMFORTABLE } + /** Whether lines sit as close as the font allows (no leading, no gaps between blocks). */ -internal val NoteLineSpacing.isCompact: Boolean get() = this == NoteLineSpacing.COMPACT +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 NoteLineSpacing.lineHeight(fontSize: TextUnit, normal: TextUnit): TextUnit { +fun EditorLineSpacing.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 + 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 NoteLineSpacing.frameInset: Dp +val EditorLineSpacing.frameInset: Dp get() = when (this) { - NoteLineSpacing.COMPACT -> 0.dp - NoteLineSpacing.DEFAULT -> FRAME_INSET_DEFAULT - NoteLineSpacing.COMFORTABLE -> FRAME_INSET_COMFORTABLE + 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]. */ -internal fun NoteLineSpacing.blockGap(base: Dp): Dp = when (this) { - NoteLineSpacing.COMPACT -> 0.dp - NoteLineSpacing.DEFAULT -> base - NoteLineSpacing.COMFORTABLE -> base + BLOCK_GAP_EXTRA_COMFORTABLE +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 NoteLineSpacing.listItemGap(checklist: Boolean, base: Dp): Dp = when { - this == NoteLineSpacing.COMPACT || (checklist && this == NoteLineSpacing.DEFAULT) -> 0.dp - this == NoteLineSpacing.COMFORTABLE -> LIST_ITEM_GAP_COMFORTABLE +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 } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt b/app/src/main/java/com/pasich/encly/editor/ui/BlockActions.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt rename to app/src/main/java/com/pasich/encly/editor/ui/BlockActions.kt index 956a0fa3..ed7a5703 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActions.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/BlockActions.kt @@ -1,15 +1,15 @@ -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 +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 kotlinx.coroutines.flow.StateFlow /** diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt b/app/src/main/java/com/pasich/encly/editor/ui/BlockActionsImpl.kt similarity index 50% rename from app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt rename to app/src/main/java/com/pasich/encly/editor/ui/BlockActionsImpl.kt index 03e8c228..8b9bcea0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockActionsImpl.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/BlockActionsImpl.kt @@ -1,20 +1,19 @@ -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 +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.goesUp +import com.pasich.encly.editor.state.requestFor import kotlinx.coroutines.flow.StateFlow /** @@ -25,56 +24,56 @@ import kotlinx.coroutines.flow.StateFlow @Suppress("TooManyFunctions") // Implements BlockActions, one editor call each. internal class BlockActionsImpl( private val block: Block, - private val viewModel: EditNoteViewModel, + private val controller: EditorController, private val focusRegistry: BlockFocusRegistry, override val highlights: FindHighlights, ) : BlockActions { - private val index: Int get() = viewModel.indexOfBlock(block) + private val index: Int get() = controller.indexOfBlock(block) override fun onAddParagraph() { val current = index - if (current >= 0) viewModel.addBlockAfter(current, BlockType.TEXT) + if (current >= 0) controller.addBlockAfter(current, BlockType.TEXT) } override fun onRemoveBlock(blockRemoveAction: BlockRemoveAction) { - viewModel.removeBlock(block, blockRemoveAction) + controller.removeBlock(block, blockRemoveAction) } override fun onReplaceBlock(type: Block) { val current = index - if (current >= 0) viewModel.replaceBlock(current, type) + if (current >= 0) controller.replaceBlock(current, type) } override fun onTextChanged(newText: String) { - viewModel.onBlockTextChanged(block, newText) + controller.onBlockTextChanged(block, newText) } override fun onListItemsChanged(newItems: List, mergeable: Boolean) { - if (block is Block.ListBlock) viewModel.onListItemsChanged(block, newItems, mergeable) + if (block is Block.ListBlock) controller.onListItemsChanged(block, newItems, mergeable) } override fun onLinkChanged(newLink: LinkDataBlock) { - if (block is Block.LinkBlock) viewModel.onLinkChanged(block, newLink) + if (block is Block.LinkBlock) controller.onLinkChanged(block, newLink) } - override fun onLineBreak(lineBreak: LineBreak): String? = viewModel.onLineBreak(block, lineBreak) + override fun onLineBreak(lineBreak: LineBreak): String? = controller.onLineBreak(block, lineBreak) - override fun onBackspaceAtStart(): Boolean = viewModel.onBackspaceAtStart(block) + override fun onBackspaceAtStart(): Boolean = controller.onBackspaceAtStart(block) - override fun onShortcut(typed: String, shortcut: Shortcut): Boolean = viewModel.onShortcut(block, typed, shortcut) + override fun onShortcut(typed: String, shortcut: Shortcut): Boolean = controller.onShortcut(block, typed, shortcut) override fun onLinkPasted(pasted: String, link: LinkDataBlock): Boolean = - viewModel.onLinkPasted(block, pasted, link) + controller.onLinkPasted(block, pasted, link) override fun onListLineBreak(itemId: String, lineBreak: LineBreak): String? = - (block as? Block.ListBlock)?.let { viewModel.onListLineBreak(it, itemId, lineBreak) } + (block as? Block.ListBlock)?.let { controller.onListLineBreak(it, itemId, lineBreak) } override fun onListBackspaceAtStart(itemId: String): Boolean = - (block as? Block.ListBlock)?.let { viewModel.onListBackspaceAtStart(it, itemId) } == true + (block as? Block.ListBlock)?.let { controller.onListBackspaceAtStart(it, itemId) } == true - override fun onCaretMoved(offset: Int) = viewModel.onCaretMoved(block, offset) + override fun onCaretMoved(offset: Int) = controller.onCaretMoved(block, offset) - override fun onListItemCaret(itemId: String, offset: Int) = viewModel.onListItemCaret(block, itemId, 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) @@ -84,22 +83,22 @@ internal class BlockActionsImpl( override val slash: SlashActions = object : SlashActions { override val blockId: String get() = block.id - override val menu: StateFlow get() = viewModel.slashPicker.menu + override val menu: StateFlow get() = controller.slashPicker.menu - override fun commit() = viewModel.slashPicker.commit() + override fun commit() = controller.slashPicker.commit() override fun select(command: SlashCommand) { - viewModel.applySlash(block, command) + controller.applySlash(block, command) } - override fun dismiss() = viewModel.slashPicker.dismiss() + override fun dismiss() = controller.slashPicker.dismiss() - override fun moveHighlight(delta: Int) = viewModel.slashPicker.moveHighlight(delta) + override fun moveHighlight(delta: Int) = controller.slashPicker.moveHighlight(delta) - override fun setLabels(labels: Map) = viewModel.slashPicker.setLabels(labels) + override fun setLabels(labels: Map) = controller.slashPicker.setLabels(labels) } - override fun onSlashTyped(text: String, started: Boolean) = viewModel.onSlashTyped(block, text, started) + override fun onSlashTyped(text: String, started: Boolean) = controller.onSlashTyped(block, text, started) override fun navigate(move: CaretMove): Boolean { val target = if (move.goesUp) { @@ -109,7 +108,7 @@ internal class BlockActionsImpl( } // 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!!) + if (moved) controller.onBlockFocused(target!!) return moved } diff --git a/app/src/main/java/com/pasich/encly/editor/ui/BlockFocusHooks.kt b/app/src/main/java/com/pasich/encly/editor/ui/BlockFocusHooks.kt new file mode 100644 index 00000000..091dff8a --- /dev/null +++ b/app/src/main/java/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 +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 +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/EditorBlocksHost.kt b/app/src/main/java/com/pasich/encly/editor/ui/EditorBlocksHost.kt similarity index 78% rename from app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt rename to app/src/main/java/com/pasich/encly/editor/ui/EditorBlocksHost.kt index 1b8f432d..05024949 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor +package com.pasich.encly.editor.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitFirstDown @@ -23,36 +23,33 @@ 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 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.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.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.SeparatorBlock +import com.pasich.encly.editor.ui.blocks.TextBlock +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. */ class EditorBlocksHost( - val viewModel: EditNoteViewModel, + val controller: EditorController, val focusRegistry: BlockFocusRegistry, val highlights: FindHighlights, - val onOpenSheet: (BottomSheetsOpenType, Block) -> Unit, + val onOpenSheet: (BlockSheetKind, Block) -> Unit, ) /** @@ -76,20 +73,20 @@ fun LazyListScope.editorBlocks( contentType = { _, block -> block::class.simpleName }, ) { index, block -> val blockActions = - remember(block, host) { BlockActionsImpl(block, host.viewModel, host.focusRegistry, host.highlights) } + remember(block, host) { BlockActionsImpl(block, host.controller, 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) }, + onFocus = { host.controller.onBlockFocused(block) }, + onInteract = { host.controller.onBlockInteraction(block) }, + onToggleLock = { host.controller.toggleChecklistLock(block) }, + onFocusLost = { host.controller.onBlockFocusLost(block) }, onOpenSheet = { type -> - host.viewModel.onBlockInteraction(block) + host.controller.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) }, + onMove = { up -> host.controller.moveBlockOf(block, up) }, + onDuplicate = { host.controller.duplicateBlock(block) }, + onDelete = { host.controller.removeBlock(block, BlockRemoveAction.REMOVE) }, ) } EditorBlock( @@ -111,14 +108,14 @@ fun LazyListScope.editorBlocks( val keyboardController = LocalSoftwareKeyboardController.current Spacer( Modifier - .height(EnclyTheme.spacing.editorTapArea) + .height(LocalEditorTheme.current.spacing.tapArea) .fillMaxWidth() .clickable( enabled = !isLocked, indication = null, interactionSource = remember { MutableInteractionSource() }, ) { - host.viewModel.addBlockToEnd() + host.controller.addBlockToEnd() // The last field may still hold focus with the keyboard hidden: focusing it again shows nothing. keyboardController?.show() }, @@ -136,7 +133,7 @@ private class EditorBlockCallbacks( val onInteract: () -> Unit, val onToggleLock: () -> Unit, val onFocusLost: () -> Unit, - val onOpenSheet: (BottomSheetsOpenType) -> Unit, + val onOpenSheet: (BlockSheetKind) -> Unit, val onMove: (up: Boolean) -> Unit, val onDuplicate: () -> Unit, val onDelete: () -> Unit, @@ -151,11 +148,12 @@ private fun EditorBlock( blockActions: BlockActions, callbacks: EditorBlockCallbacks, focusRegistry: BlockFocusRegistry, + position: BlockPosition, isLocked: Boolean = false, isEditingLink: Boolean = false, isOnlyBlock: Boolean = false, - position: BlockPosition = BlockPosition(first = true, last = true), ) { + val theme = LocalEditorTheme.current 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) @@ -171,7 +169,7 @@ private fun EditorBlock( // 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) + .padding(horizontal = theme.spacing.m) .onFocusChanged { focusState -> hasFocus = focusState.hasFocus // A list has one field per item: any of them focused counts as the list focused, @@ -183,7 +181,7 @@ private fun EditorBlock( } } .editorBlockFrame( - LocalNoteLineSpacing.current.frameInset.takeIf { + theme.lineSpacing.frameInset.takeIf { block is Block.TextBlock || block is Block.ListBlock || block is Block.QuoteBlock }, ), @@ -218,11 +216,12 @@ private fun blockAccessibilityActions( 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 strings = LocalEditorStrings.current + val moveUp = strings.text(EditorString.BLOCK_MOVE_UP) + val moveDown = strings.text(EditorString.BLOCK_MOVE_DOWN) + val duplicate = strings.text(EditorString.BLOCK_DUPLICATE) + val delete = strings.text(EditorString.BLOCK_DELETE) + val lock = strings.text(if (checklistLocked == true) EditorString.CHECKLIST_UNLOCK else EditorString.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) } }) @@ -242,7 +241,7 @@ private fun BlockContent( blockActions: BlockActions, focusRequester: FocusRequester, state: BlockContentState, - onOpenSheet: (BottomSheetsOpenType) -> Unit, + onOpenSheet: (BlockSheetKind) -> Unit, modifier: Modifier = Modifier, ) { // [modifier] reaches the block's field (its TalkBack actions); the focus target joins it. @@ -272,14 +271,15 @@ private fun BlockContent( is Block.LinkBlock -> LinkBlock( block, blockActions = blockActions, - onClick = { onOpenSheet(BottomSheetsOpenType.ACTION_LINK) }, + onClick = { onOpenSheet(BlockSheetKind.LINK) }, fieldModifier = fieldModifier, isLocked = isLocked, isEditing = state.isEditingLink, ) - is Block.SeparatorBlock -> SeparatorBlock( - onClick = { onOpenSheet(BottomSheetsOpenType.ACTION_OTHER) }, + // 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, ) diff --git a/app/src/main/java/com/pasich/encly/editor/ui/EditorController.kt b/app/src/main/java/com/pasich/encly/editor/ui/EditorController.kt new file mode 100644 index 00000000..fd12f425 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/ui/EditorController.kt @@ -0,0 +1,107 @@ +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.state.BlockRemoveAction +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.SlashPicker + +/** + * 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. +interface EditorController { + /** The note's blocks, live. */ + val blocks: List + + /** The slash picker the blocks show. */ + val slashPicker: SlashPicker + + /** Position of [block]; -1 when it is not in the note (any more). */ + fun indexOfBlock(block: Block): Int + + /** [block] got focus. */ + fun onBlockFocused(block: Block) + + /** [block] lost focus. */ + fun onBlockFocusLost(block: Block) + + /** The user works on [block] without its field taking focus (a touch, its sheet). */ + fun onBlockInteraction(block: Block) + + /** Adds a block of [blockType] after position [afterIndex] and focuses it. */ + fun addBlockAfter(afterIndex: Int, blockType: BlockType) + + /** Focuses the trailing paragraph, adding one first unless the note already ends in one. */ + fun addBlockToEnd() + + /** Removes [block] if [action] allows it. */ + fun removeBlock(block: Block, action: BlockRemoveAction) + + /** Replaces the block at [index] with [newBlock]; false when there is none there. */ + fun replaceBlock(index: Int, newBlock: Block): Boolean + + /** Moves the block the user works on one place [up] (or down). */ + fun moveBlock(up: Boolean) + + /** Moves [block] one place [up] (or down). */ + 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]. */ + fun interactedBlockPosition(): Int + + /** Duplicates [block] right after itself. */ + fun duplicateBlock(block: Block) + + /** Locks or unlocks the checklist [block]. */ + fun toggleChecklistLock(block: Block) + + /** Reopens the address of the saved link [block] for editing. */ + fun editLink(block: Block.LinkBlock) + + /** A paragraph, heading or quote [block] now reads [newText]. */ + fun onBlockTextChanged(block: Block, newText: String) + + /** The items of the list [block] changed; typing is [mergeable] into one undo step. */ + fun onListItemsChanged(block: Block.ListBlock, newItems: List, mergeable: Boolean) + + /** The link [block] now holds [newLink]. */ + 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. */ + fun onLineBreak(block: Block, lineBreak: LineBreak): String? + + /** Backspace at the start of [block]'s field; false when it does nothing. */ + fun onBackspaceAtStart(block: Block): Boolean + + /** A mark [typed] at the start of [block]'s paragraph; true when applied. */ + fun onShortcut(block: Block, typed: String, shortcut: Shortcut): Boolean + + /** A lone web address [pasted] into the empty paragraph [block]; true when it became [link]. */ + fun onLinkPasted(block: Block, pasted: String, link: LinkDataBlock): Boolean + + /** A line break in item [itemId] of [block]. */ + fun onListLineBreak(block: Block.ListBlock, itemId: String, lineBreak: LineBreak): String? + + /** Backspace at the start of item [itemId] of [block]. */ + fun onListBackspaceAtStart(block: Block.ListBlock, itemId: String): Boolean + + /** The cursor in [block]'s field moved to [offset]. */ + fun onCaretMoved(block: Block, offset: Int) + + /** The cursor in item [itemId] of [block] moved to [offset]. */ + fun onListItemCaret(block: Block, itemId: String, offset: Int) + + /** The paragraph [block] reads [text]; [started] when a "/" was just typed into the empty paragraph. */ + fun onSlashTyped(block: Block, text: String, started: Boolean) + + /** Turns the paragraph [block] into [command]'s block. */ + fun applySlash(block: Block, command: SlashCommand) +} diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt b/app/src/main/java/com/pasich/encly/editor/ui/FindHighlights.kt similarity index 65% rename from app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt rename to app/src/main/java/com/pasich/encly/editor/ui/FindHighlights.kt index 47138620..af23db06 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/FindHighlights.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/FindHighlights.kt @@ -1,9 +1,9 @@ -package com.pasich.encly.presentation.editor +package com.pasich.encly.editor.ui 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 +import com.pasich.encly.editor.state.FindMatch +import com.pasich.encly.editor.state.FindState +import com.pasich.encly.editor.state.fieldKey /** * What the blocks tint for Find in this note. It reads the find state only while drawing, so a @@ -12,7 +12,7 @@ import com.pasich.encly.presentation.editor.state.fieldKey */ 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 = + fun matchesOf(blockId: String, itemId: String?): List = state.value.byField[fieldKey(blockId, itemId)].orEmpty() /** The match "n of m" refers to, if any. */ diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt b/app/src/main/java/com/pasich/encly/editor/ui/TapBelowBlocks.kt similarity index 55% rename from app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt rename to app/src/main/java/com/pasich/encly/editor/ui/TapBelowBlocks.kt index d58e5ccb..1d02278c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/TapBelowBlocks.kt +++ b/app/src/main/java/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,36 +6,34 @@ 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 = - if (!enabled) { - this - } else { - pointerInput(listState) { - detectTapGestures { position -> - val info = listState.layoutInfo - val last = info.visibleItemsInfo.lastOrNull() - val reachedEnd = last != null && last.index == info.totalItemsCount - 1 - if (reachedEnd && position.y > last.offset + last.size) onTap() - } +fun Modifier.tapBelowBlocks(listState: LazyListState, enabled: Boolean, onTap: () -> Unit): Modifier = if (!enabled) { + this +} else { + pointerInput(listState) { + detectTapGestures { position -> + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + val reachedEnd = last != null && last.index == info.totalItemsCount - 1 + if (reachedEnd && position.y > last.offset + last.size) onTap() } } +} /** * Writing at the end of the note: a paragraph there takes focus, and the keyboard comes up even where * the last field kept its focus after the keyboard was hidden (focusing it again shows nothing). */ @Composable -internal fun rememberWriteAtEnd(viewModel: EditNoteViewModel): () -> Unit { +fun rememberWriteAtEnd(controller: EditorController): () -> Unit { val keyboardController = LocalSoftwareKeyboardController.current return { - viewModel.addBlockToEnd() + controller.addBlockToEnd() keyboardController?.show() } } diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/BlockInput.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/BlockInput.kt index 5253bb65..174c5650 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/BlockInput.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/BlockInput.kt @@ -1,14 +1,14 @@ -package com.pasich.encly.presentation.editor.blocks +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 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 +import com.pasich.encly.editor.state.LineBreak +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 /** Which field of a block a [BlockInput] watches: a paragraph, another text block, or a list item. */ internal sealed interface BlockField { diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt index 23b083e0..dbc2f7c4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/FieldHighlight.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -11,9 +11,9 @@ 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 +import com.pasich.encly.editor.focus.CaretGeometry +import com.pasich.encly.editor.focus.geometry +import com.pasich.encly.editor.ui.FindHighlights private const val MATCH_ALPHA = 0.18f private const val CURRENT_MATCH_ALPHA = 0.4f diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/HBlock.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/HBlock.kt index b87364e2..5d2d0d4c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/HBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/HBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -15,15 +15,13 @@ 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 +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 fun HBlock(block: Block.HBlock, blockActions: BlockActions, modifier: Modifier = Modifier, isLocked: Boolean = false) { @@ -47,14 +45,14 @@ fun HBlock(block: Block.HBlock, blockActions: BlockActions, modifier: Modifier = modifier = modifier .onFocusChanged { focused = it.isFocused } .fillMaxWidth() - .padding(top = EnclyTheme.spacing.labelGap) + .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 = stringResource(R.string.block_heading_level, block.blockType.headingLevel()), + text = LocalEditorStrings.current.heading(block.blockType.headingLevel()), style = textStyle.copy( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), ), @@ -74,17 +72,17 @@ 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 theme = LocalEditorTheme.current 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 + 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 = fontStyles.families.heading, + fontFamily = theme.fontFamilies.heading, ) } diff --git a/app/src/main/java/com/pasich/encly/editor/ui/blocks/IdnShim.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/IdnShim.kt new file mode 100644 index 00000000..8cb0227f --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/IdnShim.kt @@ -0,0 +1,15 @@ +package com.pasich.encly.editor.ui.blocks + +import java.net.IDN + +/* + * 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. IDN also normalizes and rejects invalid + * names, which a hand-written punycode encoder would have to repeat. When the editor moves to a + * multiplatform module this file becomes an `expect`/`actual` pair; nothing else changes. + */ + +/** [host] (lower case, with a non-ASCII character) in its ASCII form; null when it is not a valid name. */ +internal fun toAsciiHost(host: String): String? = runCatching { IDN.toASCII(host) }.getOrNull() diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LineMetrics.kt b/app/src/main/java/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 app/src/main/java/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/app/src/main/java/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/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/LinkBlock.kt similarity index 74% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/LinkBlock.kt index afdd6574..d349a9f6 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/LinkBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/LinkBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -26,26 +26,24 @@ 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.onKeyEvent +import androidx.compose.ui.input.key.onPreviewKeyEvent 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 +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 /** * A link: its address entry until one is saved (or while [isEditing] it), then the link as @@ -100,6 +98,10 @@ fun LinkBlock( } } +/** 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, @@ -112,36 +114,31 @@ private fun LinkUrlField( autoFocus: Boolean = false, ) { val focusRequester = remember { FocusRequester() } + val strings = LocalEditorStrings.current Row( verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), + horizontalArrangement = Arrangement.spacedBy(LocalEditorTheme.current.spacing.xs), modifier = modifier.fillMaxWidth(), ) { - EnclyTextField( + EditorTextField( value = inputText, onValueChange = onInputChange, - label = stringResource(R.string.enter_url), - state = if (rejected) FieldState.Error(stringResource(R.string.link_blocked)) else FieldState.Default, + 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) - .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 - } + // Previewed: the field's own key handling would take Backspace first. + .onPreviewKeyEvent { event -> + removesEmptyAddress(event, inputText).also { if (it) onRemoveBlock() } }, ) - EnclyToolButton( - icon = EnclyIcons.Plus, - contentDescription = stringResource(R.string.link_add), + EditorFilledToolButton( + icon = LocalEditorTheme.current.icons.plus, + contentDescription = strings.text(EditorString.LINK_ADD), onClick = if (inputText.isNotBlank()) onCommit else null, - style = ToolStyle.FILLED, ) } if (autoFocus) { @@ -158,29 +155,35 @@ private fun LinkUrlField( 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 -> stringResource(R.string.link_blocked) - urlModel.isError -> stringResource(R.string.falied_content) + 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 = EnclyTheme.spacing.xxs) + .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(EnclyTheme.spacing.s), + horizontalArrangement = Arrangement.spacedBy(spacing.s), modifier = Modifier - .clickable(role = Role.Button, onClickLabel = stringResource(R.string.more_options), onClick = onClick) - .padding(EnclyTheme.spacing.s), + .clickable( + role = Role.Button, + onClickLabel = strings.text(EditorString.MORE_OPTIONS), + onClick = onClick, + ) + .padding(spacing.s), ) { LinkTile() Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.textGap), + verticalArrangement = Arrangement.spacedBy(spacing.textGap), modifier = Modifier.weight(1f), ) { Text( @@ -213,17 +216,19 @@ private fun LinkText(urlModel: LinkDataBlock, onClick: () -> Unit, modifier: Mod /** 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(EnclyTheme.spacing.tileSmall)) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(spacing.tileSmall)) { Icon( - EnclyIcons.Globe, + theme.icons.globe, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(EnclyTheme.spacing.iconSmall), + modifier = Modifier.size(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/editor/ui/blocks/ListBlock.kt similarity index 90% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/ListBlock.kt index a378a875..7aa9fe4d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/ListBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/ListBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,7 +38,6 @@ 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 @@ -47,26 +46,24 @@ 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 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.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.listItemGap +import com.pasich.encly.editor.theme.markerBoxSize +import com.pasich.encly.editor.theme.markerColumn +import com.pasich.encly.editor.ui.BlockActions +import com.pasich.encly.editor.ui.components.EditorCheckbox import kotlin.math.roundToInt /** From this many items numbers take two digits, and a wider hanging indent. */ @@ -168,11 +165,12 @@ fun ListBlock( val focus = rememberListFocus(block, blockActions) val editor = remember(block) { ListEditor(block, { actions }) { focus.pending = it } } val checklist = block.blockType == BlockType.LIST_CHECK + val theme = LocalEditorTheme.current val marker = rememberListMarker(block.blockType, items.size) Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy( - LocalNoteLineSpacing.current.listItemGap(checklist, EnclyTheme.spacing.xxs), + theme.lineSpacing.listItemGap(checklist, theme.spacing.xxs), ), ) { items.forEachIndexed { itemIndex, item -> @@ -258,7 +256,7 @@ private fun ListItemRow( ) { val state = field.state val checklist = marker.type == BlockType.LIST_CHECK - val placeholder = stringResource(R.string.list_item_placeholder) + val placeholder = LocalEditorStrings.current.text(EditorString.LIST_ITEM_PLACEHOLDER) Row( modifier = modifier.fillMaxWidth(), verticalAlignment = if (checklist) Alignment.Top else Alignment.CenterVertically, @@ -269,7 +267,7 @@ private fun ListItemRow( marker = marker, ) - BlockType.LIST_NUMBER -> Marker("${marker.number}.", marker.width, EnclyTheme.typography.dataSmall) + 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 { }) @@ -284,7 +282,7 @@ private fun ListItemRow( .weight(1f) .then( if (checklist) { - Modifier.padding(vertical = EnclyTheme.spacing.textGap) + Modifier.padding(vertical = LocalEditorTheme.current.spacing.textGap) } else { Modifier.alignByBaseline() }, @@ -375,13 +373,13 @@ private class ListEditor( @Composable internal fun listTextStyle(): TextStyle { - val fontStyles = rememberFontStyles() - val spacing = LocalNoteLineSpacing.current + val theme = LocalEditorTheme.current + val spacing = theme.lineSpacing 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), + 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), @@ -391,10 +389,10 @@ internal fun listTextStyle(): TextStyle { /** 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() + val theme = LocalEditorTheme.current Text( text = text, - style = style.copy(fontSize = fontStyles.sizes.list), + style = style.copy(fontSize = theme.fontSizes.list), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = modifier .width(width) @@ -412,13 +410,11 @@ private fun RowScope.Marker(text: String, width: Dp, style: TextStyle, modifier: @Composable private fun RowScope.RowCheckMark(state: CheckState, marker: ListMarker) { val metrics = marker.metrics - val pad = with(LocalDensity.current) { EnclyTheme.spacing.textGap.toPx() } + val pad = with(LocalDensity.current) { LocalEditorTheme.current.spacing.textGap.toPx() } Layout( content = { - EnclyCheckbox( + EditorCheckbox( checked = state.checked, - onCheckedChange = null, - size = CheckboxSize.COMPACT, boxSize = marker.box, modifier = Modifier.testTag(CHECK_BOX_TAG), ) diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/NoteLink.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/NoteLink.kt index e3d83a68..7ae378be 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/NoteLink.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/NoteLink.kt @@ -1,7 +1,6 @@ -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 @@ -77,7 +76,7 @@ private fun parseWebLink(url: String): NoteLink { */ private 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 if (lower.all { it.code < ASCII_END }) lower else toAsciiHost(lower) } private const val ASCII_END = 0x80 @@ -88,7 +87,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 +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/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt similarity index 68% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt index 76be564c..0e4ecd52 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/QuoteBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -24,17 +24,14 @@ 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 +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 @@ -52,7 +49,8 @@ fun QuoteBlock( fieldModifier: Modifier = Modifier, isLocked: Boolean = false, ) { - val fontStyles = rememberFontStyles() + 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). @@ -60,31 +58,26 @@ fun QuoteBlock( val highlight = rememberFieldHighlight(blockActions.highlights, block.id) RegisterCaretAtX(blockActions, state, highlight) var focused by remember { mutableStateOf(false) } - val quoteStyle = EnclyTheme.typography.quote.copy( + val quoteStyle = theme.quoteStyle.copy( color = MaterialTheme.colorScheme.onSurface, - fontSize = fontStyles.sizes.quote, - lineHeight = LocalNoteLineSpacing.current.lineHeight( - fontSize = fontStyles.sizes.quote, - normal = EnclyTheme.typography.quote.lineHeight, + 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 (design spec §4.4). Column( - verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.labelGap), + verticalArrangement = Arrangement.spacedBy(spacing.labelGap), modifier = modifier - .padding(top = EnclyTheme.spacing.xs) + .padding(top = spacing.xs) .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium) - .padding(horizontal = EnclyTheme.spacing.fieldGap, vertical = EnclyTheme.spacing.m), + .padding(horizontal = spacing.fieldGap, vertical = spacing.m), ) { - Icon( - EnclyIcons.Quote, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), - ) + QuoteMark() BasicTextField( state = state, readOnly = isLocked, @@ -104,24 +97,28 @@ fun QuoteBlock( }, ) // 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), - ) + 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 = stringResource(R.string.quote), + text = LocalEditorStrings.current.text(EditorString.QUOTE), style = style.copy( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), ), diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt similarity index 70% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt index 42855185..64834b8d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -9,12 +9,12 @@ 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 +import com.pasich.encly.editor.EditorString +import com.pasich.encly.editor.LocalEditorStrings +import com.pasich.encly.editor.theme.LocalEditorTheme /** * A 1 dp rule in a 24 dp tap target that opens the block's sheet. Taller than the rule is only so a @@ -22,21 +22,23 @@ import com.pasich.encly.ui.theme.EnclyTheme */ @Composable fun SeparatorBlock(onClick: () -> Unit, modifier: Modifier = Modifier, isLocked: Boolean = false) { - val label = stringResource(R.string.block_separator) + val strings = LocalEditorStrings.current + val spacing = LocalEditorTheme.current.spacing + val label = strings.text(EditorString.BLOCK_SEPARATOR) Box( contentAlignment = Alignment.Center, modifier = modifier .fillMaxWidth() - .height(EnclyTheme.spacing.l) + .height(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), + onClickLabel = strings.text(EditorString.MORE_OPTIONS), onClick = onClick, ) .semantics { contentDescription = label }, ) { - HorizontalDivider(thickness = EnclyTheme.spacing.hairline, color = MaterialTheme.colorScheme.outlineVariant) + HorizontalDivider(thickness = 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/editor/ui/blocks/SlashMenuPopup.kt similarity index 69% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt index de2e191e..ef840716 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/SlashMenuPopup.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing @@ -13,10 +13,7 @@ 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 @@ -24,13 +21,16 @@ 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 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 @@ -58,6 +58,8 @@ private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, 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) { @@ -73,7 +75,7 @@ private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, modifier: if (menu == null || !menu.isShown) return val density = LocalDensity.current - val reserve = with(density) { EnclyToolbarReserve.roundToPx() } + val reserve = with(density) { EditorToolbarReserve.roundToPx() } val bottomInset = WindowInsets.safeDrawing.getBottom(density) val position = remember(reserve, bottomInset) { UnderOrOverField(bottomLimit = { it - bottomInset - reserve }) } Popup( @@ -82,10 +84,10 @@ private fun OpenSlashMenu(field: TextFieldState, slash: SlashActions, modifier: // 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)) }, + EditorCommandMenu( + items = menu.matches.map { CommandMenuItem(it.icon(icons), labels.getValue(it)) }, highlighted = menu.highlighted, - title = stringResource(R.string.slash_menu_title), + title = strings.text(EditorString.SLASH_MENU_TITLE), onSelect = { currentSlash.select(menu.matches[it]) }, modifier = modifier, ) @@ -97,32 +99,32 @@ internal val LocalSlashLabels = compositionLocalOf?> { /** The localized label of every command, for the list and for matching the query. */ @Composable -private fun slashLabels(): Map = LocalSlashLabels.current ?: rememberSlashLabels() +private fun slashLabels(): Map = + LocalSlashLabels.current ?: rememberSlashLabels(LocalEditorStrings.current) -/** The localized label of every command, built once per locale: provide it as [LocalSlashLabels] above the editor. */ +/** + * The label of every command in [strings]' language, built once per [strings]: [EditorProvider] + * provides 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) - } +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(): 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 +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 } /** diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/StrikeSegment.kt b/app/src/main/java/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 app/src/main/java/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/app/src/main/java/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/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlock.kt similarity index 77% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlock.kt index e050c363..e2bd5206 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlock.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlock.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -12,15 +12,14 @@ 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 +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 @@ -41,14 +40,14 @@ fun TextBlock( isLocked: Boolean = false, showPlaceholder: Boolean = true, ) { - val fontStyles = rememberFontStyles() - val spacing = LocalNoteLineSpacing.current + 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 = fontStyles.sizes.textBlock, - fontFamily = fontStyles.families.body, - lineHeight = spacing.lineHeight(fontStyles.sizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), + fontSize = theme.fontSizes.textBlock, + fontFamily = theme.fontFamilies.body, + lineHeight = spacing.lineHeight(theme.fontSizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), ) val actions by rememberUpdatedState(blockActions) val input = remember { BlockInput(BlockField.Paragraph) { actions } } @@ -73,7 +72,7 @@ fun TextBlock( SlashMenuPopup(field = state, slash = blockActions.slash) if (showPlaceholder && state.text.isEmpty()) { Text( - text = stringResource(block.placeholder), + text = LocalEditorStrings.current.text(block.placeholder), style = style.copy( color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = EDITOR_PLACEHOLDER_ALPHA), ), diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt index 9f2e2fd2..83b6d724 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextBlockKeys.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/blocks/TextBlockKeys.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.ui.Modifier @@ -8,12 +8,12 @@ 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 +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 diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextSync.kt b/app/src/main/java/com/pasich/encly/editor/ui/blocks/TextSync.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextSync.kt rename to app/src/main/java/com/pasich/encly/editor/ui/blocks/TextSync.kt index 2345ac76..faf2fab6 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/blocks/TextSync.kt +++ b/app/src/main/java/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,8 @@ 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.ui.BlockActions import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt b/app/src/main/java/com/pasich/encly/editor/ui/components/CommandMenuItem.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt rename to app/src/main/java/com/pasich/encly/editor/ui/components/CommandMenuItem.kt index ece4c5d4..d5ffbeac 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/CommandMenuItem.kt +++ b/app/src/main/java/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,9 +26,9 @@ 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]. */ +/** One row of an [EditorCommandMenu]. */ data class CommandMenuItem(val icon: ImageVector, val label: String) private val MenuMaxHeight = 240.dp @@ -42,13 +42,14 @@ private val MenuElevation = 6.dp * are more than fit. [title] describes the list to TalkBack. */ @Composable -fun EnclyCommandMenu( +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,20 @@ 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) .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/app/src/main/java/com/pasich/encly/editor/ui/components/EditorControls.kt b/app/src/main/java/com/pasich/encly/editor/ui/components/EditorControls.kt new file mode 100644 index 00000000..009b410c --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/ui/components/EditorControls.kt @@ -0,0 +1,127 @@ +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 +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 +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 +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 + .size(ToolSize) + .semantics { role = Role.Button }, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(LocalEditorTheme.current.spacing.iconMedium), + ) + } + } + } +} diff --git a/app/src/main/java/com/pasich/encly/editor/ui/components/EditorSheet.kt b/app/src/main/java/com/pasich/encly/editor/ui/components/EditorSheet.kt new file mode 100644 index 00000000..3134da99 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/ui/components/EditorSheet.kt @@ -0,0 +1,186 @@ +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 +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 +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 +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) + .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/app/src/main/java/com/pasich/encly/editor/ui/components/EditorTextField.kt b/app/src/main/java/com/pasich/encly/editor/ui/components/EditorTextField.kt new file mode 100644 index 00000000..ff6e43fc --- /dev/null +++ b/app/src/main/java/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 +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/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt similarity index 85% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt rename to app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt index e11d933f..bf57c3c3 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionBlockDialog.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.dialogs.blocks +package com.pasich.encly.editor.ui.sheets sealed class ActionBlockDialog { data class Move(val move: Int) : ActionBlockDialog() // 1 - top, 2 - bottom diff --git a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt similarity index 63% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt rename to app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt index fa3a76f6..6bd4114a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/ActionLinkBottomSheet.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.dialogs.blocks +package com.pasich.encly.editor.ui.sheets import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ExperimentalMaterial3Api @@ -9,15 +9,15 @@ 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 +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 @@ -29,7 +29,9 @@ fun ActionLinkBottomSheet( modifier: Modifier = Modifier, ) { if (settings.isBottomSheetVisible) { - EnclyBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + 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) { @@ -49,24 +51,24 @@ fun ActionLinkBottomSheet( } // 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, + EditorSheetRow( + title = strings.text(EditorString.LINK_OPEN), + icon = icons.external, onClick = { onAction(ActionBlockDialog.OpenLink) }, ) } - EnclySheetRow( - title = stringResource(R.string.link_copy), - icon = EnclyIcons.Copy, + EditorSheetRow( + title = strings.text(EditorString.LINK_COPY), + icon = icons.copy, onClick = { onAction(ActionBlockDialog.CopyLink) }, ) if (settings.canEdit) { - EnclySheetRow( - title = stringResource(R.string.link_edit), - icon = EnclyIcons.Edit, + EditorSheetRow( + title = strings.text(EditorString.LINK_EDIT), + icon = icons.edit, onClick = { onAction(ActionBlockDialog.EditLink) }, ) - EnclyGroupDivider() + EditorGroupDivider() BlockEditRows(settings, onAction) } } diff --git a/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt new file mode 100644 index 00000000..b5c5579d --- /dev/null +++ b/app/src/main/java/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt @@ -0,0 +1,39 @@ +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 +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. */ +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 +} diff --git a/app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt new file mode 100644 index 00000000..8fb3e366 --- /dev/null +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt new file mode 100644 index 00000000..ac9a0104 --- /dev/null +++ b/app/src/main/java/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). */ +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 +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/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt b/app/src/main/java/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt similarity index 74% rename from app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt rename to app/src/main/java/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt index 1b3ffa24..f8c397b2 100644 --- a/app/src/main/java/com/pasich/encly/presentation/dialogs/blocks/SettingsBlockDialog.kt +++ b/app/src/main/java/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.dialogs.blocks +package com.pasich.encly.editor.ui.sheets -import com.pasich.encly.dynamicBlocks.Block +import com.pasich.encly.editor.model.Block data class SettingsBlockDialog( val block: Block, diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/BlockTool.kt similarity index 94% rename from app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt rename to app/src/main/java/com/pasich/encly/presentation/components/editNote/BlockTool.kt index 18039102..e733001a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/BlockTool.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/editNote/BlockTool.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor +package com.pasich.encly.presentation.components.editNote import androidx.annotation.StringRes import androidx.compose.runtime.Composable @@ -6,11 +6,11 @@ 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.editor.model.BlockType +import com.pasich.encly.editor.state.HEADING_TOOL 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( diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt index 6c1847ff..9ec40c09 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/editNote/NoteBottomBar.kt @@ -41,16 +41,13 @@ 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.editor.model.BlockType +import com.pasich.encly.editor.state.toolType 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.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt index 9d54c3e8..34233db0 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt +++ b/app/src/main/java/com/pasich/encly/presentation/components/editNote/TitleField.kt @@ -22,9 +22,9 @@ 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.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 diff --git a/app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt b/app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt index cbc698f5..779e980d 100644 --- a/app/src/main/java/com/pasich/encly/presentation/components/editNote/ToolbarState.kt +++ b/app/src/main/java/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 diff --git a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt index 191b57d6..b4b0024c 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/Editor.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pasich.encly.ui.theme.EnclyTheme @@ -106,17 +105,6 @@ fun EnclyToolButton( } } -/** 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 +114,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/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt b/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt index bf019ff3..939a67da 100644 --- a/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt +++ b/app/src/main/java/com/pasich/encly/presentation/designsystem/TextFields.kt @@ -39,12 +39,6 @@ import com.pasich.encly.ui.theme.EnclyTheme /** 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 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/editor/persistence/NotePersistence.kt b/app/src/main/java/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt index fbfbeed8..8b507f55 100644 --- 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 @@ -2,13 +2,13 @@ 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 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 diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt index 9fe235da..697afce4 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt @@ -9,9 +9,7 @@ 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.editor.model.BlockType import com.pasich.encly.ui.theme.fontSet import com.pasich.encly.utils.FontSizeUtils @@ -41,9 +39,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 +65,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) + } } /** diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt index abdff209..34e6418f 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt @@ -45,6 +45,15 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import com.pasich.encly.R 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.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.presentation.components.editNote.NoteBottomBar import com.pasich.encly.presentation.components.editNote.NoteOverline import com.pasich.encly.presentation.components.editNote.TitleField @@ -52,15 +61,9 @@ 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 @@ -121,8 +124,9 @@ fun EditNoteScreen( 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 -> + val controller = rememberEditorController() + val host = remember(controller, focusRegistry, highlights) { + EditorBlocksHost(controller, focusRegistry, highlights) { type, block -> overlays.blockSheet.open(type, block.id) } } @@ -135,19 +139,19 @@ fun EditNoteScreen( 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, ) { + // Inside the provider: the block sheets are the editor's, and read its strings and theme. + EditorOverlays( + overlays = overlays, + exits = exits, + onManageTags = { navController.navigate(NavRoutes.EditTagRoute.name) }, + onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, + ) EditorScaffold( actions = EditorBarActions( onBack = exits::back, @@ -326,7 +330,8 @@ private fun EditorContent( modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel(), ) { - val spacing = LocalNoteLineSpacing.current + 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() @@ -345,7 +350,7 @@ private fun EditorContent( 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)), + .tapBelowBlocks(listState, enabled = !lockEditor, onTap = rememberWriteAtEnd(controller)), ) { if (loading) { item(key = "skeleton") { diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt new file mode 100644 index 00000000..e283cc95 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt @@ -0,0 +1,95 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.LineBreak +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.ui.EditorController +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * 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) : EditorController { + override val blocks: List get() = viewModel.blocks + + override val slashPicker: SlashPicker get() = viewModel.slashPicker + + 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 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) +} + +/** The editor's view of the screen's note view model. */ +@Composable +internal fun rememberEditorController(): EditorController { + val viewModel: EditNoteViewModel = metroViewModel() + return remember(viewModel) { EditNoteViewModelController(viewModel) } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt new file mode 100644 index 00000000..91c54e02 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorBindings.kt @@ -0,0 +1,207 @@ +package com.pasich.encly.presentation.screen.editnote + +import android.content.Context +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import com.pasich.encly.R +import com.pasich.encly.core.AppLogger +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.presentation.designsystem.EnclyIcons +import com.pasich.encly.ui.theme.EnclyTheme + +// 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 context = LocalContext.current + EditorProvider( + strings = rememberEditorStrings(), + theme = rememberEditorTheme(fontStyles, lineSpacing), + clipboard = remember(context) { AppEditorClipboard(context) }, + linkOpener = remember(context) { LinkOpener { url -> openNoteLink(context, url) } }, + logger = AppEditorLogger, + content = content, + ) +} + +/** The editor's texts from the app's string resources, in the language they resolve to now. */ +internal class ResourceEditorStrings(private val resources: Resources) : EditorStrings { + override fun text(key: EditorString): String = resources.getString(key.resourceId()) + + override fun heading(level: Int): String = resources.getString(R.string.block_heading_level, level) +} + +/** The strings in the current language: a new instance whenever the resources or configuration change. */ +@Composable +internal fun rememberEditorStrings(): EditorStrings { + val resources = LocalResources.current + val configuration = LocalConfiguration.current + return remember(resources, configuration) { ResourceEditorStrings(resources) } +} + +/** The string resource of [this] key. */ +@Suppress("CyclomaticComplexMethod") // One branch per string the editor shows. +internal fun EditorString.resourceId(): Int = when (this) { + EditorString.TEXT -> R.string.text + EditorString.PRESS_TO_EDIT -> R.string.press_to_edit + EditorString.BLOCK_HEADING -> R.string.block_heading + EditorString.BLOCK_HEADING_LEVEL -> R.string.block_heading_level + EditorString.BLOCK_CHECKLIST -> R.string.block_checklist + EditorString.BLOCK_BULLETED_LIST -> R.string.block_bulleted_list + EditorString.BLOCK_NUMBERED_LIST -> R.string.block_numbered_list + EditorString.BLOCK_LIST -> R.string.block_list + EditorString.QUOTE -> R.string.quote + EditorString.BLOCK_LINK -> R.string.block_link + EditorString.BLOCK_SEPARATOR -> R.string.block_separator + EditorString.MORE_OPTIONS -> R.string.more_options + EditorString.LIST_ITEM_PLACEHOLDER -> R.string.list_item_placeholder + EditorString.SLASH_MENU_TITLE -> R.string.slash_menu_title + EditorString.ENTER_URL -> R.string.enter_url + EditorString.LINK_BLOCKED -> R.string.link_blocked + EditorString.LINK_CONTENT_FAILED -> R.string.falied_content + EditorString.LINK_ADD -> R.string.link_add + EditorString.LINK_OPEN -> R.string.link_open + EditorString.LINK_COPY -> R.string.link_copy + EditorString.LINK_EDIT -> R.string.link_edit + EditorString.BLOCK_MOVE_UP -> R.string.block_move_up + EditorString.BLOCK_MOVE_DOWN -> R.string.block_move_down + EditorString.BLOCK_DUPLICATE -> R.string.block_duplicate + EditorString.BLOCK_DELETE -> R.string.delete_block + EditorString.TAP_AGAIN_TO_DELETE -> R.string.tap_again_to_delete + EditorString.CHECKLIST_LOCK -> R.string.checklist_lock + EditorString.CHECKLIST_UNLOCK -> R.string.checklist_unlock + EditorString.TEMPLATE_BLANK -> R.string.template_blank + EditorString.TEMPLATE_MEETING -> R.string.template_meeting + EditorString.TEMPLATE_ATTENDEES -> R.string.template_attendees + EditorString.TEMPLATE_AGENDA -> R.string.template_agenda + EditorString.TEMPLATE_NOTES -> R.string.template_notes + EditorString.TEMPLATE_ACTION_ITEMS -> R.string.template_action_items + EditorString.TEMPLATE_JOURNAL -> R.string.template_journal + EditorString.TEMPLATE_JOURNAL_TODAY -> R.string.template_journal_today + EditorString.TEMPLATE_JOURNAL_GRATITUDE -> R.string.template_journal_gratitude + EditorString.TEMPLATE_JOURNAL_TOMORROW -> R.string.template_journal_tomorrow + EditorString.TEMPLATE_PROJECT -> R.string.template_project + EditorString.TEMPLATE_PROJECT_GOAL -> R.string.template_project_goal + EditorString.TEMPLATE_PROJECT_TASKS -> R.string.template_project_tasks + EditorString.TEMPLATE_PROJECT_DECISIONS -> R.string.template_project_decisions + EditorString.TEMPLATE_PROJECT_QUESTIONS -> R.string.template_project_questions + EditorString.TEMPLATE_CHECKLIST_TODO -> R.string.template_checklist_todo +} + +/** + * 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, + ) +} + +/** + * The editor copies through the app's clipboard policy ([com.pasich.encly.presentation.components.SensitiveClip]): + * every clip is marked sensitive and cleared after a minute, so [sensitive] changes nothing here. + */ +internal class AppEditorClipboard(private val context: Context) : EditorClipboard { + override fun copy(label: String, text: String, sensitive: Boolean) = copyNoteLink(context, 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/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt index f26f9d63..9963b954 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorEffects.kt @@ -28,8 +28,8 @@ 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.editor.focus.BlockFocusRegistry +import com.pasich.encly.editor.ui.sheets.BlockSheetKind import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.flow.collectLatest @@ -123,26 +123,26 @@ internal fun FocusNewNoteTitle(titleFocus: FocusRequester, viewModel: EditNoteVi } /** Which block sheet is open, and for which block (by id). */ -internal class BlockSheetState(type: MutableState, blockId: MutableState) { - var type by type +internal class BlockSheetState(kind: MutableState, blockId: MutableState) { + var kind by kind var blockId by blockId - fun open(sheet: BottomSheetsOpenType, id: String) { - type = sheet + fun open(sheet: BlockSheetKind, id: String) { + kind = sheet blockId = id } fun close() { - type = BottomSheetsOpenType.NONE + kind = null blockId = null } } @Composable internal fun rememberBlockSheetState(): BlockSheetState { - val type = rememberSaveable { mutableStateOf(BottomSheetsOpenType.NONE) } + val kind = rememberSaveable { mutableStateOf(null) } val blockId = rememberSaveable { mutableStateOf(null) } - return remember { BlockSheetState(type, blockId) } + return remember { BlockSheetState(kind, blockId) } } /** Whether the find bar is open. */ diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt index d3d8aa70..e93e0ddc 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorFindBar.kt @@ -26,10 +26,10 @@ 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.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.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt index b85b2f38..8cbc3bbf 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/editnote/EditorSheets.kt @@ -13,7 +13,6 @@ 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 @@ -21,93 +20,42 @@ 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.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.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.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel -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. + * 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. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun EditorBlockSheets( - sheet: BlockSheetState, - canEdit: Boolean, - viewModel: EditNoteViewModel = metroViewModel(), -) { - 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(), +internal fun EditorBlockSheets(sheet: BlockSheetState, canEdit: Boolean) { + val controller = rememberEditorController() + EditorBlockSheet( + kind = sheet.kind, + blockId = sheet.blockId, canEdit = canEdit, + controller = controller, + onClose = sheet::close, ) - 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]). */ diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/editnote/HistoryShortcut.kt b/app/src/main/java/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/app/src/main/java/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/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 index 88256917..58009d7c 100644 --- 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 @@ -9,8 +9,8 @@ import android.widget.Toast import androidx.core.net.toUri import com.pasich.encly.R import com.pasich.encly.core.AppLogger +import com.pasich.encly.editor.ui.blocks.isOpenableLink 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 @@ -38,12 +38,12 @@ fun openNoteLink(context: Context, url: String) { 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. + * Copies [text] (a note's link) under [label] with 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) { +fun copyNoteLink(context: Context, label: String, text: String) { val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return - SensitiveClip.copy(clipboard, context.getString(R.string.block_link), url) + SensitiveClip.copy(clipboard, label, text) 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 index 3ee7e9b2..0c8087d2 100644 --- 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 @@ -8,13 +8,13 @@ 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.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.editor.state.NoteTemplate -import com.pasich.encly.presentation.editor.state.blocks import com.pasich.encly.presentation.viewmodel.EditNoteViewModel import com.pasich.encly.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel @@ -26,7 +26,7 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel */ @Composable internal fun TemplateChooser(modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel()) { - val resources = LocalResources.current + val strings = LocalEditorStrings.current Column( verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xs), modifier = modifier.padding(horizontal = EnclyTheme.spacing.gutter, vertical = EnclyTheme.spacing.s), @@ -38,9 +38,9 @@ internal fun TemplateChooser(modifier: Modifier = Modifier, viewModel: EditNoteV ) { NoteTemplate.entries.forEach { template -> EnclyChip( - label = stringResource(template.label), + label = strings.text(template.label), selected = template == NoteTemplate.BLANK, - onClick = { viewModel.applyTemplate(template.blocks { id -> resources.getString(id) }) }, + onClick = { viewModel.applyTemplate(template.blocks(strings::text)) }, ) } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index f9966f28..fcf7c9ac 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -10,8 +10,6 @@ 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 @@ -19,47 +17,49 @@ 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.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.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.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +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.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.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.pasteLink +import com.pasich.encly.editor.state.removeInteracted +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 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 dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory diff --git a/app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt b/app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt index 3f0a20bd..f36b334e 100644 --- a/app/src/main/java/com/pasich/encly/utils/FontSizeUtils.kt +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt b/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt index da4efebe..074cbe0a 100644 --- a/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt +++ b/app/src/main/java/com/pasich/encly/utils/NotesTextFormatter.kt @@ -1,9 +1,9 @@ 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 +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 /** * Utility class for formatting note text. diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt index 8125e112..d37d3e0e 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -7,15 +7,15 @@ import com.pasich.encly.core.backup.BackupPayloadCodec import com.pasich.encly.core.backup.BackupSecret import com.pasich.encly.core.backup.assertError import com.pasich.encly.core.security.Mnemonic -import com.pasich.encly.core.serialization.BlockConverter 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.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 diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt b/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt index d6af3fdf..29b92cac 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt +++ b/app/src/test/java/com/pasich/encly/data/export/MarkdownBlocksTest.kt @@ -1,9 +1,9 @@ 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 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 index 341f35a2..4c2a52c1 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt +++ b/app/src/test/java/com/pasich/encly/data/export/MarkdownExportTest.kt @@ -1,15 +1,15 @@ 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.data.zip.zipWriter -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.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 diff --git a/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt b/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt index e59265e7..7b1c3ac9 100644 --- a/app/src/test/java/com/pasich/encly/data/export/MarkdownNotesTest.kt +++ b/app/src/test/java/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 diff --git a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt index 97d2b635..94ef19e4 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesBlockMapperTest.kt +++ b/app/src/test/java/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/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt index 99369bdc..c84428fb 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt +++ b/app/src/test/java/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 diff --git a/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt b/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt index 79de56a7..81fc10a0 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/ExpectedNote.kt @@ -1,8 +1,8 @@ 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( diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt index 24a95c0a..07ef74ce 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportFixtures.kt @@ -1,7 +1,7 @@ package com.pasich.encly.data.importer -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.io.Buffer import kotlinx.io.asSource import java.io.ByteArrayOutputStream diff --git a/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt index fa1834df..c728028a 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportMapperTest.kt +++ b/app/src/test/java/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 diff --git a/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt b/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt index bb8b7a4e..dfbdb3f0 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt +++ b/app/src/test/java/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 diff --git a/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt b/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt index 44327bb2..25193925 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/RealExportsTest.kt +++ b/app/src/test/java/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 diff --git a/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt b/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt index b68bcd4e..e64bb8c6 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/TextParsersTest.kt @@ -240,7 +240,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 { diff --git a/app/src/test/java/com/pasich/encly/editor/EditorBoundaryTest.kt b/app/src/test/java/com/pasich/encly/editor/EditorBoundaryTest.kt new file mode 100644 index 00000000..708cc1a2 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/EditorBoundaryTest.kt @@ -0,0 +1,119 @@ +package com.pasich.encly.editor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The editor package is written to move into a Kotlin Multiplatform library of its own, so its + * 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. A source that still needs the JVM or Android + * is listed in [ALLOWED_PLATFORM_FILES] with the reason, and nothing else may be. + */ +class EditorBoundaryTest { + private val sourceRoot = listOf( + File("src/main/java/com/pasich/encly/editor"), + File("app/src/main/java/com/pasich/encly/editor"), + ).first { it.isDirectory } + + 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("no sources under $sourceRoot", sources.size > 50) + } + + @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 + val allowed = ALLOWED_PREFIXES.any { import.startsWith(it) } || + import in ALLOWED_PLATFORM_FILES[file.relative()].orEmpty() + "${file.relative()}:$number import $import".takeUnless { allowed } + } + } + assertEquals("the editor may not import these", emptyList(), offenders) + } + + @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("the editor may not use the JVM or Android directly", emptyList(), offenders) + } + + @Test + fun theAllowListHoldsOnlyWhatIsStillNeeded() { + ALLOWED_PLATFORM_FILES.forEach { (path, imports) -> + val file = File(sourceRoot, path) + assertTrue("$path is listed but gone", file.isFile) + val text = file.readText() + imports.forEach { assertTrue("$path no longer imports $it", "import $it" in text) } + } + } + + @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) }) + } + + 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.", + "androidx.lifecycle.", + "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""", + """(?? = 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" + } + 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(events.single().second, "secret" !in 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, + ) + assertEquals(all.size, all.map { it.name }.toSet().size) + } +} diff --git a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt b/app/src/test/java/com/pasich/encly/editor/codec/BlockConverterTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt rename to app/src/test/java/com/pasich/encly/editor/codec/BlockConverterTest.kt index 9650ab2b..e5cec19d 100644 --- a/app/src/test/java/com/pasich/encly/core/serialization/BlockConverterTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/codec/BlockConverterTest.kt @@ -1,9 +1,9 @@ -package com.pasich.encly.core.serialization +package com.pasich.encly.editor.codec -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.serialization.json.Json import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt b/app/src/test/java/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt rename to app/src/test/java/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt index 74f79310..d154751f 100644 --- a/app/src/test/java/com/pasich/encly/core/serialization/BlockJsonFormatTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt @@ -1,9 +1,9 @@ -package com.pasich.encly.core.serialization +package com.pasich.encly.editor.codec -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.assertNull diff --git a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt b/app/src/test/java/com/pasich/encly/editor/codec/InlineHtmlTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt rename to app/src/test/java/com/pasich/encly/editor/codec/InlineHtmlTest.kt index 0db44014..38c51665 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/InlineHtmlTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/codec/InlineHtmlTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.data.handoff +package com.pasich.encly.editor.codec import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt b/app/src/test/java/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt similarity index 99% rename from app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt rename to app/src/test/java/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt index 8c658856..3b070a14 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/MarkdownBlockMapperTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.data.importer +package com.pasich.encly.editor.codec import com.pasich.encly.data.importer.ImportFixtures.describe import org.junit.Assert.assertEquals diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt b/app/src/test/java/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt rename to app/src/test/java/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt index 29d5c89a..08525651 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/BlockFocusRegistryTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt @@ -1,8 +1,8 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt b/app/src/test/java/com/pasich/encly/editor/focus/CaretGeometryTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt rename to app/src/test/java/com/pasich/encly/editor/focus/CaretGeometryTest.kt index b7ea2b71..a839532e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/CaretGeometryTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/focus/CaretGeometryTest.kt @@ -1,9 +1,9 @@ -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 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 org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt b/app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt new file mode 100644 index 00000000..39726cbc --- /dev/null +++ b/app/src/test/java/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.Assert.assertEquals +import org.junit.Assert.assertFalse +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 +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/app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt b/app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt new file mode 100644 index 00000000..668577a6 --- /dev/null +++ b/app/src/test/java/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.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.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +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/app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt b/app/src/test/java/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt similarity index 99% rename from app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt rename to app/src/test/java/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt index 74c37814..ad899930 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/focus/KeyboardUtilsTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/focus/KeyboardUtilsTest.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.KeyEventType diff --git a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt b/app/src/test/java/com/pasich/encly/editor/model/BlockFactoryTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt rename to app/src/test/java/com/pasich/encly/editor/model/BlockFactoryTest.kt index 3e275592..981523a3 100644 --- a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockFactoryTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/model/BlockFactoryTest.kt @@ -1,9 +1,5 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt b/app/src/test/java/com/pasich/encly/editor/model/BlockIdTest.kt similarity index 93% rename from app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt rename to app/src/test/java/com/pasich/encly/editor/model/BlockIdTest.kt index 24725bcf..282ec8ce 100644 --- a/app/src/test/java/com/pasich/encly/dynamicBlocks/BlockIdTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/model/BlockIdTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.dynamicBlocks +package com.pasich.encly.editor.model import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BlockActionsTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/BlockActionsTest.kt index 6046451d..bf2bacbf 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockActionsTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/BlockActionsTest.kt @@ -1,9 +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.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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BlockEditingTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/BlockEditingTest.kt index 757fb2f1..8302a48e 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditingTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/BlockEditingTest.kt @@ -1,9 +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.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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BlockEditorStateTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/BlockEditorStateTest.kt index 9b08137c..207585c7 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockEditorStateTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/BlockEditorStateTest.kt @@ -1,9 +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.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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BlockOperationsTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/BlockOperationsTest.kt index ff3e99c7..dd8d8660 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/BlockOperationsTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/BlockOperationsTest.kt @@ -1,7 +1,7 @@ -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 org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/pasich/encly/editor/state/BranchGapsMoreTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BranchGapsMoreTest.kt new file mode 100644 index 00000000..aa8617fe --- /dev/null +++ b/app/src/test/java/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 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 + +/** 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/app/src/test/java/com/pasich/encly/editor/state/BranchGapsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/BranchGapsTest.kt new file mode 100644 index 00000000..e6a21734 --- /dev/null +++ b/app/src/test/java/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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 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() { + assertThrows(IllegalArgumentException::class.java) { 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 == BlockType.TEXT || tool == BlockType.SEPARATOR || tool == BlockType.LINK) { + assertNull(tool.name, converted) + } else { + assertNotNull(tool.name, converted) + } + 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/app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt b/app/src/test/java/com/pasich/encly/editor/state/ChecklistLockTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/ChecklistLockTest.kt index 025f734d..097b82ea 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/ChecklistLockTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/ChecklistLockTest.kt @@ -1,9 +1,9 @@ -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.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 kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/pasich/encly/editor/state/ContentChangesTest.kt b/app/src/test/java/com/pasich/encly/editor/state/ContentChangesTest.kt new file mode 100644 index 00000000..f9dd6cf4 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/state/ContentChangesTest.kt @@ -0,0 +1,52 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 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(), + ), + ) + 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/app/src/test/java/com/pasich/encly/editor/state/ConvertCaretTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/ConvertCaretTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/ConvertCaretTest.kt index d1269031..890b9774 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/ConvertCaretTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/ConvertCaretTest.kt @@ -1,8 +1,8 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt b/app/src/test/java/com/pasich/encly/editor/state/MarkdownLinesTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/MarkdownLinesTest.kt index 262e3c2f..79c74c1f 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/MarkdownLinesTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/MarkdownLinesTest.kt @@ -1,7 +1,7 @@ -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 org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt b/app/src/test/java/com/pasich/encly/editor/state/NoteFindTest.kt similarity index 94% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/NoteFindTest.kt index 62ee1508..8927ccb2 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteFindTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/NoteFindTest.kt @@ -1,10 +1,10 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt b/app/src/test/java/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt index e862aaca..07e9b333 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteJsonGoldenTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.core.serialization.BlockConverter +import com.pasich.encly.editor.codec.BlockConverter import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt b/app/src/test/java/com/pasich/encly/editor/state/NoteTemplatesTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/NoteTemplatesTest.kt index 7ff47bba..09e96b0c 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/NoteTemplatesTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/NoteTemplatesTest.kt @@ -1,8 +1,9 @@ -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 @@ -16,8 +17,8 @@ import org.junit.Test 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() { diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/SlashCommandsTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/SlashCommandsTest.kt index 89a49b2b..9cd8cf6c 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/SlashCommandsTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/SlashCommandsTest.kt @@ -1,7 +1,7 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt b/app/src/test/java/com/pasich/encly/editor/state/SmartPasteTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/SmartPasteTest.kt index 1c15d6f9..0f692199 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/SmartPasteTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/SmartPasteTest.kt @@ -1,7 +1,7 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/editor/state/StateGapsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/StateGapsTest.kt new file mode 100644 index 00000000..b16523ce --- /dev/null +++ b/app/src/test/java/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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 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/app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt b/app/src/test/java/com/pasich/encly/editor/state/TextEditsTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/TextEditsTest.kt index 53346166..7c71e5fb 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/TextEditsTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/TextEditsTest.kt @@ -1,6 +1,6 @@ -package com.pasich.encly.presentation.editor.state +package com.pasich.encly.editor.state -import com.pasich.encly.domain.model.ItemListBlock +import com.pasich.encly.editor.model.ItemListBlock import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt b/app/src/test/java/com/pasich/encly/editor/state/UndoCaretTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt rename to app/src/test/java/com/pasich/encly/editor/state/UndoCaretTest.kt index 3fb491a5..b5bd6977 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/state/UndoCaretTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/state/UndoCaretTest.kt @@ -1,8 +1,8 @@ -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 diff --git a/app/src/test/java/com/pasich/encly/editor/theme/NoteSpacingTest.kt b/app/src/test/java/com/pasich/encly/editor/theme/NoteSpacingTest.kt new file mode 100644 index 00000000..93138e2d --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/theme/NoteSpacingTest.kt @@ -0,0 +1,36 @@ +package com.pasich.encly.editor.theme + +import androidx.compose.ui.unit.sp +import org.junit.Assert.assertEquals +import org.junit.Test + +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) + } + } +} diff --git a/app/src/test/java/com/pasich/encly/editor/ui/BlockActionsImplTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/BlockActionsImplTest.kt new file mode 100644 index 00000000..fbe48d78 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/ui/BlockActionsImplTest.kt @@ -0,0 +1,134 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 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"))) + } +} diff --git a/app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt new file mode 100644 index 00000000..19c0f3bb --- /dev/null +++ b/app/src/test/java/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.Assert.assertEquals +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 + +/** + * 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(state.blocks.map { it::class.simpleName }.toString(), check(state.blocks.first())) + } + + @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/app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt new file mode 100644 index 00000000..02144128 --- /dev/null +++ b/app/src/test/java/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.Assert.assertEquals +import org.junit.Assert.assertTrue +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 + +/** 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/app/src/test/java/com/pasich/encly/editor/ui/StateController.kt b/app/src/test/java/com/pasich/encly/editor/ui/StateController.kt new file mode 100644 index 00000000..4a1a3b06 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/ui/StateController.kt @@ -0,0 +1,172 @@ +package com.pasich.encly.editor.ui + +import androidx.compose.foundation.lazy.LazyColumn +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 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.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.Shortcut +import com.pasich.encly.editor.state.SlashCommand +import com.pasich.encly.editor.state.SlashPicker +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.pasteLink +import com.pasich.encly.editor.state.toggleChecklistLock +import com.pasich.encly.editor.ui.sheets.BlockSheetKind + +/** + * 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 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 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 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 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, +) { + 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) } + } + LazyColumn { + editorBlocks(host, state.blocks, isLocked, controller.editingLinks, afterBlocks) + } + } + } +} diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt rename to app/src/test/java/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt index de128a9e..04396266 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TapBelowBlocksTest.kt +++ b/app/src/test/java/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,7 +16,6 @@ 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 diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt similarity index 89% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt rename to app/src/test/java/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt index 9e4c4806..d439cc88 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/ChecklistLayoutTest.kt +++ b/app/src/test/java/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,16 +29,16 @@ 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 @@ -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 @@ -121,7 +124,7 @@ class ChecklistLayoutTest { val (box, field) = boxAndText() assertEquals("$spacing $size", field.top + metrics.centre, box.center.y, 1f) // The style's middle is the real layout's wherever the line box is as tall as the font. - if (spacing != NoteLineSpacing.COMPACT) { + if (spacing != EditorLineSpacing.COMPACT) { assertEquals("$spacing $size layout", field.top + layoutCentre(), box.center.y, 1f) } } @@ -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 @@ -199,7 +202,7 @@ 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 } @@ -212,7 +215,7 @@ class ChecklistLayoutTest { 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/app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt new file mode 100644 index 00000000..da2521a1 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt @@ -0,0 +1,167 @@ +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.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.Assert.assertEquals +import org.junit.Assert.assertNull +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 +import org.robolectric.annotation.GraphicsMode + +/** 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(androidx.compose.runtime.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/app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt rename to app/src/test/java/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt index 5026415a..a29159ad 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/LinkUrlTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt @@ -1,4 +1,4 @@ -package com.pasich.encly.presentation.editor.blocks +package com.pasich.encly.editor.ui.blocks import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt similarity index 81% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt rename to app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt index 104c4928..2c027d83 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/SeparatorBlockTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt @@ -1,13 +1,12 @@ -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 @@ -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/app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt similarity index 96% rename from app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt rename to app/src/test/java/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt index 513db7b7..25f0e3e5 100644 --- a/app/src/test/java/com/pasich/encly/presentation/editor/blocks/TextSyncTest.kt +++ b/app/src/test/java/com/pasich/encly/editor/ui/blocks/TextSyncTest.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.ui.text.TextRange diff --git a/app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt b/app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt new file mode 100644 index 00000000..235b3c45 --- /dev/null +++ b/app/src/test/java/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.Assert.assertEquals +import org.junit.Assert.assertTrue +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 + +/** 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/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 index acf692eb..b4a16b5b 100644 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt @@ -5,14 +5,17 @@ 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.editor.EditorProvider +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.ui.sheets.ActionBlockDialog +import com.pasich.encly.editor.ui.sheets.ActionLinkBottomSheet +import com.pasich.encly.editor.ui.sheets.ActionOtherBottomSheet +import com.pasich.encly.editor.ui.sheets.SettingsBlockDialog +import com.pasich.encly.editor.ui.sheets.describe +import com.pasich.encly.presentation.screen.editnote.rememberEditorStrings +import com.pasich.encly.presentation.screen.editnote.resourceId import com.pasich.encly.ui.screens.ComposeScreenTest import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals @@ -28,22 +31,27 @@ class BlockSheetsTest : ComposeScreenTest() { private fun linkBlock(url: String, isError: Boolean = false) = Block.LinkBlock(MutableStateFlow(LinkDataBlock(title = "Example", url = url, isError = isError))) + // The sheets are the editor's; the app's strings reach them through its provider. private fun showOther(settings: SettingsBlockDialog) = setScreen { - ActionOtherBottomSheet( - settings = settings, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onAction = { actions += it }, - onDismiss = { dismissed++ }, - ) + EditorProvider(strings = rememberEditorStrings()) { + 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++ }, - ) + EditorProvider(strings = rememberEditorStrings()) { + ActionLinkBottomSheet( + settings = settings, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onAction = { actions += it }, + onDismiss = { dismissed++ }, + ) + } } private fun shown(id: Int) = countText(str(id)) > 0 @@ -61,7 +69,7 @@ class BlockSheetsTest : ComposeScreenTest() { Block.SeparatorBlock() to R.string.block_separator, ) - names.forEach { (block, name) -> assertEquals(block::class.simpleName, name, block.describe()) } + names.forEach { (block, name) -> assertEquals(block::class.simpleName, name, block.describe().resourceId()) } assertEquals(names.size, names.map { it.second }.toSet().size) } diff --git a/app/src/test/java/com/pasich/encly/presentation/editor/persistence/AutosaveDebounceTest.kt b/app/src/test/java/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/app/src/test/java/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/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 index 65ad0802..1260bf8d 100644 --- 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 @@ -1,11 +1,11 @@ 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.editor.codec.BlockConverter +import com.pasich.encly.editor.model.Block import com.pasich.encly.testutil.TestNotesRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt index a4c46133..f89c4e1d 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt +++ b/app/src/test/java/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.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 diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt index 3ed468a0..d8067dd5 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt +++ b/app/src/test/java/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.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 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 index 4d6ed6fa..43b79e34 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt @@ -2,12 +2,12 @@ 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.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 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..c9d746ce 100644 --- a/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt +++ b/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt @@ -41,7 +41,13 @@ class NoDpLiteralsTest { } private companion object { - val ALLOWED = listOf("/com/pasich/encly/ui/theme/", "/com/pasich/encly/presentation/designsystem/") + // The editor carries its own tokens and small controls (it owns no design system of its host). + val ALLOWED = listOf( + "/com/pasich/encly/ui/theme/", + "/com/pasich/encly/presentation/designsystem/", + "/com/pasich/encly/editor/theme/", + "/com/pasich/encly/editor/ui/components/", + ) val DP_LITERAL = Regex("""(? Date: Thu, 29 Oct 2026 11:33:29 +0200 Subject: [PATCH 09/75] ci: run the instrumented tests on an emulator (API 26 and 35) --- .github/workflows/android.yml | 59 +++++++++++++++++++++++++++++++++-- CONTRIBUTING.md | 3 +- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 086c04e5..e9da0f95 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -55,8 +55,8 @@ jobs: 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. + # differs only in store flags and is built by the release workflow; the instrumented tests + # (Room migrations, SQLCipher) run in the `instrumented` job below. - name: Format, unit tests, detekt, release build run: >- ./gradlew spotlessCheck @@ -115,3 +115,58 @@ jobs: app/build/test-results/ 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. 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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + - 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 app and the test APK + run: ./gradlew :app:assembleFdroidDebug :app:assembleFdroidDebugAndroidTest + + - 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 :app:connectedFdroidDebugAndroidTest + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: instrumented-reports-api-${{ matrix.api }} + path: app/build/reports/androidTests/ + retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e6142b6..44fdaf30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,8 @@ 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: +- 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 :app:connectedFdroidDebugAndroidTest` (they wipe the debug app's data). - New detekt or lint findings must be fixed, not added to `app/detekt-baseline.xml` or `app/lint-baseline.xml`. Pull requests that shrink those baselines are very welcome. From 44d83f761d1118662904e0b4c53391f1de150c0c Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 12:40:54 +0200 Subject: [PATCH 10/75] refactor: platform services behind interfaces in the data and domain 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. --- .../pasich/encly/core/backup/BackupPayload.kt | 25 +- .../pasich/encly/core/backup/JvmJsonBytes.kt | 43 +++ .../encly/core/common/AndroidAppStrings.kt | 33 ++ .../pasich/encly/core/common/AndroidUiText.kt | 16 + .../pasich/encly/core/common/AppStrings.kt | 28 +- .../com/pasich/encly/core/common/UiText.kt | 13 +- .../com/pasich/encly/core/common/WallClock.kt | 6 + .../pasich/encly/core/di/SecurityModule.kt | 17 +- .../core/platform/HandlerMainThreadTimer.kt | 17 + .../encly/core/platform/KeyValueStore.kt | 41 ++ .../encly/core/platform/MainThreadTimer.kt | 12 + .../SharedPreferencesKeyValueStore.kt | 51 +++ .../core/security/ActivityBiometricHost.kt | 13 + .../core/security/AuthenticationManager.kt | 2 +- .../pasich/encly/core/security/AutoLock.kt | 6 +- .../encly/core/security/BiometricHost.kt | 7 + .../encly/core/security/BiometricManager.kt | 339 +---------------- .../encly/core/security/DeviceLockWatcher.kt | 45 --- .../encly/core/security/JvmVaultFileIo.kt | 61 +++ .../encly/core/security/KeyboardPrivacy.kt | 6 +- .../core/security/KeystoreBiometricManager.kt | 357 ++++++++++++++++++ .../encly/core/security/KeystorePinFactor.kt | 144 +++++++ .../encly/core/security/LockoutClock.kt | 26 +- .../encly/core/security/PinHardwareFactor.kt | 143 ------- .../encly/core/security/SecurityManager.kt | 39 +- .../encly/core/security/SessionLockManager.kt | 12 +- .../core/security/SystemDeviceLockWatcher.kt | 47 +++ .../encly/core/security/SystemLockoutClock.kt | 22 ++ .../com/pasich/encly/core/security/Utf8.kt | 87 ++++- .../pasich/encly/core/security/VaultCodec.kt | 123 ++++++ .../pasich/encly/core/security/VaultFileIo.kt | 29 ++ .../pasich/encly/core/security/VaultStore.kt | 132 +++---- .../security/cipher/AndroidDatabaseState.kt | 8 + .../core/security/cipher/SQLCipherUtils.kt | 16 +- .../encly/data/backup/BackupImporter.kt | 3 +- .../pasich/encly/data/backup/BackupManager.kt | 8 +- .../data/database/SecureDatabaseManager.kt | 1 + .../pasich/encly/data/export/JvmUnicode.kt | 62 +++ .../encly/data/export/MarkdownBlocks.kt | 3 +- .../encly/data/export/MarkdownFileNames.kt | 47 +-- .../handoff/CacheHandoffStagingSweeper.kt | 4 +- .../encly/data/handoff/HandoffStaging.kt | 91 +++-- .../data/handoff/MyNotesHandoffMapper.kt | 4 +- .../encly/data/importer/ImportMapper.kt | 7 +- .../encly/data/importer/ImportReader.kt | 2 - .../encly/data/importer/SafImportFiles.kt | 11 +- .../java/com/pasich/encly/data/model/Note.kt | 5 +- .../java/com/pasich/encly/data/model/Task.kt | 3 +- .../java/com/pasich/encly/data/zip/JvmZip.kt | 7 +- .../encly/domain/usecase/OnboardingUseCase.kt | 6 +- .../usecase/task/UpdateTaskStatusUseCase.kt | 3 +- .../presentation/screen/EditTagScreen.kt | 1 + .../presentation/screen/NotesListScreen.kt | 1 + .../presentation/screen/TagsListScreen.kt | 1 + .../screen/archive/ArchiveScreen.kt | 1 + .../screen/onboarding/OnboardingScreen.kt | 1 + .../screen/onboarding/OnboardingSteps.kt | 1 + .../screen/settings/SecuritySettingsScreen.kt | 1 + .../screen/trash/TrashNotesList.kt | 1 + .../presentation/viewmodel/BackupFlows.kt | 3 +- .../viewmodel/ImportFromMyNotesViewModel.kt | 8 +- .../presentation/viewmodel/LockViewModel.kt | 3 +- .../viewmodel/OnboardingViewModel.kt | 3 +- .../viewmodel/SecuritySettingsViewModel.kt | 5 +- .../com/pasich/encly/PlatformBoundaryTest.kt | 230 +++++++++++ .../encly/core/common/UiTextAndStringsTest.kt | 6 +- .../pasich/encly/core/common/WallClockTest.kt | 15 + .../com/pasich/encly/core/di/AppGraphTest.kt | 4 +- .../platform/HandlerMainThreadTimerTest.kt | 50 +++ .../SharedPreferencesKeyValueStoreTest.kt | 90 +++++ .../security/ActivityBiometricHostTest.kt | 27 ++ .../encly/core/security/AutoLockTest.kt | 10 +- .../core/security/BiometricManagerTest.kt | 7 +- .../encly/core/security/DuressPinTest.kt | 12 +- .../encly/core/security/MonotonicClockTest.kt | 18 + .../encly/core/security/SQLCipherUtilsTest.kt | 1 + .../security/SecurityManagerLifecycleTest.kt | 14 +- .../core/security/SecurityManagerTest.kt | 10 +- .../core/security/SecurityPlatformTest.kt | 4 +- .../core/security/SessionLockTimerTest.kt | 2 + .../pasich/encly/core/security/Utf8Test.kt | 44 +++ .../security/VaultStoreLegacyFormatTest.kt | 107 ++++++ .../pasich/encly/core/security/WipePinTest.kt | 12 +- .../encly/data/backup/BackupManagerTest.kt | 10 +- .../encly/data/handoff/HandoffFixtures.kt | 3 +- .../encly/data/handoff/HandoffStagingTest.kt | 32 +- .../encly/data/importer/ImportReaderTest.kt | 3 +- .../encly/data/importer/SafImportFilesTest.kt | 71 ++++ .../domain/usecase/RestoreFromBackupTest.kt | 4 +- .../viewmodel/AuthSetupViewModelTest.kt | 4 +- .../presentation/viewmodel/BackupFlowsTest.kt | 24 +- .../viewmodel/BackupViewModelTest.kt | 4 +- .../ImportFromMyNotesViewModelTest.kt | 4 +- .../viewmodel/LockViewModelTest.kt | 19 +- .../viewmodel/OnboardingFailuresTest.kt | 11 +- .../viewmodel/OnboardingViewModelTest.kt | 4 +- .../SecuritySettingsViewModelTest.kt | 29 +- .../encly/testutil/InMemoryKeyValueStore.kt | 10 + .../ui/screens/LockScreenRecreationTest.kt | 6 +- .../com/pasich/encly/ui/screens/TestApp.kt | 12 +- docs/architecture.md | 10 + 101 files changed, 2254 insertions(+), 932 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/backup/JvmJsonBytes.kt create mode 100644 app/src/main/java/com/pasich/encly/core/common/AndroidAppStrings.kt create mode 100644 app/src/main/java/com/pasich/encly/core/common/AndroidUiText.kt create mode 100644 app/src/main/java/com/pasich/encly/core/common/WallClock.kt create mode 100644 app/src/main/java/com/pasich/encly/core/platform/HandlerMainThreadTimer.kt create mode 100644 app/src/main/java/com/pasich/encly/core/platform/KeyValueStore.kt create mode 100644 app/src/main/java/com/pasich/encly/core/platform/MainThreadTimer.kt create mode 100644 app/src/main/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStore.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/ActivityBiometricHost.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/BiometricHost.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/JvmVaultFileIo.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/KeystorePinFactor.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/SystemDeviceLockWatcher.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/SystemLockoutClock.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/VaultCodec.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/VaultFileIo.kt create mode 100644 app/src/main/java/com/pasich/encly/core/security/cipher/AndroidDatabaseState.kt create mode 100644 app/src/main/java/com/pasich/encly/data/export/JvmUnicode.kt create mode 100644 app/src/test/java/com/pasich/encly/PlatformBoundaryTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/common/WallClockTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/platform/HandlerMainThreadTimerTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/platform/SharedPreferencesKeyValueStoreTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/ActivityBiometricHostTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/MonotonicClockTest.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/Utf8Test.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/VaultStoreLegacyFormatTest.kt create mode 100644 app/src/test/java/com/pasich/encly/data/importer/SafImportFilesTest.kt create mode 100644 app/src/test/java/com/pasich/encly/testutil/InMemoryKeyValueStore.kt 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 index ec9a0807..a66a1bd8 100644 --- a/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt +++ b/app/src/main/java/com/pasich/encly/core/backup/BackupPayload.kt @@ -1,6 +1,5 @@ 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 @@ -10,12 +9,8 @@ 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 @@ -131,15 +126,7 @@ object BackupPayloadCodec { 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() - } - } + fun encode(payload: BackupPayload): ByteArray = json.encodeToWipedBytes(BackupPayload.serializer(), payload) /** * A payload from a newer schema is [BackupError.UNSUPPORTED_VERSION]: it authenticated, so @@ -189,7 +176,7 @@ object BackupPayloadCodec { } private fun decodeStream(format: Json, deserializer: DeserializationStrategy, bytes: ByteArray): T = - format.decodeFromStream(deserializer, ByteArrayInputStream(bytes)) + format.decodeFromBytes(deserializer, bytes) private inline fun invalidPayloadOn(block: () -> T): T = try { block() @@ -222,12 +209,4 @@ object BackupPayloadCodec { 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/backup/JvmJsonBytes.kt b/app/src/main/java/com/pasich/encly/core/backup/JvmJsonBytes.kt new file mode 100644 index 00000000..f1fd3256 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/backup/JvmJsonBytes.kt @@ -0,0 +1,43 @@ +@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 Android/JVM side of the backup payload's JSON: 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 this stays on java.io streams until a platform + * buffer that can be wiped exists. A Kotlin Multiplatform build would turn these into expect/actual. + */ + +/** UTF-8 JSON of [value]. The result is plaintext: the caller wipes it. The serializer's own buffer is wiped here. */ +internal fun Json.encodeToWipedBytes(serializer: SerializationStrategy, value: T): ByteArray { + val out = WipeableByteArrayOutputStream() + return try { + encodeToStream(serializer, value, out) + out.toByteArray() + } finally { + out.wipe() + } +} + +/** The value in the UTF-8 JSON [bytes]; the caller keeps (and wipes) them. */ +internal 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/app/src/main/java/com/pasich/encly/core/common/AndroidAppStrings.kt b/app/src/main/java/com/pasich/encly/core/common/AndroidAppStrings.kt new file mode 100644 index 00000000..4ff76396 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/common/AndroidAppStrings.kt @@ -0,0 +1,33 @@ +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 com.pasich.encly.core.di.ApplicationContext +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn + +/** + * [AppStrings] on the Android resources. The application context alone ignores the in-app + * language on Android 12 and lower, so the per-app locale is applied explicitly. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class) +@Inject +class AndroidAppStrings(@param:ApplicationContext private val context: Context) : AppStrings { + + override 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/AndroidUiText.kt b/app/src/main/java/com/pasich/encly/core/common/AndroidUiText.kt new file mode 100644 index 00000000..d827b9fc --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/common/AndroidUiText.kt @@ -0,0 +1,16 @@ +package com.pasich.encly.core.common + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +/** The text in the composition's configuration, so it follows the in-app language. */ +@Composable +fun UiText.asString(): String = when (this) { + is UiText.Resource -> stringResource(id) +} + +/** The text in [context]'s configuration; pass an Activity, not the application context. */ +fun UiText.asString(context: Context): String = when (this) { + is UiText.Resource -> context.getString(id) +} 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 index 2b09d20d..6bcd1da4 100644 --- a/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt +++ b/app/src/main/java/com/pasich/encly/core/common/AppStrings.kt @@ -1,32 +1,12 @@ 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 com.pasich.encly.core.di.ApplicationContext -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn /** * 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]. + * the in-app language. Text that is only shown is [UiText]. The Android implementation is + * [AndroidAppStrings]. */ -@SingleIn(AppScope::class) -@Inject -class AppStrings(@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) - } +interface AppStrings { + fun get(@StringRes id: Int, vararg args: Any): String } 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 index 418e011e..d4e14fbd 100644 --- a/app/src/main/java/com/pasich/encly/core/common/UiText.kt +++ b/app/src/main/java/com/pasich/encly/core/common/UiText.kt @@ -1,9 +1,6 @@ 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). @@ -12,20 +9,12 @@ import androidx.compose.ui.res.stringResource * 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. + * The resolvers are `asString` in AndroidUiText.kt. */ 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/common/WallClock.kt b/app/src/main/java/com/pasich/encly/core/common/WallClock.kt new file mode 100644 index 00000000..428b0838 --- /dev/null +++ b/app/src/main/java/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). */ +fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds() 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 85c85bd7..c0a2b38e 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,15 @@ 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.KeystoreBiometricManager import com.pasich.encly.core.security.KeystorePinFactor import com.pasich.encly.core.security.LockoutClock import com.pasich.encly.core.security.PinHardwareFactor @@ -44,8 +49,8 @@ object SecurityModule { */ @Provides @SingleIn(AppScope::class) - fun provideAppFlags(@ApplicationContext context: Context): SharedPreferences = - context.getSharedPreferences(FLAGS_FILE, Context.MODE_PRIVATE) + 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" @@ -57,6 +62,12 @@ interface SecurityBindingsModule { @Binds fun bindPinHardwareFactor(factor: KeystorePinFactor): PinHardwareFactor + @Binds + fun bindBiometricManager(manager: KeystoreBiometricManager): BiometricManager + + @Binds + fun bindMainThreadTimer(timer: HandlerMainThreadTimer): MainThreadTimer + @Binds fun bindLockoutClock(clock: SystemLockoutClock): LockoutClock 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/KeyValueStore.kt b/app/src/main/java/com/pasich/encly/core/platform/KeyValueStore.kt new file mode 100644 index 00000000..49f00f2b --- /dev/null +++ b/app/src/main/java/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. + */ +interface KeyValueStore { + fun getString(key: String, default: String?): String? + + fun getBoolean(key: String, default: Boolean): Boolean + + fun getLong(key: String, default: Long): Long + + fun contains(key: String): Boolean + + /** Applies [block]'s changes in one batch; persisted in the background. */ + fun edit(block: Editor.() -> Unit) + + /** Applies [block]'s changes in one batch and waits until they are persisted; false if they were not. */ + fun commit(block: Editor.() -> Unit): Boolean + + /** The changes of one [edit] or [commit]. */ + interface Editor { + fun putString(key: String, value: String) + + fun putBoolean(key: String, value: Boolean) + + fun putLong(key: String, value: Long) + + fun remove(key: String) + + /** Removes every key. */ + fun clear() + } +} diff --git a/app/src/main/java/com/pasich/encly/core/platform/MainThreadTimer.kt b/app/src/main/java/com/pasich/encly/core/platform/MainThreadTimer.kt new file mode 100644 index 00000000..cf89271a --- /dev/null +++ b/app/src/main/java/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). */ +fun interface MainThreadTimer { + /** Schedules [action] after [delayMs]; the returned function cancels it. */ + fun schedule(delayMs: Long, action: () -> Unit): () -> Unit + + /** Never runs anything (unit tests that drive the callers by hand). */ + object None : MainThreadTimer { + override fun schedule(delayMs: Long, action: () -> Unit): () -> Unit = {} + } +} 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/AuthenticationManager.kt b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt index ce7d857c..67f4e505 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AuthenticationManager.kt @@ -877,7 +877,7 @@ class AuthenticationManager( /** 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) diff --git a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt index fdd97b7a..14d8013c 100644 --- a/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt +++ b/app/src/main/java/com/pasich/encly/core/security/AutoLock.kt @@ -1,6 +1,6 @@ package com.pasich.encly.core.security -import android.content.SharedPreferences +import com.pasich.encly.core.platform.KeyValueStore import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -41,14 +41,14 @@ interface AutoLockPolicy { */ @SingleIn(AppScope::class) @Inject -class AutoLock(private val flags: SharedPreferences) : AutoLockPolicy { +class AutoLock(private val flags: KeyValueStore) : 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() + flags.edit { putString(DELAY_KEY, delay.name) } _delay.value = delay } diff --git a/app/src/main/java/com/pasich/encly/core/security/BiometricHost.kt b/app/src/main/java/com/pasich/encly/core/security/BiometricHost.kt new file mode 100644 index 00000000..7bd301f2 --- /dev/null +++ b/app/src/main/java/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. + */ +interface BiometricHost 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 index f5c2ede4..267a90d1 100644 --- a/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/BiometricManager.kt @@ -1,36 +1,11 @@ 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 com.pasich.encly.core.di.ApplicationContext -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. + * 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. */ -@SingleIn(AppScope::class) -@Suppress("TooManyFunctions") // Centralizes the complete auth-bound biometric slot lifecycle. -@Inject -class BiometricManager(@param:ApplicationContext private val context: Context, private val store: VaultStore) { +interface BiometricManager { enum class BiometricType { APP_UNLOCK, MASTER_KEY_ACCESS, @@ -53,303 +28,36 @@ class BiometricManager(@param:ApplicationContext private val context: Context, p 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 + fun isStrongBiometricAvailable(): Boolean /** 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 strongBiometricStatus(): BiometricStatus - fun enroll(activity: FragmentActivity, dek: ByteArray, onResult: (Boolean) -> Unit) { - if (!isStrongBiometricAvailable() || dek.size != DEK_LENGTH) { - onResult(false) - return - } + fun hasSlot(): Boolean - 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) - } - } + /** Wraps [dek] under a new auth-bound key after a prompt on [host]; [onResult] says whether the slot was stored. */ + fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) /** - * Returns the DEK only after a successful auth-bound CryptoObject operation. - * The caller owns the returned bytes and must zeroize them. + * Returns the DEK only after a successful auth-bound 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) - } - } + fun unlock(host: BiometricHost, onResult: (ByteArray?) -> Unit) /** Generic re-auth prompt for settings actions that do not themselves release a key. */ fun authenticate( - activity: FragmentActivity, + host: BiometricHost, 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) { - } - } + /** Removes the slot and the key behind it. */ + fun disable() - fun disable() { - store.edit { removePrefix(SLOT_PREFIX) } - deleteKeyOnly() + companion object { + /** The [VaultStore] key prefix of the slot's entries. */ + const val SLOT_PREFIX = "bio." } } @@ -364,12 +72,5 @@ enum class BiometricStatus { 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 - } - } + companion object } 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 index 4ce5a546..80f74613 100644 --- a/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt +++ b/app/src/main/java/com/pasich/encly/core/security/DeviceLockWatcher.kt @@ -1,16 +1,5 @@ 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 - /** 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. */ @@ -25,37 +14,3 @@ interface DeviceLockWatcher { override fun watchScreenOff(onScreenOff: () -> Unit): () -> Unit = {} } } - -@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/JvmVaultFileIo.kt b/app/src/main/java/com/pasich/encly/core/security/JvmVaultFileIo.kt new file mode 100644 index 00000000..33482ade --- /dev/null +++ b/app/src/main/java/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]. */ +fun VaultStore(file: File): VaultStore = VaultStore(JvmVaultFileIo(file)) + +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/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt b/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt index b4ec4726..ba4ebf30 100644 --- a/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt +++ b/app/src/main/java/com/pasich/encly/core/security/KeyboardPrivacy.kt @@ -1,6 +1,6 @@ package com.pasich.encly.core.security -import android.content.SharedPreferences +import com.pasich.encly.core.platform.KeyValueStore import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -17,12 +17,12 @@ import kotlinx.coroutines.flow.asStateFlow */ @SingleIn(AppScope::class) @Inject -class KeyboardPrivacy(private val flags: SharedPreferences) { +class KeyboardPrivacy(private val flags: KeyValueStore) { 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() + flags.edit { putBoolean(STRICT_KEY, enabled) } _strict.value = enabled } 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..b7efdd57 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt @@ -0,0 +1,357 @@ +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 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) : + 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) { + val activity = host.activityOrNull() + if (activity == null) { + onResult(false) + return + } + 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. + */ + @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, context.getString(R.string.biometric_unavailable)) + return + } + 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) { + } + } + + 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 index 2ae6f872..6b1bdf8d 100644 --- a/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt +++ b/app/src/main/java/com/pasich/encly/core/security/LockoutClock.kt @@ -1,12 +1,6 @@ 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 +import kotlin.time.TimeSource /** * Time for the PIN lockout. Monotonic, so changing the wall clock does not end a lockout. @@ -20,26 +14,14 @@ interface LockoutClock { fun bootCount(): Int } -@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 - } -} - /** - * A monotonic clock without Android (JVM unit tests). Unlike `elapsedRealtime` it may not + * 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. */ object JvmMonotonicClock : LockoutClock { - private const val NANOS_PER_MILLI = 1_000_000L + private val origin = TimeSource.Monotonic.markNow() - override fun elapsedRealtime(): Long = System.nanoTime() / NANOS_PER_MILLI + override fun elapsedRealtime(): Long = origin.elapsedNow().inWholeMilliseconds override fun bootCount(): Int = -1 } 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 index 2670e74f..ece373f3 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PinHardwareFactor.kt @@ -1,20 +1,5 @@ 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 - /** * 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 @@ -62,131 +47,3 @@ interface PinHardwareFactor { * (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") -@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/SecurityManager.kt b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt index 71fb6f8e..a57b6030 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SecurityManager.kt @@ -1,9 +1,8 @@ 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.core.crypto.Crypto +import com.pasich.encly.core.platform.KeyValueStore import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.database.SecureDatabaseManager import com.pasich.encly.data.database.VaultFile @@ -11,8 +10,9 @@ import com.pasich.encly.data.handoff.HandoffStagingSweeper import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi enum class InitialStatus { NO, @@ -51,10 +51,11 @@ enum class VaultUnlockResult { * 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 class SecurityManager( - private val appFlags: SharedPreferences, + private val appFlags: KeyValueStore, private val vaultStore: VaultStore, private val seedPhraseManager: SeedPhraseManager, private val secureDatabaseManager: SecureDatabaseManager, @@ -274,11 +275,11 @@ class SecurityManager( return if (opened) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR } - fun requestBiometricKey(activity: FragmentActivity, onResult: (ByteArray?) -> Unit) { - biometricManager.unlock(activity, onResult) + fun requestBiometricKey(host: BiometricHost, onResult: (ByteArray?) -> Unit) { + biometricManager.unlock(host, onResult) } - fun enrollBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { + 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) { @@ -286,7 +287,7 @@ class SecurityManager( return } - biometricManager.enroll(activity, dek) { ok -> + biometricManager.enroll(host, dek) { ok -> authenticationManager.markBiometricEnabled(ok) onResult(ok) } @@ -299,14 +300,14 @@ class SecurityManager( authenticationManager.markBiometricEnabled(false) } - fun confirmBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { + 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( - activity, + host, BiometricManager.BiometricType.SETTINGS_TOGGLE, object : BiometricManager.BiometricCallback { override fun onSuccess() = onResult(true) @@ -445,7 +446,7 @@ class SecurityManager( secureDatabaseManager.reset() return false } - eraseEpoch.incrementAndGet() + eraseEpoch.addAndFetch(1L) return true } @@ -463,7 +464,7 @@ class SecurityManager( 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() + appFlags.commit { remove(BackupManager.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() @@ -475,7 +476,7 @@ class SecurityManager( * 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() + fun eraseEpoch(): Long = eraseEpoch.load() // --- wipe PIN ----------------------------------------------------------------------- @@ -603,9 +604,7 @@ class SecurityManager( if (!seedPhraseManager.verificationKeyData() || !authenticationManager.hasPinSlot()) { return false } - return appFlags.edit() - .putBoolean(ONBOARDING_SHOWN_KEY, true) - .commit() + return appFlags.commit { putBoolean(ONBOARDING_SHOWN_KEY, true) } } fun generateMnemonicCode(): CharArray = seedPhraseManager.generateMnemonic() @@ -623,7 +622,7 @@ class SecurityManager( biometricManager.disable() authenticationManager.wipe() seedPhraseManager.wipe() - appFlags.edit().remove(ONBOARDING_SHOWN_KEY).commit() + 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 @@ -646,7 +645,7 @@ class SecurityManager( 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() + if (lastVault != null && lastVault != vault) eraseEpoch.addAndFetch(1L) lastVault = vault sessionDek = dek.copyOf() sessionVault = vault @@ -672,7 +671,7 @@ class SecurityManager( 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() + appFlags.commit { clear() } securityStatus = InitialStatus.ONBOARDING return true } diff --git a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt index 070d79d3..cf66bb90 100644 --- a/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/SessionLockManager.kt @@ -1,10 +1,9 @@ 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 com.pasich.encly.core.platform.MainThreadTimer import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -52,6 +51,7 @@ class SessionLockManager( private val deviceLock: DeviceLockWatcher = DeviceLockWatcher.None, systemClock: LockoutClock = JvmMonotonicClock, private val autoLock: AutoLockPolicy = AutoLockPolicy.Immediately, + private val mainThread: MainThreadTimer = MainThreadTimer.None, ) : DefaultLifecycleObserver, VaultLockEvents { @@ -94,13 +94,7 @@ class SessionLockManager( 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 - } + internal var timer: (delayMs: Long, action: () -> Unit) -> () -> Unit = mainThread::schedule private var cancelPickerTimeout: (() -> Unit)? = null private var stopScreenOffWatch: (() -> Unit)? = null 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/Utf8.kt b/app/src/main/java/com/pasich/encly/core/security/Utf8.kt index 2043ac35..8288b850 100644 --- a/app/src/main/java/com/pasich/encly/core/security/Utf8.kt +++ b/app/src/main/java/com/pasich/encly/core/security/Utf8.kt @@ -1,17 +1,88 @@ package com.pasich.encly.core.security -import java.nio.CharBuffer - 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 copy is made, and the - * encoder's intermediate buffer is zeroized before returning. + * [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 buffer = Charsets.UTF_8.encode(CharBuffer.wrap(chars)) - val bytes = ByteArray(buffer.remaining()) - buffer.get(bytes) - if (buffer.hasArray()) SensitiveDataCleaner.clear(buffer.array()) + 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/app/src/main/java/com/pasich/encly/core/security/VaultCodec.kt b/app/src/main/java/com/pasich/encly/core/security/VaultCodec.kt new file mode 100644 index 00000000..54c08312 --- /dev/null +++ b/app/src/main/java/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/app/src/main/java/com/pasich/encly/core/security/VaultFileIo.kt b/app/src/main/java/com/pasich/encly/core/security/VaultFileIo.kt new file mode 100644 index 00000000..09076508 --- /dev/null +++ b/app/src/main/java/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]. + */ +interface VaultFileIo { + /** Whether the vault file exists. */ + fun exists(): Boolean + + /** The whole vault file. */ + fun read(): ByteArray + + /** Removes the temp file a write that never reached its rename left behind; never throws. */ + 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. + */ + fun replace(bytes: ByteArray, beforeRename: (() -> Unit)?) + + /** Deletes the vault file; true when it is gone afterwards (it may never have existed). */ + fun delete(): Boolean +} 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 index bc14102a..f4000907 100644 --- a/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt +++ b/app/src/main/java/com/pasich/encly/core/security/VaultStore.kt @@ -1,16 +1,7 @@ package com.pasich.encly.core.security import com.pasich.encly.core.crypto.Crypto -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 kotlinx.io.IOException /** * The vault's key slots and PIN lockout state, in one app-private file. @@ -29,9 +20,13 @@ import java.nio.file.StandardCopyOption * * 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. -class VaultStore(private val file: File) { +class VaultStore(private val io: VaultFileIo) { private val lock = Any() // Guarded by [lock]. Null until the first read. @@ -52,11 +47,11 @@ class VaultStore(private val file: File) { 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 + load()[key]?.takeIf { it.size == Int.SIZE_BYTES }?.let { BigEndian.readInt(it) } ?: default } fun getLong(key: String, default: Long): Long = synchronized(lock) { - load()[key]?.takeIf { it.size == Long.SIZE_BYTES }?.let { ByteBuffer.wrap(it).long } ?: default + load()[key]?.takeIf { it.size == Long.SIZE_BYTES }?.let { BigEndian.readLong(it) } ?: default } fun getBoolean(key: String, default: Boolean): Boolean = synchronized(lock) { @@ -93,8 +88,8 @@ class VaultStore(private val file: File) { cache?.values?.forEach(SensitiveDataCleaner::clear) cache = emptyMap() corrupt = false - tempFile().delete() - !file.exists() || file.delete() + io.deleteTemp() + io.delete() } class Editor internal constructor(private val values: MutableMap) { @@ -104,11 +99,11 @@ class VaultStore(private val file: File) { } fun putInt(key: String, value: Int) { - values[key] = ByteBuffer.allocate(Int.SIZE_BYTES).putInt(value).array() + values[key] = BigEndian.bytesOf(value) } fun putLong(key: String, value: Long) { - values[key] = ByteBuffer.allocate(Long.SIZE_BYTES).putLong(value).array() + values[key] = BigEndian.bytesOf(value) } fun putBoolean(key: String, value: Boolean) { @@ -128,12 +123,12 @@ class VaultStore(private val file: File) { 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()) { + io.deleteTemp() + val loaded = if (!io.exists()) { emptyMap() } else { try { - parse(file.readBytes()) + parse(io.read()) } catch (_: IOException) { null } @@ -144,66 +139,48 @@ class VaultStore(private val file: File) { 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 + io.replace(bytes, beforeRename) } 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 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".toByteArray(Charsets.US_ASCII) + val MAGIC = "ENCLYVS3".encodeToByteArray() 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 = Crypto.sha256(body) - return try { - body + digest - } finally { - SensitiveDataCleaner.clear(body) + 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. */ @@ -214,19 +191,19 @@ class VaultStore(private val file: File) { 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 = DataInputStream(bytes.inputStream(0, bodyLength)) - val magic = ByteArray(MAGIC.size).also(input::readFully) + 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 = input.readUTF() + val key = ModifiedUtf8.decode(input.readBytes(input.readUnsignedShort())) val length = input.readInt() if (length !in 0..MAX_VALUE_LENGTH) return null - values[key] = ByteArray(length).also(input::readFully) + values[key] = input.readBytes(length) } - return if (input.available() == 0) values else null + return if (input.remaining == 0) values else null } catch (_: IOException) { return null } finally { @@ -234,4 +211,27 @@ class VaultStore(private val file: File) { } } } + + /** 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/main/java/com/pasich/encly/core/security/cipher/AndroidDatabaseState.kt b/app/src/main/java/com/pasich/encly/core/security/cipher/AndroidDatabaseState.kt new file mode 100644 index 00000000..164b79fd --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/cipher/AndroidDatabaseState.kt @@ -0,0 +1,8 @@ +package com.pasich.encly.core.security.cipher + +import android.content.Context +import kotlinx.io.files.Path + +/** The state of the app database called [dbName] in this app's database directory. */ +fun SQLCipherUtils.getDatabaseState(context: Context, dbName: String?): SQLCipherUtils.State = + getDatabaseState(Path(context.getDatabasePath(dbName).path)) diff --git a/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt b/app/src/main/java/com/pasich/encly/core/security/cipher/SQLCipherUtils.kt index af4b7a39..120f6bf4 100644 --- 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 @@ -1,8 +1,8 @@ package com.pasich.encly.core.security.cipher -import android.content.Context -import java.io.File -import java.io.FileInputStream +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. @@ -16,13 +16,11 @@ 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 + fun getDatabaseState(dbPath: Path): State { + if (!SystemFileSystem.exists(dbPath)) return State.DOES_NOT_EXIST return try { val header = ByteArray(SQLITE_MAGIC.size) - val read = FileInputStream(dbPath).use { it.read(header) } + val read = SystemFileSystem.source(dbPath).buffered().use { it.readAtMostTo(header) } if (read == SQLITE_MAGIC.size && header.contentEquals(SQLITE_MAGIC)) { State.UNENCRYPTED } else { @@ -33,5 +31,5 @@ object SQLCipherUtils { } } - private val SQLITE_MAGIC = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + private val SQLITE_MAGIC = "SQLite format 3\u0000".encodeToByteArray() } diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt index 9a059dd7..5e07bebd 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupImporter.kt @@ -10,7 +10,6 @@ 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 java.util.Locale enum class ImportMode { /** @@ -216,7 +215,7 @@ 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( title = title, diff --git a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt index 12b19fa5..59d7a9fc 100644 --- a/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt +++ b/app/src/main/java/com/pasich/encly/data/backup/BackupManager.kt @@ -1,7 +1,5 @@ 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 @@ -12,6 +10,8 @@ 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.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 @@ -29,9 +29,9 @@ import kotlinx.coroutines.CancellationException class BackupManager( private val securityManager: SecurityManager, private val store: VaultDataStore, - private val secureStoragePrefs: SharedPreferences, + private val secureStoragePrefs: KeyValueStore, ) { - 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 diff --git a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt index d3812b4f..8bc085de 100644 --- a/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt +++ b/app/src/main/java/com/pasich/encly/data/database/SecureDatabaseManager.kt @@ -6,6 +6,7 @@ import com.pasich.encly.core.AppLogger import com.pasich.encly.core.di.ApplicationContext import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.core.security.cipher.SQLCipherUtils +import com.pasich.encly.core.security.cipher.getDatabaseState import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn diff --git a/app/src/main/java/com/pasich/encly/data/export/JvmUnicode.kt b/app/src/main/java/com/pasich/encly/data/export/JvmUnicode.kt new file mode 100644 index 00000000..1d831a7a --- /dev/null +++ b/app/src/main/java/com/pasich/encly/data/export/JvmUnicode.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.data.export + +import java.text.Normalizer + +/* + * The Android/JVM side 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. A Kotlin Multiplatform + * build would turn these top-level functions into expect/actual declarations. + */ + +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 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 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 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/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt b/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt index 0f206a47..bce9637c 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownBlocks.kt @@ -5,7 +5,6 @@ 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 java.util.Locale /** * A note's blocks as Markdown (CommonMark with GFM task lists), for the plaintext export. @@ -69,7 +68,7 @@ object MarkdownBlocks { /** [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 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 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 index 2a494def..6a83cba4 100644 --- a/app/src/main/java/com/pasich/encly/data/export/MarkdownFileNames.kt +++ b/app/src/main/java/com/pasich/encly/data/export/MarkdownFileNames.kt @@ -1,8 +1,5 @@ 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. * @@ -41,57 +38,21 @@ class MarkdownFileNames(reserved: Collection = emptyList(), private val return cutBytes(base, room.coerceAtLeast(1)).trimEnd('.', ' ') + suffix } - private fun key(path: String) = Normalizer.normalize(path, Normalizer.Form.NFC).lowercase(Locale.ROOT) + private fun key(path: String) = normalizeNfc(path).lowercase() 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('.', ' ') + val kept = keepFileNameCharacters(normalizeNfc(title)) + val spaced = kept.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) - } + fun cutBytes(text: String, bytes: Int): String = utf8Prefix(text, bytes) } } 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 f9413a28..f7f78d85 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 @@ -5,11 +5,11 @@ import com.pasich.encly.core.di.ApplicationContext import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import java.io.File +import kotlinx.io.files.Path /** [HandoffStagingSweeper] for the staging directory in the app's cache. */ @SingleIn(AppScope::class) @Inject class CacheHandoffStagingSweeper(@param:ApplicationContext private val context: Context) : HandoffStagingSweeper { - override fun sweep() = HandoffStaging.sweep(File(context.cacheDir, HandoffStaging.DIR_NAME)) + 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 index fe26c95e..905a42b8 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt +++ b/app/src/main/java/com/pasich/encly/data/handoff/HandoffStaging.kt @@ -1,12 +1,12 @@ package com.pasich.encly.data.handoff +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.data.zip.openZipFile -import kotlinx.io.asSink -import kotlinx.io.asSource +import kotlinx.io.IOException +import kotlinx.io.RawSource import kotlinx.io.buffered -import java.io.File -import java.io.IOException -import java.io.InputStream +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 @@ -19,19 +19,20 @@ import java.io.InputStream * 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()) { +class HandoffStaging(private val dir: Path, 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. + * 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: () -> InputStream?): HandoffImport { + fun load(open: () -> RawSource?): HandoffImport { val zip = createStagingFile(dir) try { val input = open() ?: throw HandoffException(HandoffError.INVALID_PAYLOAD) - input.use { stream -> - zip.outputStream().asSink().buffered().use { - MyNotesHandoffReader.copyLimited(stream.asSource(), it, limits) + input.use { source -> + SystemFileSystem.sink(zip).buffered().use { + MyNotesHandoffReader.copyLimited(source, it, limits) } } return MyNotesHandoffMapper.map(read(zip, limits)) @@ -44,10 +45,13 @@ class HandoffStaging(private val dir: File, private val limits: HandoffLimits = 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: File, limits: HandoffLimits = HandoffLimits()): MyNotesHandoff = - MyNotesHandoffReader.read(zip.length(), limits) { openZipFile(zip) } + 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() @@ -56,26 +60,65 @@ class HandoffStaging(private val dir: File, private val limits: HandoffLimits = * 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) { + fun sweep(dir: Path) { synchronized(inUse) { - dir.listFiles()?.filterNot { it.path in inUse }?.forEach { it.deleteRecursively() } - if (inUse.isEmpty()) dir.delete() + 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: 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 createStagingFile(dir: Path): Path = synchronized(inUse) { + 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: File, zip: File) { + private fun release(dir: Path, zip: Path) { 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() + 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/MyNotesHandoffMapper.kt b/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt index 012e7f60..28c98548 100644 --- a/app/src/main/java/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt +++ b/app/src/main/java/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( @@ -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.toByteArray(Charsets.UTF_8)) return buildString(digest.size * 2) { digest.forEach { byte -> val b = byte.toInt() diff --git a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt index 8e8d427e..ee3e47ba 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportMapper.kt @@ -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.common.currentTimeMillis +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.data.backup.BackupImporter import com.pasich.encly.editor.codec.BlockConverter import com.pasich.encly.editor.model.Block import com.pasich.encly.editor.model.BlockType -import java.security.MessageDigest /** What the user confirms before anything is written: counts only, never content. */ data class ImportPreview( @@ -157,7 +158,7 @@ object ImportMapper { } private fun sha256Hex(text: String): String { - val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + val digest = Crypto.sha256(text.toByteArray(Charsets.UTF_8)) return buildString(digest.size * 2) { digest.forEach { byte -> val b = byte.toInt() @@ -187,7 +188,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/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt index 0ac7aa9b..13307619 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/ImportReader.kt @@ -83,8 +83,6 @@ class ImportReader(private val limits: ImportLimits = ImportLimits()) { file.open() } catch (_: IOException) { null - } catch (_: SecurityException) { - null } if (stream == null) { unreadable++ 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 index 47a6fba5..93789f5d 100644 --- a/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt +++ b/app/src/main/java/com/pasich/encly/data/importer/SafImportFiles.kt @@ -25,8 +25,15 @@ class SafImportFiles(@param:ApplicationContext private val context: Context) { fun tree(tree: Uri, limits: ImportLimits = ImportLimits()): ImportFileSource = TreeWalker(SafTree(tree, limits), limits).source() - /** The platform stream as a kotlinx-io source: the import logic never sees an [java.io.InputStream]. */ - private fun open(uri: Uri) = context.contentResolver.openInputStream(uri)?.asSource() + /** + * The platform stream as a kotlinx-io source: the import logic never sees an [java.io.InputStream] + * or a [SecurityException]. A provider that refuses access is an unreadable file, like a missing one. + */ + private fun open(uri: Uri) = try { + context.contentResolver.openInputStream(uri)?.asSource() + } catch (_: SecurityException) { + null + } private fun displayName(uri: Uri): String? = try { context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { 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 index 8720f9bf..6703df21 100644 --- a/app/src/main/java/com/pasich/encly/data/model/Note.kt +++ b/app/src/main/java/com/pasich/encly/data/model/Note.kt @@ -5,6 +5,7 @@ import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey +import com.pasich.encly.core.common.currentTimeMillis /** * [uid] is the note's stable, device-independent identity used by encrypted backups (merge @@ -61,8 +62,8 @@ data class Note( fun new(title: String, value: String, tagId: Long? = null): Note = Note( title = title, value = value, - date = System.currentTimeMillis(), - dateCreate = System.currentTimeMillis(), + date = currentTimeMillis(), + dateCreate = currentTimeMillis(), tagId = tagId, ) } 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 index 68f2712a..4c4c04b6 100644 --- a/app/src/main/java/com/pasich/encly/data/model/Task.kt +++ b/app/src/main/java/com/pasich/encly/data/model/Task.kt @@ -4,6 +4,7 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import com.pasich.encly.core.common.currentTimeMillis /** [uid]: stable backup identity, see [Note.uid]. */ @Entity(tableName = "tasks", indices = [Index(value = ["uid"], unique = true)]) @@ -13,7 +14,7 @@ data class Task( val title: String, val description: String? = null, val isCompleted: Boolean = false, - val createdDate: Long = System.currentTimeMillis(), + val createdDate: Long = currentTimeMillis(), val completedDate: Long? = null, val priority: Int = 0, // 0 - Low, 1 - Medium, 2 - High val categoryId: Long? = null, diff --git a/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt b/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt index fb8a252a..c2c689bc 100644 --- a/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt +++ b/app/src/main/java/com/pasich/encly/data/zip/JvmZip.kt @@ -5,6 +5,7 @@ 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 @@ -60,9 +61,9 @@ private val cp437: Charset = try { /** [bytes] as CP437, the ZIP format's default name encoding (ISO-8859-1 where the JVM has no CP437). */ internal fun decodeCp437(bytes: ByteArray): String = String(bytes, cp437) -/** The ZIP at [file], opened with [ZipFile]; throws an [IOException] if it is not one. */ -internal fun openZipFile(file: File): ZipEntrySource = object : ZipEntrySource { - private val archive = ZipFile(file) +/** The ZIP at [path], opened with [ZipFile]; throws an [IOException] if it is not one. */ +internal fun openZipFile(path: Path): ZipEntrySource = object : ZipEntrySource { + private val archive = ZipFile(File(path.toString())) override val entryCount: Int get() = archive.size() diff --git a/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt index 4bc13ae4..6a4d3fa1 100644 --- a/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt +++ b/app/src/main/java/com/pasich/encly/domain/usecase/OnboardingUseCase.kt @@ -1,9 +1,9 @@ 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 @@ -77,6 +77,6 @@ class OnboardingUseCase( 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/task/UpdateTaskStatusUseCase.kt b/app/src/main/java/com/pasich/encly/domain/usecase/task/UpdateTaskStatusUseCase.kt index f318ab3d..4ed361f5 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -9,6 +10,6 @@ class UpdateTaskStatusUseCase(private val tasksRepository: TasksRepository) { suspend operator fun invoke( id: Long, isCompleted: Boolean, - completedDate: Long? = if (isCompleted) System.currentTimeMillis() else null, + completedDate: Long? = if (isCompleted) currentTimeMillis() else null, ): Result = tasksRepository.updateTaskStatus(id, isCompleted, completedDate) } diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt index 501e58c8..9a173b15 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/EditTagScreen.kt @@ -58,6 +58,7 @@ 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.data.model.Tag import com.pasich.encly.presentation.designsystem.EnclyBottomSheet import com.pasich.encly.presentation.designsystem.EnclyButton diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt index 7dc275f5..aa430453 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/NotesListScreen.kt @@ -35,6 +35,7 @@ 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.data.model.Note import com.pasich.encly.data.model.NoteWithTag import com.pasich.encly.data.model.Tag diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt index 034bdb44..c8370c4e 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/TagsListScreen.kt @@ -16,6 +16,7 @@ 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.data.model.Tag import com.pasich.encly.presentation.components.tiles.TagItem import com.pasich.encly.presentation.viewmodel.TagListEvent.SelectTag diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt index a2dc0537..7f5d1c02 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/archive/ArchiveScreen.kt @@ -17,6 +17,7 @@ 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.domain.model.NoteListItem import com.pasich.encly.domain.model.SwipeSettings import com.pasich.encly.presentation.components.swipe.SwipeContext diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt index b855951d..73ec5dd8 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/onboarding/OnboardingScreen.kt @@ -41,6 +41,7 @@ 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.common.asString import com.pasich.encly.core.locale.AppLanguage import com.pasich.encly.core.locale.AppLocales import com.pasich.encly.presentation.designsystem.DialogAction 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 index ec80b9ac..c9db1a1f 100644 --- 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 @@ -28,6 +28,7 @@ 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.core.common.asString import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.DoneRow import com.pasich.encly.presentation.designsystem.EnclyCallout diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index 6e148cc0..259626e7 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt @@ -26,6 +26,7 @@ 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.common.asString import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt index 2e87746b..397d5e37 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/trash/TrashNotesList.kt @@ -20,6 +20,7 @@ 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.data.model.Note import com.pasich.encly.presentation.components.tiles.NoteItem import com.pasich.encly.presentation.designsystem.EnclyEmptyState diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index c04a3e95..f1d9e72a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -8,6 +8,7 @@ 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.security.ActivityBiometricHost import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.data.backup.BackupDocuments @@ -203,7 +204,7 @@ class ReauthFlow( fun withBiometric(activity: FragmentActivity) { val step = state.step as? BackupStep.Reauth ?: return - securityManager.confirmBiometric(activity) { ok -> + securityManager.confirmBiometric(ActivityBiometricHost(activity)) { ok -> if (ok) { state.launch { onAuthenticated(step.action) } } else { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt index 8f01c947..469a4783 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModel.kt @@ -36,7 +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 kotlinx.io.asSource +import kotlinx.io.files.Path import java.io.IOException /** Where a My Notes hand-off is. */ @@ -74,7 +75,7 @@ class ImportFromMyNotesViewModel( @Assisted private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val staging = HandoffStaging(File(context.cacheDir, HandoffStaging.DIR_NAME)) + private val staging = HandoffStaging(Path(context.cacheDir.path, HandoffStaging.DIR_NAME)) private val _step = MutableStateFlow(HandoffStep.Waiting) val step: StateFlow = _step.asStateFlow() @@ -178,7 +179,8 @@ class ImportFromMyNotesViewModel( } private suspend fun read(uri: Uri): HandoffStep = try { - val loaded = withContext(ioDispatcher) { staging.load { context.contentResolver.openInputStream(uri) } } + val loaded = + withContext(ioDispatcher) { staging.load { context.contentResolver.openInputStream(uri)?.asSource() } } handoff = loaded HandoffStep.Preview(loaded.preview) } catch (e: HandoffException) { 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 index ca2f3573..f4a7f09b 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -4,6 +4,7 @@ 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.ActivityBiometricHost import com.pasich.encly.core.security.AuthStrategy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner @@ -117,7 +118,7 @@ class LockViewModel( */ fun authenticateBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { biometricPrompt.launch(activity) { release -> - securityManager.requestBiometricKey(activity) { dek -> + securityManager.requestBiometricKey(ActivityBiometricHost(activity)) { dek -> if (!release()) { dek?.let(SensitiveDataCleaner::clear) return@requestBiometricKey diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 9ac73068..1a1c03cf 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -9,6 +9,7 @@ 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.ActivityBiometricHost import com.pasich.encly.core.security.PIN_LENGTH import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.data.backup.BackupDocuments @@ -354,7 +355,7 @@ class OnboardingViewModel( fun enrollBiometric(activity: FragmentActivity) { if (!_uiState.value.biometricPending) return _uiState.update { it.copy(biometricPending = false) } - onboardingUseCase.enrollBiometric(activity) { ok -> + onboardingUseCase.enrollBiometric(ActivityBiometricHost(activity)) { ok -> _uiState.update { it.copy(biometricEnabled = ok) } } } diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 39d87aca..98f68c72 100644 --- a/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -5,6 +5,7 @@ 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.security.ActivityBiometricHost import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.AutoLockDelay @@ -182,7 +183,7 @@ class SecuritySettingsViewModel( } private fun enrollBiometric(activity: FragmentActivity, release: () -> Boolean) { - securityManager.enrollBiometric(activity) { ok -> + securityManager.enrollBiometric(ActivityBiometricHost(activity)) { ok -> release() _uiState.value = _uiState.value.copy( biometricEnable = ok && securityManager.isBiometricEnabled(), @@ -192,7 +193,7 @@ class SecuritySettingsViewModel( } private fun disableBiometric(activity: FragmentActivity, release: () -> Boolean) { - securityManager.confirmBiometric(activity) { confirmed -> + securityManager.confirmBiometric(ActivityBiometricHost(activity)) { confirmed -> release() if (confirmed) { securityManager.disableBiometric() diff --git a/app/src/test/java/com/pasich/encly/PlatformBoundaryTest.kt b/app/src/test/java/com/pasich/encly/PlatformBoundaryTest.kt new file mode 100644 index 00000000..85c47fe3 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/PlatformBoundaryTest.kt @@ -0,0 +1,230 @@ +package com.pasich.encly + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The domain and data logic is written to move into Kotlin Multiplatform modules (`:core:data`, + * `:core:security`, `:core:database`), so the packages in [LOGIC_PACKAGES] may use nothing of + * Android and nothing of the JVM: no `android.` or `javax.` or `java.` import, no `androidx.` + * beyond Room, sqlite, DataStore, lifecycle and annotations, no `System`, `javaClass` or + * `SecurityException` in code (`R.string` ids in domain enums stay until Compose + * resources replace them). What the platform has to do is behind an interface the + * logic declares (KeyValueStore, VaultFileIo, BiometricManager, MainThreadTimer, AppStrings, + * LockoutClock, ...) and implemented in one adapter file. Each adapter file is listed in + * [ADAPTER_FILES] with the reason, and nothing else may be: the check also fails when a listed + * file no longer needs its place, and when a logic file names a type that only an adapter + * declares (a same-package reference needs no import, so imports alone would not show it). + */ +class PlatformBoundaryTest { + private val sourceRoot = listOf( + File("src/main/java/com/pasich/encly"), + File("app/src/main/java/com/pasich/encly"), + ).first { it.isDirectory } + + private fun File.relative() = relativeTo(sourceRoot).invariantSeparatorsPath + + private val logicFiles: List = LOGIC_PACKAGES.flatMap { pkg -> + File(sourceRoot, pkg).walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() + } + + private val checkedFiles = logicFiles.filter { it.relative() !in ADAPTER_FILES } + + /** 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 = when { + ALLOWED_ANDROIDX.any { import.startsWith(it) } -> false + else -> FORBIDDEN_PREFIXES.any { import.startsWith(it) } + } + + @Test + fun theSourcesWereFound() { + assertTrue("no logic sources under $sourceRoot", logicFiles.size > 80) + assertTrue(ADAPTER_FILES.keys.all { File(sourceRoot, it).isFile }) + } + + @Test + fun noImportReachesOntoAndroidOrTheJvm() { + val offenders = checkedFiles.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("move the platform call behind an interface and an adapter file", emptyList(), offenders) + } + + @Test + fun noCodeNamesThePlatformWithoutAnImport() { + val offenders = checkedFiles.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("the logic may not use the JVM or Android directly", emptyList(), offenders) + } + + @Test + fun noLogicFileReferencesATypeOnlyAnAdapterDeclares() { + val adapterNames = ADAPTER_FILES.keys.flatMap { declaredNames(File(sourceRoot, it), typesOnly = true) }.toSet() + val logicNames = sourceRoot.walkTopDown() + .filter { it.isFile && it.extension == "kt" && it.relative() !in ADAPTER_FILES } + .flatMap { declaredNames(it, typesOnly = true) }.toSet() + val adapterOnly = adapterNames - logicNames + assertTrue("the adapters declare nothing?", adapterOnly.size > 10) + // Crypto is the facade over the JCA seam that was isolated before this check existed. + val offenders = checkedFiles.filter { it.relative() != "core/crypto/Crypto.kt" }.flatMap { file -> + file.codeLines().flatMap { (number, line) -> + adapterOnly.filter { Regex("""\b$it\b""").containsMatchIn(line) } + .map { "${file.relative()}:$number names $it (declared by an adapter)" } + } + } + assertEquals("the logic may only name the interfaces", emptyList(), offenders) + } + + @Test + fun theAdapterListHoldsOnlyWhatIsStillNeeded() { + ADAPTER_FILES.forEach { (path, reason) -> + val file = File(sourceRoot, path) + assertTrue("$path: write why it is an adapter", reason.length > 10) + val needsIt = file.codeLines().any { (_, line) -> + val import = IMPORT.matchEntire(line.trim())?.groupValues?.get(1) + (import != null && forbidden(import)) || PLATFORM_USE.containsMatchIn(line) + } + assertTrue("$path no longer touches the platform: take it off the list", needsIt) + } + } + + @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.compose.ui.res.stringResource")) + assertFalse(forbidden("androidx.room.Dao")) + assertFalse(forbidden("androidx.lifecycle.ViewModel")) + 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) {")) + assertTrue(PLATFORM_USE.containsMatchIn("Normalizer.normalize(a, Normalizer.Form.NFC)")) + assertTrue(PLATFORM_USE.containsMatchIn("val c = Character.isISOControl(cp)")) + assertFalse(PLATFORM_USE.containsMatchIn("val t = Clock.System.now()")) + assertTrue(PLATFORM_USE.containsMatchIn("e.javaClass.simpleName")) + assertFalse(PLATFORM_USE.containsMatchIn("val language = Locale.javascript")) + assertEquals( + listOf("Foo", "bar"), + declaredNames("class Foo\nfun Foo.bar() = 1\n class Nested\nprivate fun hid()"), + ) + } + + private fun declaredNames(file: File, typesOnly: Boolean): List = declaredNames(file.readText(), typesOnly) + + /** + * Top-level (unindented, non-private) types, and functions unless [typesOnly], a source declares. The + * seam functions of the Jvm* files are meant to be called by name; only the types are checked. + */ + private fun declaredNames(text: String, typesOnly: Boolean = false): List = + text.lines().mapNotNull { line -> + val groups = DECLARATION.find(line)?.groupValues ?: return@mapNotNull null + (if (typesOnly) groups[1] else groups[1].ifEmpty { groups[2] }).ifEmpty { null } + } + + private companion object { + val IMPORT = Regex("""import ([\w.]+)(?: as \w+)?""") + val STRING = Regex(""""(?:[^"\\]|\\.)*"""") + val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""") + val DECLARATION = Regex( + """^(?:(?:internal|public|data|enum|sealed|abstract|open|fun)\s+)*(?:class|object|interface)\s+(\w+)""" + + """|^(?:internal\s+)?fun\s+(?:<[^>]+>\s+)?(?:[\w.]+\.)?(\w+)\(""", + ) + + // The packages, below `com.pasich.encly`, whose logic moves to common code. + val LOGIC_PACKAGES = listOf( + "domain", + "data/repository", + "data/backup", + "data/importer", + "data/export", + "data/handoff", + "data/zip", + "data/model", + "data/datasource", + "core/backup", + "core/crypto", + "core/security", + "core/common", + "core/platform", + ) + + val FORBIDDEN_PREFIXES = listOf("android.", "androidx.", "java.", "javax.", "dagger.") + + // What common code may take from androidx: Room, sqlite, DataStore, lifecycle and annotations. + val ALLOWED_ANDROIDX = listOf( + "androidx.room.", + "androidx.sqlite.", + "androidx.datastore.", + "androidx.lifecycle.", + "androidx.annotation.", + ) + + // Direct uses of the platform that need no import: FQNs, System, javaClass, R, 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""", + """(?(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/DuressPinTest.kt b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt index 4d36af1d..adf42d99 100644 --- a/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/DuressPinTest.kt @@ -5,7 +5,7 @@ import com.pasich.encly.data.database.SecureDatabaseManager import com.pasich.encly.data.database.VaultFile import com.pasich.encly.testutil.FakeLockoutClock import com.pasich.encly.testutil.FakePinFactor -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.eqValue import com.pasich.encly.testutil.tempVaultStore @@ -563,7 +563,7 @@ class DuressPinTest { 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 biometric: BiometricManager = mock(BiometricManager::class.java) @@ -581,10 +581,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(BackupManager.LAST_EXPORT_KEY, 1L) + } } } diff --git a/app/src/test/java/com/pasich/encly/core/security/MonotonicClockTest.kt b/app/src/test/java/com/pasich/encly/core/security/MonotonicClockTest.kt new file mode 100644 index 00000000..729f1cce --- /dev/null +++ b/app/src/test/java/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/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt b/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt index 12249c8e..321809e7 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SQLCipherUtilsTest.kt @@ -4,6 +4,7 @@ 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 com.pasich.encly.core.security.cipher.getDatabaseState import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith 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 index 7d6eab94..dac39553 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerLifecycleTest.kt @@ -3,7 +3,7 @@ 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.InMemoryKeyValueStore import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.tempVaultFile @@ -30,7 +30,7 @@ import java.io.File class SecurityManagerLifecycleTest { private lateinit var file: File private lateinit var store: VaultStore - private lateinit var prefs: InMemorySharedPreferences + private lateinit var prefs: InMemoryKeyValueStore private lateinit var seed: SeedPhraseManager private lateinit var auth: AuthenticationManager private lateinit var database: SecureDatabaseManager @@ -46,7 +46,7 @@ class SecurityManagerLifecycleTest { fun setUp() { file = tempVaultFile() store = VaultStore(file) - prefs = InMemorySharedPreferences() + prefs = InMemoryKeyValueStore() seed = SeedPhraseManager(store) auth = AuthenticationManager(store, FakePinFactor(), FakeLockoutClock()) database = mock(SecureDatabaseManager::class.java) @@ -194,7 +194,7 @@ class SecurityManagerLifecycleTest { committedVault() var enrolled: Boolean? = null - manager.enrollBiometric(MockActivity().activity) { enrolled = it } + manager.enrollBiometric(ActivityBiometricHost(MockActivity().activity)) { enrolled = it } assertEquals(false, enrolled) assertEquals(0, enrollCalls) @@ -304,7 +304,7 @@ class SecurityManagerLifecycleTest { slotPresent = true var result: Boolean? = null - manager.enrollBiometric(MockActivity().activity) { result = it } + manager.enrollBiometric(ActivityBiometricHost(MockActivity().activity)) { result = it } assertEquals(true, result) assertTrue(manager.isBiometricEnabled()) @@ -319,7 +319,7 @@ class SecurityManagerLifecycleTest { enrollResult = false var result: Boolean? = null - manager.enrollBiometric(MockActivity().activity) { result = it } + manager.enrollBiometric(ActivityBiometricHost(MockActivity().activity)) { result = it } assertEquals(false, result) assertFalse(manager.isBiometricEnabled()) @@ -355,7 +355,7 @@ class SecurityManagerLifecycleTest { manager.unlockWithPin(PIN.toCharArray()) val results = mutableListOf() - manager.confirmBiometric(MockActivity().activity) { results += it } + manager.confirmBiometric(ActivityBiometricHost(MockActivity().activity)) { results += it } val callback = checkNotNull(prompt) callback.onFailed() assertEquals("a single bad scan is not an answer", emptyList(), results) diff --git a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt index 83dd3637..27aeb0cb 100644 --- a/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/SecurityManagerTest.kt @@ -1,7 +1,7 @@ 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,7 +21,7 @@ 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 auth: AuthenticationManager @@ -32,7 +32,7 @@ class SecurityManagerTest { @Before fun setUp() { - prefs = InMemorySharedPreferences() + prefs = InMemoryKeyValueStore() seed = mock(SeedPhraseManager::class.java) database = mock(SecureDatabaseManager::class.java) auth = mock(AuthenticationManager::class.java) @@ -298,7 +298,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 +345,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/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/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/Utf8Test.kt b/app/src/test/java/com/pasich/encly/core/security/Utf8Test.kt new file mode 100644 index 00000000..9d4a2170 --- /dev/null +++ b/app/src/test/java/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/VaultStoreLegacyFormatTest.kt b/app/src/test/java/com/pasich/encly/core/security/VaultStoreLegacyFormatTest.kt new file mode 100644 index 00000000..b32bea56 --- /dev/null +++ b/app/src/test/java/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/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt index 83afbd32..20293dd5 100644 --- a/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/WipePinTest.kt @@ -4,7 +4,7 @@ import com.pasich.encly.data.backup.BackupManager 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.InMemoryKeyValueStore import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.tempVaultFile import com.pasich.encly.testutil.tempVaultStore @@ -672,7 +672,7 @@ 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 biometric: BiometricManager = mock(BiometricManager::class.java) @@ -686,10 +686,10 @@ class WipePinTest { 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(BackupManager.LAST_EXPORT_KEY, 1L) + } } /** The same files read by a new process. */ diff --git a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt index 55954021..7ee8540d 100644 --- a/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt +++ b/app/src/test/java/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -6,7 +6,7 @@ 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.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.test.runTest import kotlinx.io.asSource @@ -21,7 +21,7 @@ import java.io.ByteArrayInputStream class BackupManagerTest { - private lateinit var prefs: InMemorySharedPreferences + private lateinit var prefs: InMemoryKeyValueStore private lateinit var manager: BackupManager @Before @@ -32,7 +32,7 @@ class BackupManagerTest { val store = InMemoryVaultDataStore().apply { insertNote(Note(title = "Plan", value = "[]", uid = "n-plan")) } - prefs = InMemorySharedPreferences() + prefs = InMemoryKeyValueStore() manager = BackupManager(security, store, prefs).apply { clock = { 1L } } } @@ -86,7 +86,7 @@ class BackupManagerTest { } try { - BackupManager(security, store, InMemorySharedPreferences()).createBackup() + BackupManager(security, store, InMemoryKeyValueStore()).createBackup() fail("expected a closed-session failure") } catch (e: BackupException) { assertEquals(BackupError.IO, e.error) @@ -97,7 +97,7 @@ class BackupManagerTest { fun theDecoyVaultRefusesEveryBackupStepAsAStorageError() = runTest { val security = mock(SecurityManager::class.java) `when`(security.isDecoySession()).thenReturn(true) - val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()) + val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()) val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) val errors = listOf( diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt index a0849f81..fabdf338 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffFixtures.kt +++ b/app/src/test/java/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 @@ -93,4 +94,4 @@ internal object HandoffFixtures { /** 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(zip, limits) + HandoffStaging.read(Path(zip.path), limits) diff --git a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt b/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt index 3cb9a6dd..17ebac61 100644 --- a/app/src/test/java/com/pasich/encly/data/handoff/HandoffStagingTest.kt +++ b/app/src/test/java/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/importer/ImportReaderTest.kt b/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt index 68db7beb..a29f7ac9 100644 --- a/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt +++ b/app/src/test/java/com/pasich/encly/data/importer/ImportReaderTest.kt @@ -137,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") { 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 diff --git a/app/src/test/java/com/pasich/encly/data/importer/SafImportFilesTest.kt b/app/src/test/java/com/pasich/encly/data/importer/SafImportFilesTest.kt new file mode 100644 index 00000000..f76a622f --- /dev/null +++ b/app/src/test/java/com/pasich/encly/data/importer/SafImportFilesTest.kt @@ -0,0 +1,71 @@ +package com.pasich.encly.data.importer + +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.ParcelFileDescriptor +import androidx.test.core.app.ApplicationProvider +import kotlinx.io.readByteArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +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 +import java.io.File + +/** + * The Android side of the import's file access. The import logic knows no `SecurityException`: + * a provider that refuses a file must reach it as an unreadable one (`open` returns null), the + * same as a file that is gone. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class SafImportFilesTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + class DenyingProvider : 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 { + if (u.lastPathSegment == "denied") throw SecurityException("not granted") + val file = File.createTempFile("saf", null).apply { writeBytes("hello".toByteArray()) } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + } + + private fun open(name: String): kotlinx.io.RawSource? { + Robolectric.buildContentProvider(DenyingProvider::class.java).create(AUTHORITY) + val uri = Uri.parse("content://$AUTHORITY/$name") + return SafImportFiles(context).files(listOf(uri)).files().single().open() + } + + @Test + fun aRefusedFileIsUnreadableNotAnException() { + assertNull(open("denied")) + } + + @Test + fun aGrantedFileIsReadThroughTheStream() { + val source = open("granted") + + assertNotNull(source) + val buffer = kotlinx.io.Buffer() + source!!.use { it.readAtMostTo(buffer, Long.MAX_VALUE) } + assertEquals("hello", buffer.readByteArray().decodeToString()) + } + + private companion object { + const val AUTHORITY = "com.pasich.encly.test.saf" + } +} diff --git a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt b/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt index a098f2c1..b3311f4f 100644 --- a/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt +++ b/app/src/test/java/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt @@ -13,7 +13,7 @@ import com.pasich.encly.data.backup.BackupMapper import com.pasich.encly.data.backup.PendingRestore import com.pasich.encly.data.model.Note 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 @@ -50,7 +50,7 @@ class RestoreFromBackupTest { true } store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemorySharedPreferences()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) pending = PendingRestore(backupManager) useCase = OnboardingUseCase(security, backupManager, pending) } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt index 54aacd7c..82c5d7ec 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt @@ -7,7 +7,7 @@ import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupMapper 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 +48,7 @@ class AuthSetupViewModelTest { true } store = InMemoryVaultDataStore() - pending = PendingRestore(BackupManager(security, store, InMemorySharedPreferences())) + pending = PendingRestore(BackupManager(security, store, InMemoryKeyValueStore())) 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/BackupFlowsTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 96fbb030..24287096 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -17,7 +17,7 @@ import com.pasich.encly.data.model.Tag import com.pasich.encly.data.model.Task 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 @@ -57,7 +57,7 @@ class BackupFlowsTest { 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 @@ -86,7 +86,7 @@ class BackupFlowsTest { 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) } @@ -422,16 +422,14 @@ 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), + phraseSetup = BackupPhraseSetup(security), + securityManager = security, + sessionLockManager = SessionLockManager(security), + documents = documents, + ) private fun state() = viewModel.uiState.value diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt index 649cdc7a..9448de20 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -7,7 +7,7 @@ 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.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.anyCharArray import kotlinx.coroutines.Dispatchers @@ -38,7 +38,7 @@ class BackupViewModelTest { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) viewModel = BackupViewModel( - backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()), + backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()), phraseSetup = BackupPhraseSetup(security), securityManager = security, sessionLockManager = SessionLockManager(security), diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt index 3da0dc0a..55a14ab7 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt @@ -12,7 +12,7 @@ import com.pasich.encly.data.backup.BackupManager 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.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -72,7 +72,7 @@ class ImportFromMyNotesViewModelTest { context = context, securityManager = security, sessionLockManager = sessionLock, - backupManager = BackupManager(security, store, InMemorySharedPreferences()), + backupManager = BackupManager(security, store, InMemoryKeyValueStore()), ioDispatcher = dispatcher, savedStateHandle = savedState, ) 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 index fd4f138f..969d8c62 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore import androidx.lifecycle.viewmodel.CreationExtras +import com.pasich.encly.core.security.ActivityBiometricHost import com.pasich.encly.core.security.AuthStrategy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager @@ -195,7 +196,9 @@ class LockViewModelTest { @Test fun aBiometricKeyUnlocksAndIsWiped() = runTest { val key = ByteArray(KEY_LENGTH) { 7 } - answerCallback(key).`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) + answerCallback( + key, + ).`when`(security).requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(true) assertTrue(biometric()) @@ -204,12 +207,14 @@ class LockViewModelTest { @Test fun aCancelledPromptOrARejectedKeyDoesNotUnlock() = runTest { - answerCallback(null).`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) + answerCallback( + null, + ).`when`(security).requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) assertFalse(biometric()) verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) answerCallback(ByteArray(KEY_LENGTH)).`when`(security) - .requestBiometricKey(eqValue(activity), anyCallback()) + .requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) `when`(security.unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean())).thenReturn(false) assertFalse(biometric()) } @@ -220,7 +225,7 @@ class LockViewModelTest { viewModel.authenticateBiometric(activity) {} viewModel.authenticateBiometric(activity) {} - verify(security, times(1)).requestBiometricKey(eqValue(activity), anyCallback()) + verify(security, times(1)).requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) assertTrue(viewModel.biometricInFlight) } @@ -236,7 +241,7 @@ class LockViewModelTest { assertFalse(viewModel.biometricInFlight) val recreated = MockActivity().activity viewModel.authenticateBiometric(recreated) {} - verify(security).requestBiometricKey(eqValue(recreated), anyCallback()) + verify(security).requestBiometricKey(eqValue(ActivityBiometricHost(recreated)), anyCallback()) } @Test @@ -246,7 +251,7 @@ class LockViewModelTest { @Suppress("UNCHECKED_CAST") answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit null - }.`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) + }.`when`(security).requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) val results = mutableListOf() viewModel.authenticateBiometric(activity) { results += it } host.destroy() @@ -265,7 +270,7 @@ class LockViewModelTest { viewModel.authenticateBiometric(activity) {} - verify(security, never()).requestBiometricKey(eqValue(activity), anyCallback()) + verify(security, never()).requestBiometricKey(eqValue(ActivityBiometricHost(activity)), anyCallback()) assertFalse(viewModel.biometricInFlight) } 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 index 99c5f06e..e30babec 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt @@ -3,13 +3,14 @@ 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.security.ActivityBiometricHost 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.PendingRestore import com.pasich.encly.domain.usecase.OnboardingUseCase -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyCallback @@ -145,19 +146,19 @@ class OnboardingFailuresTest { assertFalse(vm.uiState.value.biometricPending) assertFalse(vm.uiState.value.biometricEnabled) - verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, never()).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) } @Test fun theFingerprintPromptIsShownOnceAndItsResultKept() = runTest { - answerCallback(true).`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + answerCallback(true).`when`(security).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) val vm = readyWithBiometricPending() vm.enrollBiometric(activity) vm.enrollBiometric(activity) assertTrue(vm.uiState.value.biometricEnabled) - verify(security).enrollBiometric(eqValue(activity), anyCallback()) + verify(security).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) } @Test @@ -194,7 +195,7 @@ class OnboardingFailuresTest { private fun viewModel(biometric: Boolean = false): OnboardingViewModel { `when`(security.biometricAvailable()).thenReturn(biometric) - val backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemorySharedPreferences()) + val backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()) return OnboardingViewModel( OnboardingUseCase(security, backupManager, PendingRestore(backupManager)), mock(BackupDocuments::class.java), diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt index 6235952e..0b749aa7 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt @@ -15,7 +15,7 @@ import com.pasich.encly.data.backup.BackupMapper 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.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.anyCharArray import kotlinx.coroutines.Dispatchers @@ -78,7 +78,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()) pending = PendingRestore(backupManager) return OnboardingViewModel(OnboardingUseCase(security, backupManager, pending), documents) } diff --git a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index ded7acf2..2b26ddfd 100644 --- a/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/app/src/test/java/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -3,6 +3,7 @@ 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.security.ActivityBiometricHost import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock @@ -12,7 +13,7 @@ import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.WipePinChange -import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyCallback @@ -61,8 +62,8 @@ class SecuritySettingsViewModelTest { viewModel = SecuritySettingsViewModel( security, - KeyboardPrivacy(InMemorySharedPreferences()), - AutoLock(InMemorySharedPreferences()), + KeyboardPrivacy(InMemoryKeyValueStore()), + AutoLock(InMemoryKeyValueStore()), ) // The first load reads the mock on an IO thread; stubbing it meanwhile would race. runBlocking { viewModel.uiState.first { it.loaded } } @@ -247,7 +248,7 @@ 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(ActivityBiometricHost(activity)), anyCallback()) `when`(security.isBiometricEnabled()).thenReturn(true) viewModel.toggleBiometric(activity, enable = true) @@ -259,7 +260,7 @@ 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(ActivityBiometricHost(activity)), anyCallback()) viewModel.toggleBiometric(activity, enable = true) @@ -270,7 +271,7 @@ class SecuritySettingsViewModelTest { @Test fun disablingBiometricsNeedsAConfirmedPrompt() = runTest { viewModel.uiState.first { it.loaded } - answerCallback(true).`when`(security).confirmBiometric(eqValue(activity), anyCallback()) + answerCallback(true).`when`(security).confirmBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) viewModel.toggleBiometric(activity, enable = false) @@ -281,7 +282,7 @@ 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(ActivityBiometricHost(activity)), anyCallback()) `when`(security.isBiometricEnabled()).thenReturn(true) viewModel.toggleBiometric(activity, enable = false) @@ -300,8 +301,8 @@ class SecuritySettingsViewModelTest { viewModel.toggleBiometric(activity, 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(ActivityBiometricHost(activity)), anyCallback()) + verify(security, never()).confirmBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) } @Test @@ -311,8 +312,8 @@ class SecuritySettingsViewModelTest { viewModel.toggleBiometric(activity, enable = false) viewModel.toggleBiometric(activity, enable = true) - verify(security, times(1)).confirmBiometric(eqValue(activity), anyCallback()) - verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, times(1)).confirmBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) + verify(security, never()).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) verify(security, never()).disableBiometric() } @@ -324,7 +325,7 @@ class SecuritySettingsViewModelTest { @Suppress("UNCHECKED_CAST") answer[0] = invocation.arguments.last() as (Boolean) -> Unit null - }.`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + }.`when`(security).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) viewModel.toggleBiometric(activity, enable = true) viewModel.toggleBiometric(activity, enable = true) @@ -332,7 +333,7 @@ class SecuritySettingsViewModelTest { assertFalse(viewModel.biometricInFlight) viewModel.toggleBiometric(activity, enable = true) - verify(security, times(2)).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, times(2)).enrollBiometric(eqValue(ActivityBiometricHost(activity)), anyCallback()) } @Test @@ -347,7 +348,7 @@ class SecuritySettingsViewModelTest { assertFalse(viewModel.biometricInFlight) val recreated = MockActivity().activity viewModel.toggleBiometric(recreated, enable = true) - verify(security).enrollBiometric(eqValue(recreated), anyCallback()) + verify(security).enrollBiometric(eqValue(ActivityBiometricHost(recreated)), anyCallback()) } private suspend fun checkCurrent(pin: String): Boolean = checkCurrent(pin.toCharArray()) 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/ui/screens/LockScreenRecreationTest.kt b/app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt index 8664710a..5ee62e5f 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt @@ -13,6 +13,8 @@ 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.ActivityBiometricHost +import com.pasich.encly.core.security.BiometricHost import com.pasich.encly.domain.model.ThemeSettings import com.pasich.encly.presentation.navigation.NavRoutes import com.pasich.encly.presentation.screen.LockScreen @@ -81,7 +83,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(ActivityBiometricHost(first)), anyCallback()) checkNotNull(scenario).recreate() val second = currentActivity() @@ -90,7 +92,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(ActivityBiometricHost(second)), anyCallback()) assertTrue(lock.biometricInFlight) } diff --git a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt index fbd512a8..0ecc5531 100644 --- a/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt +++ b/app/src/test/java/com/pasich/encly/ui/screens/TestApp.kt @@ -6,7 +6,7 @@ import androidx.lifecycle.SavedStateHandle 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.AndroidAppStrings import com.pasich.encly.core.security.AuthSettings import com.pasich.encly.core.security.AuthType import com.pasich.encly.core.security.AutoLock @@ -58,7 +58,7 @@ 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.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore import com.pasich.encly.testutil.TestNotesRepository import com.pasich.encly.testutil.anyCharArray @@ -83,7 +83,7 @@ internal class TestApp(context: Context) { val documents: BackupDocuments = mock(BackupDocuments::class.java) val markdownDocuments: MarkdownDocuments = mock(MarkdownDocuments::class.java) val store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemorySharedPreferences()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) val sessionLock = SessionLockManager(security) val selectedTag = SelectedTagHolder() init { @@ -202,8 +202,8 @@ internal class TestApp(context: Context) { fun securitySettings() = SecuritySettingsViewModel( security, - KeyboardPrivacy(InMemorySharedPreferences()), - AutoLock(InMemorySharedPreferences()), + KeyboardPrivacy(InMemoryKeyValueStore()), + AutoLock(InMemoryKeyValueStore()), ) fun backup() = BackupViewModel( @@ -219,7 +219,7 @@ internal class TestApp(context: Context) { sessionLockManager = sessionLock, store = store, documents = markdownDocuments, - strings = AppStrings(appContext), + strings = AndroidAppStrings(appContext), ) private val pendingRestore by lazy { PendingRestore(backupManager) } diff --git a/docs/architecture.md b/docs/architecture.md index 733b4e26..81248c8f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,6 +67,16 @@ a note is saved only when the user edits it. Smart Paste reads pasted plain text editor's `MarkdownBlockMapper` (shared with the importers) in its paste mode (nothing rewritten); the reading density (`NoteLineSpacing`) is presentation only. +## Platform boundary + +The domain and data logic (`domain`, `data/repository|backup|importer|export|handoff|zip`, `core/backup`, +`core/crypto`, `core/security`, `core/common`, `core/platform`) imports nothing of Android and nothing of the +JVM, so it can move into Kotlin Multiplatform modules (`PlatformBoundaryTest`). What it needs from the +platform is an interface it declares, implemented in one adapter file listed with its reason in that +test: `KeyValueStore` (flags), `VaultFileIo` (the vault file's fsync and atomic replace), `BiometricManager` +and `BiometricHost`, `PinHardwareFactor`, `LockoutClock`, `DeviceLockWatcher`, `MainThreadTimer`, `AppStrings`. +The adapters are bound in `core/di`. + ## Navigation Startup resolves vault state before rendering the main graph. Locked sessions route to the lock From 7b7518bafb170a393d03584e253b844bcb44f57d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 13:14:46 +0200 Subject: [PATCH 11/75] build: the block editor as a Kotlin Multiplatform module with convention 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. --- .github/workflows/android.yml | 46 + CONTRIBUTING.md | 3 +- README.md | 1 + app/build.gradle.kts | 79 +- app/detekt-baseline.xml | 7 - .../encly/editor/model/LinkDataBlock.kt | 9 - .../presentation/dialogs/BlockSheetsTest.kt | 210 ---- .../screen/editnote/EditorBindingsTest.kt | 53 + .../encly/release/CodeQualityToolingTest.kt | 7 +- .../pasich/encly/release/NoDpLiteralsTest.kt | 3 - build-logic/.gitignore | 1 + build-logic/build.gradle.kts | 16 + build-logic/settings.gradle.kts | 14 + .../src/main/kotlin/encly.compose.gradle.kts | 25 + .../main/kotlin/encly.kmp-library.gradle.kts | 39 + .../src/main/kotlin/encly.quality.gradle.kts | 32 + .../src/main/kotlin/encly.spotless.gradle.kts | 30 + build.gradle.kts | 28 +- docs/architecture.md | 5 +- editor/.gitignore | 1 + editor/Module.md | 67 ++ editor/README.md | 96 ++ editor/api/android/editor.api | 900 ++++++++++++++ editor/api/desktop/editor.api | 900 ++++++++++++++ editor/api/editor.klib.api | 1047 +++++++++++++++++ editor/build.gradle.kts | 126 ++ editor/config/detekt.yml | 26 + editor/detekt-baseline.xml | 13 + .../pasich/encly/editor/EditorServicesTest.kt | 12 +- .../encly/editor/focus/FocusBranchTest.kt | 8 +- .../encly/editor/focus/FocusGapsTest.kt | 12 +- .../encly/editor/ui/EditorBlocksUiTest.kt | 8 +- .../encly/editor/ui/EditorGapsUiTest.kt | 6 +- .../encly/editor/ui/TapBelowBlocksTest.kt | 4 +- .../editor/ui/blocks/ChecklistLayoutTest.kt | 24 +- .../encly/editor/ui/blocks/EditorDrawTest.kt | 8 +- .../editor/ui/blocks/SeparatorBlockTest.kt | 4 +- .../editor/ui/sheets/EditorBlockSheetTest.kt | 6 +- .../editor/ui/blocks/AsciiHost.android.kt | 5 + .../com/pasich/encly/editor/EditorServices.kt | 45 +- .../com/pasich/encly/editor/EditorStrings.kt | 10 +- .../encly/editor/codec/BlockConverter.kt | 8 +- .../pasich/encly/editor/codec/BlockJson.kt | 0 .../pasich/encly/editor/codec/ImportBlocks.kt | 15 +- .../pasich/encly/editor/codec/InlineHtml.kt | 22 +- .../encly/editor/codec/MarkdownBlockMapper.kt | 8 +- .../encly/editor/focus/BlockFocusRegistry.kt | 24 +- .../encly/editor/focus/CaretGeometry.kt | 14 +- .../encly/editor/focus/KeyboardUtils.kt | 2 +- .../com/pasich/encly/editor/model/Block.kt | 30 +- .../pasich/encly/editor/model/BlockFactory.kt | 4 +- .../pasich/encly/editor/model/BlockUtils.kt | 2 +- .../encly/editor/model/ItemListBlock.kt | 6 +- .../encly/editor/model/LinkDataBlock.kt | 13 + .../pasich/encly/editor/model/TextualBlock.kt | 7 +- .../encly/editor/state/BlockDuplicate.kt | 4 +- .../pasich/encly/editor/state/BlockEditing.kt | 8 +- .../encly/editor/state/BlockEditorState.kt | 48 +- .../encly/editor/state/BlockEditorToolbar.kt | 24 +- .../encly/editor/state/BlockOperations.kt | 2 +- .../encly/editor/state/BlockSelection.kt | 38 +- .../pasich/encly/editor/state/BlockTools.kt | 12 +- .../pasich/encly/editor/state/CaretMove.kt | 8 +- .../pasich/encly/editor/state/CaretTarget.kt | 0 .../pasich/encly/editor/state/ListEditing.kt | 6 +- .../com/pasich/encly/editor/state/NoteFind.kt | 39 +- .../encly/editor/state/NoteTemplates.kt | 16 +- .../pasich/encly/editor/state/PasteEditing.kt | 0 .../com/pasich/encly/editor/state/Shortcut.kt | 16 +- .../encly/editor/state/SlashCommands.kt | 35 +- .../pasich/encly/editor/state/SmartPaste.kt | 20 +- .../pasich/encly/editor/state/TextEdits.kt | 18 +- .../pasich/encly/editor/theme/EditorIcons.kt | 44 +- .../pasich/encly/editor/theme/EditorTheme.kt | 18 +- .../pasich/encly/editor/theme/NoteSpacing.kt | 10 +- .../pasich/encly/editor/ui/BlockActions.kt | 4 +- .../encly/editor/ui/BlockActionsImpl.kt | 0 .../pasich/encly/editor/ui/BlockFocusHooks.kt | 4 +- .../encly/editor/ui/EditorBlocksHost.kt | 12 +- .../encly/editor/ui/EditorController.kt | 60 +- .../pasich/encly/editor/ui/FindHighlights.kt | 6 +- .../pasich/encly/editor/ui/TapBelowBlocks.kt | 23 +- .../encly/editor/ui/blocks/AsciiHost.kt | 11 +- .../encly/editor/ui/blocks/BlockInput.kt | 0 .../encly/editor/ui/blocks/FieldHighlight.kt | 0 .../pasich/encly/editor/ui/blocks/HBlock.kt | 7 +- .../encly/editor/ui/blocks/LineMetrics.kt | 0 .../encly/editor/ui/blocks/LinkBlock.kt | 2 +- .../encly/editor/ui/blocks/ListBlock.kt | 2 +- .../pasich/encly/editor/ui/blocks/NoteLink.kt | 4 +- .../pasich/encly/editor/ui/blocks/Punycode.kt | 121 ++ .../encly/editor/ui/blocks/QuoteBlock.kt | 2 +- .../encly/editor/ui/blocks/SeparatorBlock.kt | 2 +- .../encly/editor/ui/blocks/SlashMenuPopup.kt | 4 +- .../encly/editor/ui/blocks/StrikeSegment.kt | 0 .../encly/editor/ui/blocks/TextBlock.kt | 4 +- .../encly/editor/ui/blocks/TextBlockKeys.kt | 0 .../pasich/encly/editor/ui/blocks/TextSync.kt | 2 +- .../editor/ui/components/CommandMenuItem.kt | 4 +- .../editor/ui/components/EditorControls.kt | 6 +- .../encly/editor/ui/components/EditorSheet.kt | 6 +- .../editor/ui/components/EditorTextField.kt | 2 +- .../editor/ui/sheets/ActionBlockDialog.kt | 2 +- .../editor/ui/sheets/ActionLinkBottomSheet.kt | 2 +- .../ui/sheets/ActionOtherBottomSheet.kt | 4 +- .../encly/editor/ui/sheets/BlockEditRows.kt | 0 .../encly/editor/ui/sheets/BlockSheetKind.kt | 4 +- .../editor/ui/sheets/SettingsBlockDialog.kt | 2 +- .../encly/editor/codec/BlockConverterTest.kt | 14 +- .../encly/editor/codec/BlockJsonFormatTest.kt | 8 +- .../encly/editor/codec/InlineHtmlTest.kt | 14 +- .../editor/codec/MarkdownBlockMapperTest.kt | 35 +- .../editor/focus/BlockFocusRegistryTest.kt | 12 +- .../encly/editor/focus/CaretGeometryTest.kt | 8 +- .../encly/editor/focus/KeyboardUtilsTest.kt | 8 +- .../encly/editor/model/BlockFactoryTest.kt | 12 +- .../pasich/encly/editor/model/BlockIdTest.kt | 8 +- .../encly/editor/state/BlockActionsTest.kt | 10 +- .../encly/editor/state/BlockEditingTest.kt | 14 +- .../editor/state/BlockEditorStateTest.kt | 10 +- .../encly/editor/state/BlockOperationsTest.kt | 8 +- .../encly/editor/state/BranchGapsMoreTest.kt | 12 +- .../encly/editor/state/BranchGapsTest.kt | 20 +- .../encly/editor/state/ChecklistLockTest.kt | 8 +- .../encly/editor/state/ContentChangesTest.kt | 6 +- .../encly/editor/state/ConvertCaretTest.kt | 4 +- .../encly/editor/state/MarkdownLinesTest.kt | 10 +- .../pasich/encly/editor/state/NoteFindTest.kt | 10 +- .../encly/editor/state/NoteJsonGoldenTest.kt | 4 +- .../encly/editor/state/NoteTemplatesTest.kt | 20 +- .../encly/editor/state/SlashCommandsTest.kt | 14 +- .../encly/editor/state/SmartPasteTest.kt | 6 +- .../encly/editor/state/StateGapsTest.kt | 10 +- .../encly/editor/state/TextEditsTest.kt | 10 +- .../encly/editor/state/UndoCaretTest.kt | 16 +- .../encly/editor/theme/NoteSpacingTest.kt | 4 +- .../encly/editor/ui/BlockActionsImplTest.kt | 10 +- .../pasich/encly/editor/ui/StateController.kt | 0 .../encly/editor/ui/blocks/LinkUrlTest.kt | 18 +- .../encly/editor/ui/blocks/PunycodeTest.kt | 84 ++ .../encly/editor/ui/blocks/TextSyncTest.kt | 8 +- .../editor/ui/blocks/AsciiHost.desktop.kt | 5 + .../pasich/encly/editor/EditorBoundaryTest.kt | 57 +- .../encly/editor/ui/blocks/AsciiHost.ios.kt | 3 + gradle/libs.versions.toml | 13 + settings.gradle.kts | 2 + 146 files changed, 4366 insertions(+), 908 deletions(-) delete mode 100644 app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt delete mode 100644 app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt create mode 100644 app/src/test/java/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt create mode 100644 build-logic/.gitignore create mode 100644 build-logic/build.gradle.kts create mode 100644 build-logic/settings.gradle.kts create mode 100644 build-logic/src/main/kotlin/encly.compose.gradle.kts create mode 100644 build-logic/src/main/kotlin/encly.kmp-library.gradle.kts create mode 100644 build-logic/src/main/kotlin/encly.quality.gradle.kts create mode 100644 build-logic/src/main/kotlin/encly.spotless.gradle.kts create mode 100644 editor/.gitignore create mode 100644 editor/Module.md create mode 100644 editor/README.md create mode 100644 editor/api/android/editor.api create mode 100644 editor/api/desktop/editor.api create mode 100644 editor/api/editor.klib.api create mode 100644 editor/build.gradle.kts create mode 100644 editor/config/detekt.yml create mode 100644 editor/detekt-baseline.xml rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/EditorServicesTest.kt (95%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/focus/FocusBranchTest.kt (97%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/focus/FocusGapsTest.kt (97%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt (98%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/EditorGapsUiTest.kt (98%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt (97%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt (91%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt (98%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt (95%) rename {app/src/test/java => editor/src/androidHostTest/kotlin}/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt (98%) create mode 100644 editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.android.kt rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/EditorServices.kt (70%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/EditorStrings.kt (91%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/codec/BlockConverter.kt (88%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/codec/BlockJson.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/codec/ImportBlocks.kt (57%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/codec/InlineHtml.kt (74%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt (96%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/focus/BlockFocusRegistry.kt (83%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/focus/CaretGeometry.kt (84%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/focus/KeyboardUtils.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/model/Block.kt (71%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/model/BlockFactory.kt (89%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/model/BlockUtils.kt (95%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/model/ItemListBlock.kt (65%) create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/model/LinkDataBlock.kt rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/model/TextualBlock.kt (57%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockDuplicate.kt (93%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockEditing.kt (96%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockEditorState.kt (84%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockEditorToolbar.kt (69%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockOperations.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockSelection.kt (82%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/BlockTools.kt (93%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/CaretMove.kt (80%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/CaretTarget.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/ListEditing.kt (95%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/NoteFind.kt (76%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/NoteTemplates.kt (87%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/PasteEditing.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/Shortcut.kt (88%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/SlashCommands.kt (86%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/SmartPaste.kt (68%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/state/TextEdits.kt (84%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/theme/EditorIcons.kt (78%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/theme/EditorTheme.kt (87%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/theme/NoteSpacing.kt (89%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/BlockActions.kt (98%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/BlockActionsImpl.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/BlockFocusHooks.kt (81%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/EditorBlocksHost.kt (97%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/EditorController.kt (62%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/FindHighlights.kt (74%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/TapBelowBlocks.kt (62%) rename app/src/main/java/com/pasich/encly/editor/ui/blocks/IdnShim.kt => editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.kt (53%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/BlockInput.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/HBlock.kt (96%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/LineMetrics.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/LinkBlock.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/ListBlock.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/NoteLink.kt (97%) create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/Punycode.kt rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/QuoteBlock.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/SeparatorBlock.kt (94%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt (98%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/StrikeSegment.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/TextBlock.kt (97%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/TextBlockKeys.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/blocks/TextSync.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/components/CommandMenuItem.kt (97%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/components/EditorControls.kt (95%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/components/EditorSheet.kt (98%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/components/EditorTextField.kt (99%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/ActionBlockDialog.kt (88%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/ActionLinkBottomSheet.kt (98%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt (93%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/BlockEditRows.kt (100%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/BlockSheetKind.kt (97%) rename {app/src/main/java => editor/src/commonMain/kotlin}/com/pasich/encly/editor/ui/sheets/SettingsBlockDialog.kt (88%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/codec/BlockConverterTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/codec/InlineHtmlTest.kt (78%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt (81%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/focus/BlockFocusRegistryTest.kt (95%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/focus/CaretGeometryTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/focus/KeyboardUtilsTest.kt (97%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/model/BlockFactoryTest.kt (89%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/model/BlockIdTest.kt (64%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BlockActionsTest.kt (97%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BlockEditingTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BlockEditorStateTest.kt (99%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BlockOperationsTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BranchGapsMoreTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/BranchGapsTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/ChecklistLockTest.kt (95%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/ContentChangesTest.kt (95%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/ConvertCaretTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/MarkdownLinesTest.kt (93%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/NoteFindTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/NoteJsonGoldenTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/NoteTemplatesTest.kt (87%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/SlashCommandsTest.kt (96%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/SmartPasteTest.kt (99%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/StateGapsTest.kt (98%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/TextEditsTest.kt (94%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/state/UndoCaretTest.kt (94%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/theme/NoteSpacingTest.kt (95%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/ui/BlockActionsImplTest.kt (97%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/ui/StateController.kt (100%) rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/ui/blocks/LinkUrlTest.kt (85%) create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/PunycodeTest.kt rename {app/src/test/java => editor/src/commonTest/kotlin}/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt (91%) create mode 100644 editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.desktop.kt rename {app/src/test/java => editor/src/desktopTest/kotlin}/com/pasich/encly/editor/EditorBoundaryTest.kt (61%) create mode 100644 editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHost.ios.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e9da0f95..938b518c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -57,9 +57,20 @@ jobs: # 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; the instrumented tests # (Room migrations, SQLCipher) run in the `instrumented` job below. + # + # The :editor library (Kotlin Multiplatform) is checked on its desktop JVM and Android host + # targets here: unit tests, the 100 % Kover gates, detekt, the public API dump and Dokka. Its iOS + # targets compile in the `editor-ios` job below. - name: Format, unit tests, detekt, release build run: >- ./gradlew spotlessCheck + :editor:desktopTest + :editor:testAndroidHostTest + :editor:koverVerify + :editor:koverVerifyEditorLogic + :editor:detekt + :editor:checkKotlinAbi + :editor:dokkaGeneratePublicationHtml :app:testFdroidDebugUnitTest :app:koverXmlReportFdroidDebug :app:koverVerifyFdroidDebug @@ -113,9 +124,44 @@ jobs: path: | app/build/reports/ app/build/test-results/ + editor/build/reports/ + editor/build/test-results/ retention-days: 7 if-no-files-found: ignore + # The :editor library also targets iOS. Kotlin/Native compiles for Apple targets only on macOS, so + # this job builds the iOS klibs there; it runs no iOS tests (the logic is covered on the JVM). + editor-ios: + if: github.actor != 'dependabot[bot]' + name: editor (iOS compile) + runs-on: macos-15 + timeout-minutes: 45 + 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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + - name: Compile the iOS targets + run: >- + ./gradlew + :editor:compileKotlinIosSimulatorArm64 + :editor:compileKotlinIosArm64 + + # 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 is really checked. + - name: Public API dump + run: ./gradlew :editor:checkKotlinAbi + # 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. On the oldest supported # API and a current one. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44fdaf30..01eed11b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,8 @@ 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 :app:lintFdroidRelease :app:lintPlayRelease # release lint ./gradlew assembleRelease # R8 build ``` diff --git a/README.md b/README.md index f76b49ab..531bc17a 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ links, no baseline profile) and `play`. ./gradlew assembleFdroidDebug # debug APK (com.pasich.encly.debug) ./gradlew :app:testFdroidDebugUnitTest # JVM unit tests ./gradlew :app:koverVerifyFdroidDebug # coverage gate (whole app, 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8fa51033..374f0fe7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,9 +4,6 @@ import java.util.Properties // Minimum line coverage (%) of the whole app (generated code excluded) enforced by koverVerify. val koverMinLineCoverage = 85 -// Minimum line and branch coverage (%) of the editor package, enforced by koverVerifyEditor. -val editorMinCoverage = 100 - // Derive the app version from the tracked root `version.properties` so versionName and // versionCode always stay in sync and only one file needs bumping per release. val versionProps = Properties().apply { @@ -45,17 +42,15 @@ plugins { alias(libs.plugins.ksp) 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") } @@ -230,19 +225,10 @@ tasks.configureEach { } } -// The app-wide gate also runs the editor's: one command checks both. -tasks.matching { it.name == "koverVerifyFdroidDebug" }.configureEach { - dependsOn("koverVerifyEditor", "koverVerifyEditorLogic") -} - // 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. tasks.withType().configureEach { - // EditorBoundaryTest reads the editor's sources. - inputs.dir(file("src/main/java/com/pasich/encly/editor")) - .withPropertyName("editorSources") - .withPathSensitivity(PathSensitivity.RELATIVE) inputs.dir(rootProject.file("fastlane/metadata/android")) .withPropertyName("storeMetadata") .withPathSensitivity(PathSensitivity.RELATIVE) @@ -258,6 +244,7 @@ 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) } @@ -265,61 +252,7 @@ tasks.withType().configureEach { // Coverage: `./gradlew :app:koverXmlReportFdroidDebug :app:koverVerifyFdroidDebug`. // The gate is scoped to core/security, the code that guards the key hierarchy. kover { - // A report variant of its own for the note editor (package com.pasich.encly.editor), built from - // the fdroid debug classes and tests, so its gate below can differ from the app-wide one. - currentProject { - createVariant("editor") { - add("fdroidDebug") - } - createVariant("editorLogic") { - add("fdroidDebug") - } - } reports { - // The editor is a self-contained library in waiting: every line of it is covered by its own - // tests (EditorBoundaryTest guards the other half of that contract). - variant("editor") { - filters { - includes { - packages("com.pasich.encly.editor") - } - excludes { - classes("*ComposableSingletons*") - } - } - verify { - rule("Editor line coverage") { - bound { - minValue = editorMinCoverage - coverageUnits = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.LINE - } - } - } - } - // Branches are gated on the editor's logic (model, codec, state, focus, theme). The branches - // of @Composable functions are not: the Compose compiler adds `$changed`/`$default` checks - // to every one of them, which no test can take both ways. - 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 = kotlinx.kover.gradle.plugin.dsl.CoverageUnit.BRANCH - } - } - } - } variant("fdroidDebug") { filters { // Code the compilers and annotation processors write, not this project. @@ -348,6 +281,8 @@ kover { } dependencies { + // The block editor (Kotlin Multiplatform library); the app binds its extension points. + implementation(project(":editor")) // Jetpack Compose: every androidx.compose module takes its version from the BOM. val composeBom = platform(libs.androidx.compose.bom) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 7cddd569..e4e09d36 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -2,23 +2,19 @@ - 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 = 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: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 = metroViewModel(), 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 = metroViewModel(), ) 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 = 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: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 = metroViewModel(), settingsViewModel: SettingsViewModel = metroViewModel(), noteListViewModel: NoteListViewModel = metroViewModel(), ) 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, ) @@ -92,8 +88,6 @@ 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 @@ -134,7 +128,6 @@ 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 diff --git a/app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt b/app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt deleted file mode 100644 index 83c36182..00000000 --- a/app/src/main/java/com/pasich/encly/editor/model/LinkDataBlock.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.pasich.encly.editor.model - -data class LinkDataBlock( - var title: String = "", - var imageUrl: String = "", - var url: String = "", - var isError: Boolean = false, - -) 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 b4a16b5b..00000000 --- a/app/src/test/java/com/pasich/encly/presentation/dialogs/BlockSheetsTest.kt +++ /dev/null @@ -1,210 +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.editor.EditorProvider -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.ui.sheets.ActionBlockDialog -import com.pasich.encly.editor.ui.sheets.ActionLinkBottomSheet -import com.pasich.encly.editor.ui.sheets.ActionOtherBottomSheet -import com.pasich.encly.editor.ui.sheets.SettingsBlockDialog -import com.pasich.encly.editor.ui.sheets.describe -import com.pasich.encly.presentation.screen.editnote.rememberEditorStrings -import com.pasich.encly.presentation.screen.editnote.resourceId -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))) - - // The sheets are the editor's; the app's strings reach them through its provider. - private fun showOther(settings: SettingsBlockDialog) = setScreen { - EditorProvider(strings = rememberEditorStrings()) { - ActionOtherBottomSheet( - settings = settings, - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), - onAction = { actions += it }, - onDismiss = { dismissed++ }, - ) - } - } - - private fun showLink(settings: SettingsBlockDialog) = setScreen { - EditorProvider(strings = rememberEditorStrings()) { - 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().resourceId()) } - 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/screen/editnote/EditorBindingsTest.kt b/app/src/test/java/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt new file mode 100644 index 00000000..d34d3129 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/presentation/screen/editnote/EditorBindingsTest.kt @@ -0,0 +1,53 @@ +package com.pasich.encly.presentation.screen.editnote + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.pasich.encly.R +import com.pasich.encly.editor.EditorString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +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 { + private val resources = ApplicationProvider.getApplicationContext().resources + private val strings = ResourceEditorStrings(resources) + + @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 R.string.text, + EditorString.QUOTE to R.string.quote, + EditorString.BLOCK_LINK to R.string.block_link, + EditorString.BLOCK_SEPARATOR to R.string.block_separator, + EditorString.BLOCK_MOVE_UP to R.string.block_move_up, + EditorString.BLOCK_DELETE to R.string.delete_block, + EditorString.TAP_AGAIN_TO_DELETE to R.string.tap_again_to_delete, + ) + + expected.forEach { (key, id) -> + assertEquals(key.name, id, key.resourceId()) + assertEquals(key.name, resources.getString(id), strings.text(key)) + } + } + + @Test + fun aHeadingNamesItsLevel() { + (1..4).forEach { level -> assertTrue(strings.heading(level).contains(level.toString())) } + } +} 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/NoDpLiteralsTest.kt b/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt index c9d746ce..240b8e23 100644 --- a/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt +++ b/app/src/test/java/com/pasich/encly/release/NoDpLiteralsTest.kt @@ -41,12 +41,9 @@ class NoDpLiteralsTest { } private companion object { - // The editor carries its own tokens and small controls (it owns no design system of its host). val ALLOWED = listOf( "/com/pasich/encly/ui/theme/", "/com/pasich/encly/presentation/designsystem/", - "/com/pasich/encly/editor/theme/", - "/com/pasich/encly/editor/ui/components/", ) val DP_LITERAL = Regex("""(?/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..47c79470 --- /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/**") + ktlint(ktlintVersion).setEditorConfigPath(editorConfig) + } + kotlinGradle { + target("**/*.gradle.kts") + targetExclude("**/build/**", "**/.gradle/**", "**/.kotlin/**") + ktlint(ktlintVersion).setEditorConfigPath(editorConfig) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index fbecfb81..f3899fcc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,34 +1,18 @@ // 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.metro) apply false - alias(libs.plugins.detekt) apply false - alias(libs.plugins.kover) apply false - alias(libs.plugins.spotless) + // 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/docs/architecture.md b/docs/architecture.md index 81248c8f..a808146a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,8 +52,9 @@ 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 lives in the package `com.pasich.encly.editor` (`model`, `codec`, `state`, `focus`, -`theme`, `ui`), which imports nothing of the app (`EditorBoundaryTest`): the app gives it its texts +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`. 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..d8fafcc9 --- /dev/null +++ b/editor/api/android/editor.api @@ -0,0 +1,900 @@ +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_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_SEPARATOR 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_PLACEHOLDER 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 SLASH_MENU_TITLE 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 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 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 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$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$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 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 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;)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/String;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 copy (Ljava/lang/String;ZLjava/lang/String;)Lcom/pasich/encly/editor/model/ItemListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;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 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 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 getSelection ()Lcom/pasich/encly/editor/state/BlockSelection; + public final fun getSlash ()Lcom/pasich/encly/editor/state/SlashPicker; + public final fun load (Ljava/util/List;)V + public final fun redo ()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/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/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/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 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 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 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/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;)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;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 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 getSlashPicker ()Lcom/pasich/encly/editor/state/SlashPicker; + 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 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 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 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 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 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/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..d8fafcc9 --- /dev/null +++ b/editor/api/desktop/editor.api @@ -0,0 +1,900 @@ +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_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_SEPARATOR 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_PLACEHOLDER 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 SLASH_MENU_TITLE 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 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 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 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$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$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 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 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;)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/String;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 copy (Ljava/lang/String;ZLjava/lang/String;)Lcom/pasich/encly/editor/model/ItemListBlock; + public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;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 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 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 getSelection ()Lcom/pasich/encly/editor/state/BlockSelection; + public final fun getSlash ()Lcom/pasich/encly/editor/state/SlashPicker; + public final fun load (Ljava/util/List;)V + public final fun redo ()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/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/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/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 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 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 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/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;)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;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 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 getSlashPicker ()Lcom/pasich/encly/editor/state/SlashPicker; + 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 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 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 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 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 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/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..fd15f233 --- /dev/null +++ b/editor/api/editor.klib.api @@ -0,0 +1,1047 @@ +// 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 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 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/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 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 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_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_SEPARATOR // com.pasich.encly.editor/EditorString.BLOCK_SEPARATOR|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_PLACEHOLDER // com.pasich.encly.editor/EditorString.LIST_ITEM_PLACEHOLDER|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 SLASH_MENU_TITLE // com.pasich.encly.editor/EditorString.SLASH_MENU_TITLE|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 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 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 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 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 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 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 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 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 = ...) // com.pasich.encly.editor.model/ItemListBlock.|(kotlin.String;kotlin.Boolean;kotlin.String){}[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 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 copy(kotlin/String = ..., kotlin/Boolean = ..., kotlin/String = ...): com.pasich.encly.editor.model/ItemListBlock // com.pasich.encly.editor.model/ItemListBlock.copy|copy(kotlin.String;kotlin.Boolean;kotlin.String){}[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.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 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 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 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/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/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 = ...) // 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){}[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] +} + +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 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 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 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 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 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_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_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.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_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_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_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/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.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/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/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/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/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/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/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 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_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_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.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_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_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/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_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/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/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..e2123da7 --- /dev/null +++ b/editor/build.gradle.kts @@ -0,0 +1,126 @@ +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.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/app/src/test/java/com/pasich/encly/editor/EditorServicesTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/editor/EditorServicesTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt index 314aee37..a7a0f2e4 100644 --- a/app/src/test/java/com/pasich/encly/editor/EditorServicesTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt @@ -9,15 +9,15 @@ 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.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -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 +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) @@ -100,7 +100,7 @@ class EditorServicesTest { assertEquals(1, events.size) assertEquals("BlockConverter", events.single().first) - assertTrue(events.single().second, "secret" !in events.single().second) + assertTrue("secret" !in events.single().second, events.single().second) assertEquals(emptyList(), BlockConverter.jsonToBlocksOrNull("[]")) } diff --git a/app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt index 39726cbc..5aa6c4c5 100644 --- a/app/src/test/java/com/pasich/encly/editor/focus/FocusBranchTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusBranchTest.kt @@ -25,14 +25,14 @@ 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.Assert.assertEquals -import org.junit.Assert.assertFalse -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 +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. */ diff --git a/app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt index 668577a6..16dcbde1 100644 --- a/app/src/test/java/com/pasich/encly/editor/focus/FocusGapsTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/focus/FocusGapsTest.kt @@ -21,16 +21,16 @@ 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.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.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 +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. */ diff --git a/app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt index 19c0f3bb..14c7394e 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorBlocksUiTest.kt @@ -29,13 +29,13 @@ 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.Assert.assertEquals -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 +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 @@ -132,7 +132,7 @@ class EditorBlocksUiTest { fields()[0].performClick() fields()[0].performTextInput(mark) idle() - assertTrue(state.blocks.map { it::class.simpleName }.toString(), check(state.blocks.first())) + assertTrue(check(state.blocks.first()), state.blocks.map { it::class.simpleName }.toString()) } @Test diff --git a/app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt index 02144128..b5afc3f1 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/EditorGapsUiTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorGapsUiTest.kt @@ -24,14 +24,14 @@ 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.Assert.assertEquals -import org.junit.Assert.assertTrue 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 +import kotlin.test.assertTrue /** Keys, focus requests and odd line breaks that the scenario tests of the blocks leave out. */ @OptIn(ExperimentalTestApi::class) diff --git a/app/src/test/java/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt similarity index 97% rename from app/src/test/java/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt index 04396266..acbb51ae 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TapBelowBlocksTest.kt @@ -16,12 +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 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/app/src/test/java/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt similarity index 91% rename from app/src/test/java/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt index d439cc88..a4e39eee 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/ChecklistLayoutTest.kt @@ -40,13 +40,13 @@ 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) @@ -122,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 != EditorLineSpacing.COMPACT) { - assertEquals("$spacing $size layout", field.top + layoutCentre(), box.center.y, 1f) + assertEquals(field.top + layoutCentre(), box.center.y, 1f, "$spacing $size layout") } } } @@ -140,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") } } } @@ -153,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") } } @@ -190,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() } @@ -208,7 +208,7 @@ class ChecklistLayoutTest { } 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 diff --git a/app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt index da2521a1..64e81dc9 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/EditorDrawTest.kt @@ -30,15 +30,15 @@ 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.Assert.assertEquals -import org.junit.Assert.assertNull -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 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) diff --git a/app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt similarity index 95% rename from app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt index 2c027d83..861aed29 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SeparatorBlockTest.kt @@ -7,12 +7,12 @@ 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 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/app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt similarity index 98% rename from app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt rename to editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt index 235b3c45..88bddf25 100644 --- a/app/src/test/java/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/sheets/EditorBlockSheetTest.kt @@ -15,14 +15,14 @@ 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.Assert.assertEquals -import org.junit.Assert.assertTrue 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 +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) 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/app/src/main/java/com/pasich/encly/editor/EditorServices.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt similarity index 70% rename from app/src/main/java/com/pasich/encly/editor/EditorServices.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt index 91ba7325..460ac469 100644 --- a/app/src/main/java/com/pasich/encly/editor/EditorServices.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorServices.kt @@ -2,6 +2,7 @@ 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 @@ -13,16 +14,16 @@ 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. */ -fun interface EditorClipboard { +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. */ - fun copy(label: String, text: String, sensitive: Boolean) + public fun copy(label: String, text: String, sensitive: Boolean) /** Copies nothing: the default when the host provides no clipboard. */ - companion object None : EditorClipboard { - override fun copy(label: String, text: String, sensitive: Boolean) = Unit + public companion object None : EditorClipboard { + override fun copy(label: String, text: String, sensitive: Boolean): Unit = Unit } } @@ -31,13 +32,13 @@ fun interface EditorClipboard { * ([com.pasich.encly.editor.ui.blocks.isOpenableLink]); the host decides how, and tells the user * when nothing can take it. */ -fun interface LinkOpener { +public fun interface LinkOpener { /** Opens the web or mail link [url] in another app. */ - fun open(url: String) + public fun open(url: String) /** Opens nothing: the default when the host provides no opener. */ - companion object None : LinkOpener { - override fun open(url: String) = Unit + public companion object None : LinkOpener { + override fun open(url: String): Unit = Unit } } @@ -45,40 +46,42 @@ fun interface LinkOpener { * 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. */ -fun interface EditorLogger { +public fun interface EditorLogger { /** A recoverable problem: [event] says what failed in [tag]. */ - fun warn(tag: String, event: String) + public fun warn(tag: String, event: String) /** Logs nothing: the default when the host provides no logger. */ - companion object None : EditorLogger { - override fun warn(tag: String, event: String) = Unit + 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). [MarkdownBlockMapper] - * (`editor.codec`) is the editor's own reader and the default; a host may pass another. + * 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. */ -fun interface MarkdownBlockParser { +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. */ - fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List + public fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List } /** The strings the editor shows; English until the host provides its own. */ -val LocalEditorStrings = staticCompositionLocalOf { EnglishEditorStrings } +public val LocalEditorStrings: ProvidableCompositionLocal = + staticCompositionLocalOf { EnglishEditorStrings } /** Where the editor copies text to; see [EditorClipboard]. */ -val LocalEditorClipboard = staticCompositionLocalOf { EditorClipboard.None } +internal val LocalEditorClipboard = staticCompositionLocalOf { EditorClipboard.None } /** How the editor opens a link; see [LinkOpener]. */ -val LocalLinkOpener = staticCompositionLocalOf { LinkOpener.None } +internal val LocalLinkOpener = staticCompositionLocalOf { LinkOpener.None } /** Where the editor reports failures; see [EditorLogger]. */ -val LocalEditorLogger = staticCompositionLocalOf { EditorLogger.None } +internal val LocalEditorLogger = staticCompositionLocalOf { EditorLogger.None } /** * Provides everything the editor needs from its host above [content]: [strings], [theme], and the @@ -86,7 +89,7 @@ val LocalEditorLogger = staticCompositionLocalOf { EditorLogger.No * default, so [content] may also be shown with only what the host cares about. */ @Composable -fun EditorProvider( +public fun EditorProvider( strings: EditorStrings = EnglishEditorStrings, theme: EditorTheme = EditorTheme(), clipboard: EditorClipboard = EditorClipboard.None, diff --git a/app/src/main/java/com/pasich/encly/editor/EditorStrings.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt similarity index 91% rename from app/src/main/java/com/pasich/encly/editor/EditorStrings.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt index 2635ef21..63d15bd3 100644 --- a/app/src/main/java/com/pasich/encly/editor/EditorStrings.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt @@ -5,7 +5,7 @@ package com.pasich.encly.editor * 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. */ -enum class EditorString(val english: String) { +public enum class EditorString(internal val english: String) { TEXT("Text"), PRESS_TO_EDIT("Tap to start editing…"), BLOCK_HEADING("Heading"), @@ -59,16 +59,16 @@ enum class EditorString(val english: String) { * instance through [LocalEditorStrings] and shows what it returns at that moment, so a host that * changes language provides a new instance. */ -interface EditorStrings { +public interface EditorStrings { /** The text for [key]. */ - fun text(key: EditorString): String + public fun text(key: EditorString): String /** The "Heading N" label for [level] (1 to 4): [EditorString.BLOCK_HEADING_LEVEL] with the level filled in. */ - fun heading(level: Int): String + public fun heading(level: Int): String } /** English texts: the default when the host provides none, and what the editor's tests read. */ -object EnglishEditorStrings : EditorStrings { +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()) diff --git a/app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt index 146ae440..4f65a641 100644 --- a/app/src/main/java/com/pasich/encly/editor/codec/BlockConverter.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.SerializationException * Converter for transforming blocks to JSON and back. * The format is written and read by [BlockJson]. */ -object BlockConverter { +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 @@ -18,7 +18,7 @@ object BlockConverter { * @param blocks the list of blocks to serialize * @return the JSON string */ - fun blocksToJson(blocks: List): String { + public fun blocksToJson(blocks: List): String { val kept = blocks.dropLastWhile { !it.hasContent() }.filter { it.hasContent() || it is Block.TextBlock } return BlockJson.encode(kept) } @@ -30,7 +30,7 @@ object BlockConverter { * @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 */ - fun jsonToBlocks(json: String): List { + public fun jsonToBlocks(json: String): List { if (json.isEmpty()) return emptyList() return BlockJson.decode(json) } @@ -40,7 +40,7 @@ object BlockConverter { * 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. */ - fun jsonToBlocksOrNull(json: String, logger: EditorLogger = EditorLogger.None): List? = try { + 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 diff --git a/app/src/main/java/com/pasich/encly/editor/codec/BlockJson.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/editor/codec/BlockJson.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt diff --git a/app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt similarity index 57% rename from app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt index ac7724b1..f5bbe183 100644 --- a/app/src/main/java/com/pasich/encly/editor/codec/ImportBlocks.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt @@ -6,25 +6,28 @@ import com.pasich.encly.editor.model.ItemListBlock import kotlinx.coroutines.flow.MutableStateFlow /** Builders for the Encly blocks the parsers produce. */ -object ImportBlocks { +public 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)) + /** 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. */ - fun heading(value: String, level: Int): Block = Block.HBlock( + public 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)) + /** A quote block with [value] as its text. */ + public fun quote(value: String): Block = Block.QuoteBlock(text = MutableStateFlow(value)) - fun list(type: BlockType, items: List): Block = + /** 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) /** Blank-line separated paragraphs as TEXT blocks; line breaks stay inside their paragraph. */ - fun paragraphs(text: String): List = text.trim().split(PARAGRAPH_BREAK) + public 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/editor/codec/InlineHtml.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt similarity index 74% rename from app/src/main/java/com/pasich/encly/editor/codec/InlineHtml.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt index c2417f55..aab098c9 100644 --- a/app/src/main/java/com/pasich/encly/editor/codec/InlineHtml.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/InlineHtml.kt @@ -5,7 +5,7 @@ package com.pasich.encly.editor.codec * ``, `
`, `
`, 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 { +public object InlineHtml { private val LINE_BREAK = Regex("""""", RegexOption.IGNORE_CASE) private val TAG = Regex("""]*>""") @@ -20,6 +20,10 @@ object InlineHtml { ) 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 @@ -45,7 +49,7 @@ object InlineHtml { * 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 { + 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, ' ') @@ -62,11 +66,19 @@ object InlineHtml { else -> return NAMED[entity] } - return codePoint.takeIf(::isTextCodePoint)?.let { String(Character.toChars(it)) } + 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 = Character.isValidCodePoint(codePoint) && - codePoint != 0 && + 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/app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt index 0e41b077..869405c2 100644 --- a/app/src/main/java/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -18,7 +18,7 @@ import com.pasich.encly.editor.model.ItemListBlock * ([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 : MarkdownBlockParser { +public object MarkdownBlockMapper : MarkdownBlockParser { 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]*(.*)$""") @@ -36,7 +36,11 @@ object MarkdownBlockMapper : MarkdownBlockParser { 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() + /** + * [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 diff --git a/app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt similarity index 83% rename from app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt index f4e34c93..c5f688c0 100644 --- a/app/src/main/java/com/pasich/encly/editor/focus/BlockFocusRegistry.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/BlockFocusRegistry.kt @@ -23,7 +23,10 @@ private const val TAG = "BlockFocusRegistry" * 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 logger: EditorLogger = EditorLogger.None, private val blocks: () -> 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>() @@ -32,7 +35,7 @@ class BlockFocusRegistry(private val logger: EditorLogger = EditorLogger.None, p * 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 = + internal fun registerFocusTarget(blockId: String, requestFocus: () -> Unit): () -> Unit = focusTargets.register(blockId) { request -> requestFocus() val offset = request.caretOffset @@ -48,7 +51,7 @@ class BlockFocusRegistry(private val logger: EditorLogger = EditorLogger.None, p * 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 = + internal fun registerFieldsFocusTarget(blockId: String, requestFocus: (FocusRequest) -> Unit): () -> Unit = focusTargets.register(blockId, requestFocus) /** @@ -56,24 +59,25 @@ class BlockFocusRegistry(private val logger: EditorLogger = EditorLogger.None, p * 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) + 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) */ - fun registerCaretAtX(blockId: String, callback: (Float, Boolean) -> Unit): () -> Unit = + 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]. */ - fun focus(blockId: String, cursorToEnd: Boolean = false): Boolean = focus(FocusRequest(blockId, cursorToEnd)) + 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). */ - fun focus(request: FocusRequest): Boolean { + public fun focus(request: FocusRequest): Boolean { val target = focusTargets[request.blockId] ?: return false try { target(request) @@ -89,7 +93,7 @@ class BlockFocusRegistry(private val logger: EditorLogger = EditorLogger.None, p * 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 = {}) { + 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") @@ -108,14 +112,14 @@ class BlockFocusRegistry(private val logger: EditorLogger = EditorLogger.None, p * 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? { + 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. */ - fun previousFocusableBlock(blockId: String): Block? { + 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 diff --git a/app/src/main/java/com/pasich/encly/editor/focus/CaretGeometry.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/editor/focus/CaretGeometry.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt index 23340ade..c028908f 100644 --- a/app/src/main/java/com/pasich/encly/editor/focus/CaretGeometry.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/CaretGeometry.kt @@ -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/editor/focus/KeyboardUtils.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt similarity index 99% rename from app/src/main/java/com/pasich/encly/editor/focus/KeyboardUtils.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt index 681398e8..49f659b9 100644 --- a/app/src/main/java/com/pasich/encly/editor/focus/KeyboardUtils.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/focus/KeyboardUtils.kt @@ -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/app/src/main/java/com/pasich/encly/editor/model/Block.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt similarity index 71% rename from app/src/main/java/com/pasich/encly/editor/model/Block.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt index 498986e7..a8e7e633 100644 --- a/app/src/main/java/com/pasich/encly/editor/model/Block.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt @@ -4,7 +4,8 @@ import com.pasich.encly.editor.EditorString import kotlinx.coroutines.flow.MutableStateFlow import kotlin.uuid.Uuid -enum class BlockType { +/** 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, @@ -19,7 +20,7 @@ enum class BlockType { } /** A fresh editor-session identity for a block or list item. Never persisted. */ -fun newBlockId(): String = Uuid.random().toString() +internal fun newBlockId(): String = Uuid.random().toString() /** * Every block carries [id], a stable identity for the editor session: Compose keys its @@ -27,39 +28,46 @@ fun newBlockId(): String = Uuid.random().toString() * 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 +public sealed class Block { + /** The identity of the block for the editor session; see [Block]. */ + public abstract val id: String - data class TextBlock( + /** 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 - data class HBlock( + /** 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 - data class QuoteBlock( + /** A quotation. */ + public data class QuoteBlock( override val text: MutableStateFlow = MutableStateFlow(""), override val id: String = newBlockId(), ) : Block(), TextualBlock - data class LinkBlock( + /** A link card; [block] holds the link. */ + public data class LinkBlock( val block: MutableStateFlow = MutableStateFlow( LinkDataBlock(), ), override val id: String = newBlockId(), ) : Block() - data class SeparatorBlock(override val id: String = newBlockId()) : Block() + /** A horizontal rule. */ + public data class SeparatorBlock(override val id: String = newBlockId()) : Block() - data class ListBlock( + /** 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(""), @@ -79,7 +87,7 @@ sealed class 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) { +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() diff --git a/app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt similarity index 89% rename from app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt index 9151fbc7..3865956a 100644 --- a/app/src/main/java/com/pasich/encly/editor/model/BlockFactory.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt @@ -4,11 +4,11 @@ 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) diff --git a/app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt index 9984b698..7ad9db31 100644 --- a/app/src/main/java/com/pasich/encly/editor/model/BlockUtils.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt @@ -4,7 +4,7 @@ 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. diff --git a/app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt similarity index 65% rename from app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt index df9bac08..2c77bcd7 100644 --- a/app/src/main/java/com/pasich/encly/editor/model/ItemListBlock.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt @@ -5,10 +5,10 @@ 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 (`@Transient`), so stored notes are unchanged. + * serialized (the codec writes only [value] and [isCheck]), so stored notes are unchanged. */ -data class ItemListBlock( +public data class ItemListBlock( val value: String = "", val isCheck: Boolean = false, - @Transient val id: String = Uuid.random().toString(), + val id: String = Uuid.random().toString(), ) 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/app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt similarity index 57% rename from app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt index 95efc636..a90885dd 100644 --- a/app/src/main/java/com/pasich/encly/editor/model/TextualBlock.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TextualBlock.kt @@ -6,9 +6,10 @@ 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 +public interface TextualBlock { + /** The text of the block, live. */ + public val text: MutableStateFlow } /** Whether the block's text is empty. */ -fun TextualBlock.isEmpty(): Boolean = text.value.isEmpty() +internal fun TextualBlock.isEmpty(): Boolean = text.value.isEmpty() diff --git a/app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt index 15ac90c3..48435046 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockDuplicate.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt @@ -5,7 +5,7 @@ import com.pasich.encly.editor.model.newBlockId import kotlinx.coroutines.flow.MutableStateFlow /** Duplicates the block the user works on (see [duplicateBlockOf]). */ -fun BlockEditorState.duplicateInteracted() { +public fun BlockEditorState.duplicateInteracted() { duplicateBlockOf(blocks[selection.interactedIndex].id) } @@ -15,7 +15,7 @@ fun BlockEditorState.duplicateInteracted() { * 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 { +public fun BlockEditorState.duplicateBlockOf(blockId: String): Boolean { val index = indexOf(blockId) val block = blocks.getOrNull(index) ?: return false val copy = block.duplicate() diff --git a/app/src/main/java/com/pasich/encly/editor/state/BlockEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt similarity index 96% rename from app/src/main/java/com/pasich/encly/editor/state/BlockEditing.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt index 07471c08..0b43dd44 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt @@ -17,7 +17,7 @@ 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 @@ -69,7 +69,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), @@ -170,7 +170,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) @@ -188,7 +188,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/app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt similarity index 84% rename from app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt index 51138b97..81f3dca0 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockEditorState.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt @@ -17,7 +17,8 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -enum class BlockRemoveAction { REMOVE, REMOVE_BACKSPACE, REMOVE_BACKSPACE_LIST } +/** 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 @@ -28,33 +29,40 @@ enum class BlockRemoveAction { REMOVE, REMOVE_BACKSPACE, REMOVE_BACKSPACE_LIST } * 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( +public class BlockEditorState( /** Reads pasted Markdown for Smart Paste. */ - internal val markdown: MarkdownBlockParser = MarkdownBlockMapper, + public val markdown: MarkdownBlockParser = MarkdownBlockMapper, clock: () -> Long = ::currentTimeMillis, ) { private val _blocks = SnapshotStateList().apply { add(emptyNoteBlock()) } - val blocks: List get() = _blocks + + /** 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) - val selection = BlockSelection(_blocks) + /** Which block the user is working in, and focus requests for the blocks. */ + public val selection: BlockSelection = BlockSelection(_blocks) /** The slash picker: closes at any structural change, so it never outlives its paragraph. */ - val slash = SlashPicker() + public val slash: SlashPicker = SlashPicker() /** Find in this note: transient, read-only, forgotten when the note is loaded or the vault locks. */ - val find = NoteFind() + public val find: NoteFind = NoteFind() private val _canUndo = MutableStateFlow(false) - val canUndo: StateFlow = _canUndo.asStateFlow() + + /** Whether there is an edit to undo. */ + public val canUndo: StateFlow = _canUndo.asStateFlow() private val _canRedo = MutableStateFlow(false) - val canRedo: StateFlow = _canRedo.asStateFlow() + + /** 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) - val contentChanges: Flow = snapshotFlow { _blocks.toList() }.flatMapLatest { current -> + 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) { } @@ -64,7 +72,7 @@ class BlockEditorState( * 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) { + public fun load(loaded: List) { slash.dismiss() find.close() _blocks.clear() @@ -78,7 +86,7 @@ class BlockEditorState( * 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) { + public fun addBlock(block: Block) { slash.dismiss() val anchor = selection.workingIndex.coerceIn(-1, _blocks.lastIndex) val replacesEmptyFirst = block !is Block.TextBlock && _blocks.isEmptyFirstTextBlock(anchor) @@ -94,7 +102,7 @@ class BlockEditorState( * 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) { + 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 @@ -112,7 +120,7 @@ class BlockEditorState( * 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 { + public fun replaceBlock(index: Int, newBlock: Block): Boolean { if (index !in _blocks.indices) return false slash.dismiss() val old = _blocks[index] @@ -132,7 +140,7 @@ class BlockEditorState( } /** Moves the block at [fromIndex] to [toIndex]; it keeps focus. */ - fun moveBlock(fromIndex: Int, toIndex: Int) { + internal fun moveBlock(fromIndex: Int, toIndex: Int) { if (fromIndex !in _blocks.indices || toIndex !in _blocks.indices) return slash.dismiss() history.moveBlock(fromIndex, toIndex) @@ -144,7 +152,7 @@ class BlockEditorState( * 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( + public fun changeValue( blockId: String, state: MutableStateFlow, newValue: T, @@ -156,15 +164,17 @@ class BlockEditorState( } /** Makes the operations of [build] as one undo step (see [BlockOperations.batch]). */ - fun batch(build: BlockOperations.Batch.() -> Unit) { + internal fun batch(build: BlockOperations.Batch.() -> Unit) { slash.dismiss() history.batch(build) afterChange() } - fun undo() = moveHistory(undo = true) + /** Undoes the last edit (a run of typing is one edit); does nothing when there is none. */ + public fun undo(): Unit = moveHistory(undo = true) - fun redo() = moveHistory(undo = false) + /** 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 diff --git a/app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt similarity index 69% rename from app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt index 747f6d24..432a94b9 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockEditorToolbar.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorToolbar.kt @@ -5,54 +5,54 @@ 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). */ -fun BlockEditorState.canMoveInteracted(up: Boolean): Boolean { +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). */ -fun BlockEditorState.moveInteracted(up: Boolean) { +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). */ -fun BlockEditorState.indexOf(blockId: String): Int = blocks.indexOfFirst { it.id == blockId } +public 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) { +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. */ -fun BlockEditorState.moveBlockOf(blockId: String, up: Boolean) { +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. */ -fun BlockEditorState.canRemoveInteracted(): Boolean = blocks.size > 1 +public fun BlockEditorState.canRemoveInteracted(): Boolean = blocks.size > 1 /** Removes the block the user works on. */ -fun BlockEditorState.removeInteracted() { +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]. */ -fun BlockEditorState.interactedBlockPosition(): Int = when (selection.interactedIndex) { +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. */ -fun BlockEditorState.addBlockToEnd() { +public fun BlockEditorState.addBlockToEnd() { if (blocks.lastOrNull() is Block.TextBlock) { selection.focusAt(blocks.lastIndex) } else { @@ -60,6 +60,6 @@ fun BlockEditorState.addBlockToEnd() { } } -const val BLOCK_AT_TOP = 1 -const val BLOCK_AT_BOTTOM = 2 -const val BLOCK_IN_MIDDLE = -1 +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/editor/state/BlockOperations.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt similarity index 99% rename from app/src/main/java/com/pasich/encly/editor/state/BlockOperations.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt index 1e8edfa0..441f1a4a 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockOperations.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt @@ -22,7 +22,7 @@ internal fun currentTimeMillis(): Long = Clock.System.now().toEpochMilliseconds( * 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 = ::currentTimeMillis, ) { diff --git a/app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt similarity index 82% rename from app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt index 00f6d6a1..5db86958 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.receiveAsFlow * 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( +public data class FocusRequest( val blockId: String, val cursorToEnd: Boolean = false, val caret: Int? = null, @@ -33,11 +33,11 @@ data class FocusRequest( * 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) { +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). */ - val focusedBlockId: StateFlow = _focusedBlockId.asStateFlow() + 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. @@ -46,7 +46,7 @@ class BlockSelection(private val blocks: List) { 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() + 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. @@ -59,26 +59,26 @@ class BlockSelection(private val blocks: List) { * 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 + public 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() + public 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 } + 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). */ - val interactedIndex: Int + 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. */ - val workingIndex: Int get() = focusedIndex.takeIf { it >= 0 } ?: interactedIndex + internal val workingIndex: Int get() = focusedIndex.takeIf { it >= 0 } ?: interactedIndex /** The field of [blockId] took focus. */ - fun onFocused(blockId: String) { + public fun onFocused(blockId: String) { _focusedBlockId.value = blockId onInteraction(blockId) } @@ -87,32 +87,32 @@ class BlockSelection(private val blocks: List) { * 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) { + public 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) { + 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. */ - fun caretOf(blockId: String): Int? = carets[blockId] + 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]. */ - fun onItemCaret(blockId: String, itemId: String, offset: Int) { + 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. */ - fun itemOf(blockId: String): String? = listItems[blockId] + internal fun itemOf(blockId: String): String? = listItems[blockId] /** The user works on [blockId] without its field taking focus (a block sheet). */ - fun onInteraction(blockId: String) { + public fun onInteraction(blockId: String) { val index = blocks.indexOfFirst { it.id == blockId } if (index < 0) return _interactedBlockId.value = blockId @@ -123,7 +123,7 @@ class BlockSelection(private val blocks: List) { * 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) { + internal fun focusAt(index: Int, cursorToEnd: Boolean = false, caret: Int? = null, itemId: String? = null) { val block = blocks.getOrNull(index) ?: return val blockId = block.id onFocused(blockId) @@ -140,7 +140,7 @@ class BlockSelection(private val blocks: List) { 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. */ -fun Block.canTakeFocus(): Boolean = when (this) { +internal 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. @@ -156,7 +156,7 @@ fun Block.canTakeFocus(): Boolean = when (this) { * 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 { +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/app/src/main/java/com/pasich/encly/editor/state/BlockTools.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt similarity index 93% rename from app/src/main/java/com/pasich/encly/editor/state/BlockTools.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt index e51be73f..55329d6b 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/BlockTools.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt @@ -10,7 +10,7 @@ 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() { +public fun BlockEditorState.focusFirstBlock() { val first = blocks.indexOfFirst { it.canTakeFocus() } if (first >= 0) selection.focusAt(first) else addBlockToEnd() } @@ -20,7 +20,7 @@ fun BlockEditorState.focusFirstBlock() { * 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() { +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) @@ -38,7 +38,7 @@ fun BlockEditorState.focusWorkingBlock() { * 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 { +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) } } @@ -46,10 +46,10 @@ fun BlockEditorState.toggleChecklistLock(blockId: String): Boolean { } /** The Heading tool: headings of every level show it active, and it makes an H2. */ -val HEADING_TOOL = BlockType.H2 +public val HEADING_TOOL: BlockType = BlockType.H2 /** The toolbar tool shown active for [this] block; null for a paragraph. */ -fun Block.toolType(): BlockType? = when (this) { +public fun Block.toolType(): BlockType? = when (this) { is Block.TextBlock -> null is Block.HBlock -> HEADING_TOOL is Block.QuoteBlock -> BlockType.QUOTE @@ -64,7 +64,7 @@ fun Block.toolType(): BlockType? = when (this) { * 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) { +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 diff --git a/app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt similarity index 80% rename from app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt index 7cca2120..f628f079 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/CaretMove.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretMove.kt @@ -3,9 +3,9 @@ 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/editor/state/CaretTarget.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/editor/state/CaretTarget.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt diff --git a/app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt similarity index 95% rename from app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt index 13d3975d..3cad25fb 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/ListEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow * 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? { +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 listPaste(item, lineBreak) } @@ -111,7 +111,7 @@ private fun BlockEditorState.exitList(index: Int, list: Block.ListBlock, at: Int * 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 { +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 @@ -159,7 +159,7 @@ fun BlockEditorState.listBackspaceAtStart(listId: String, itemId: String): Boole * 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? { +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() diff --git a/app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt similarity index 76% rename from app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt index 2dfa1479..a8be469b 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/NoteFind.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt @@ -14,25 +14,29 @@ import kotlinx.coroutines.flow.update * 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) { +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. */ -fun fieldKey(blockId: String, itemId: String?): String = if (itemId == null) blockId else "$blockId/$itemId" +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). */ -data class FindState( +public data class FindState( val isOpen: Boolean = false, val query: String = "", - val matches: List = emptyList(), + /** Where the query was found, in note order. */ + public 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 } } + internal val byField: Map> by lazy(LazyThreadSafetyMode.NONE) { + matches.groupBy { it.field } + } - val currentMatch: FindMatch? get() = matches.getOrNull(current) + /** The match the find bar is on; null when there is none. */ + public val currentMatch: FindMatch? get() = matches.getOrNull(current) } /** @@ -40,7 +44,7 @@ data class FindState( * 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 { +internal fun findMatches(blocks: List, query: String): List { if (query.isBlank()) return emptyList() val found = mutableListOf() for (block in blocks) { @@ -64,23 +68,26 @@ private fun MutableList.addMatches(blockId: String, itemId: String?, } /** The find bar's state and its movement through the matches. */ -class NoteFind { +public class NoteFind { private val _state = MutableStateFlow(FindState()) - val state: StateFlow = _state.asStateFlow() - val isOpen: Boolean get() = _state.value.isOpen + /** The find bar, live. */ + public val state: StateFlow = _state.asStateFlow() + + internal val isOpen: Boolean get() = _state.value.isOpen - fun open() { + /** Opens the find bar. */ + public fun open() { _state.update { it.copy(isOpen = true) } } /** Closes the bar and forgets the query and every match. */ - fun close() { + public fun close() { _state.value = FindState() } /** Searches [blocks] for [query]; the first match becomes the current one. */ - fun search(query: String, blocks: List) { + 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) } } @@ -89,7 +96,7 @@ class NoteFind { * 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) { + public fun refresh(blocks: List) { _state.update { now -> if (!now.isOpen || now.query.isBlank()) return@update now val matches = findMatches(blocks, now.query) @@ -101,10 +108,10 @@ class NoteFind { } /** The next match, wrapping round after the last. */ - fun next() = step(1) + public fun next(): Unit = step(1) /** The match before, wrapping round before the first. */ - fun previous() = step(-1) + public fun previous(): Unit = step(-1) private fun step(delta: Int) { _state.update { now -> diff --git a/app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt similarity index 87% rename from app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt index b3a73986..4ea9d515 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/NoteTemplates.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteTemplates.kt @@ -11,19 +11,19 @@ import kotlinx.coroutines.flow.MutableStateFlow // store nothing of their own; a template is just the blocks it makes. /** One piece of a template. */ -sealed interface TemplatePart { +public sealed interface TemplatePart { /** A heading of [type] (H1-H4) reading the string [text]. */ - data class Heading(val type: BlockType, val text: EditorString) : TemplatePart + public data class Heading(val type: BlockType, val text: EditorString) : TemplatePart /** An empty paragraph to write in. */ - data object Paragraph : TemplatePart + public data object Paragraph : TemplatePart /** An empty list of [type] (bullets, numbers or checklist). */ - data class ListOf(val type: BlockType) : TemplatePart + public data class ListOf(val type: BlockType) : TemplatePart } /** The templates a new note can start from, in the order they are offered. */ -enum class NoteTemplate(val label: EditorString, val parts: List) { +public enum class NoteTemplate(public val label: EditorString, public val parts: List) { /** What a new note is today: nothing. */ BLANK(EditorString.TEMPLATE_BLANK, emptyList()), @@ -82,7 +82,7 @@ private fun heading(text: EditorString) = TemplatePart.Heading(BlockType.H2, tex * 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: (EditorString) -> String): List = parts.map { part -> +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) @@ -94,7 +94,7 @@ fun NoteTemplate.blocks(resolve: (EditorString) -> String): List = parts. } /** Whether the note is exactly what a new note starts as: one empty paragraph. */ -fun BlockEditorState.isBlankNote(): Boolean = +public fun BlockEditorState.isBlankNote(): Boolean = (blocks.singleOrNull() as? Block.TextBlock)?.let { it.text.value.isEmpty() } == true /** @@ -102,7 +102,7 @@ fun BlockEditorState.isBlankNote(): Boolean = * 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 { +public fun BlockEditorState.applyTemplate(template: List): Boolean { if (template.isEmpty() || !isBlankNote()) return false batch { replaceBlock(0, template.first()) diff --git a/app/src/main/java/com/pasich/encly/editor/state/PasteEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt similarity index 100% rename from app/src/main/java/com/pasich/encly/editor/state/PasteEditing.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt diff --git a/app/src/main/java/com/pasich/encly/editor/state/Shortcut.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt similarity index 88% rename from app/src/main/java/com/pasich/encly/editor/state/Shortcut.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt index 54cd1d13..e1e5d3ee 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/Shortcut.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt @@ -10,8 +10,8 @@ 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) @@ -75,10 +75,10 @@ fun shortcutBlock(shortcut: Shortcut, rest: String): Block = when (shortcut.type 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 +86,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/app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt similarity index 86% rename from app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt rename to editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt index 4045ea1b..07bffe9b 100644 --- a/app/src/main/java/com/pasich/encly/editor/state/SlashCommands.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt @@ -16,11 +16,11 @@ import kotlinx.coroutines.flow.asStateFlow * 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, - val label: EditorString, - val headingLevel: Int? = null, - val keywords: List = emptyList(), +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")), @@ -40,7 +40,7 @@ enum class SlashCommand( * 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 { +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 @@ -52,7 +52,7 @@ fun slashMatches(query: String, labels: Map): List
if (commandKeyIsMeta) "⌘⇧⌫" else "Ctrl+Shift+Backspace" AppCommand.Dismiss -> "Esc" + + AppCommand.PrintNote -> "${mod}P" + + AppCommand.ExportPdf -> if (commandKeyIsMeta) "⌘⇧P" else "Ctrl+Shift+P" } } 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 index 04757aff..bc4487e6 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt @@ -15,12 +15,15 @@ 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.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 @@ -38,6 +41,8 @@ enum class EditNoteBottomSheetAction { FIND, TRASH, EXPORT_MARKDOWN, + EXPORT_PDF, + PRINT, COPY_MARKDOWN, ARCHIVE, UNARCHIVE, @@ -54,6 +59,7 @@ fun EditNoteBottomSheet( onDismiss: () -> Unit, modifier: Modifier = Modifier, viewModel: EditNoteViewModel = metroViewModel(), + documents: Boolean = false, onAction: (EditNoteBottomSheetAction) -> Unit = {}, ) { val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() @@ -79,7 +85,7 @@ fun EditNoteBottomSheet( contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), ) ArchiveRow(note = note.note, onAction = onAction, onDismiss = onDismiss) - EditorActionRows(canCopy = !loadFailed, onAction = onAction, onDismiss = onDismiss) + EditorActionRows(canCopy = !loadFailed, documents = documents, onAction = onAction, onDismiss = onDismiss) } } } @@ -89,7 +95,12 @@ fun EditNoteBottomSheet( * 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) { +private fun EditorActionRows( + canCopy: Boolean, + documents: Boolean, + onAction: (EditNoteBottomSheetAction) -> Unit, + onDismiss: () -> Unit, +) { fun run(action: EditNoteBottomSheetAction) { onAction(action) onDismiss() @@ -104,6 +115,18 @@ private fun EditorActionRows(canCopy: Boolean, onAction: (EditNoteBottomSheetAct icon = EnclyIcons.Upload, onClick = { run(EditNoteBottomSheetAction.EXPORT_MARKDOWN) }, ) + if (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 (canCopy) { EnclySheetRow( title = stringResource(Res.string.markdown_copy), 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 index 4caddbb6..2706f1bb 100644 --- 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 @@ -81,6 +81,13 @@ class NotePersistence( @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. @@ -107,7 +114,7 @@ class NotePersistence( /** False when nothing may be saved: closed, still loading, read-only or unreadable. */ private val canPersist: Boolean - get() = !closed && loaded && !readOnly && !_contentLoadFailed.value + get() = !closed && loaded && !readOnly && !suspended && !_contentLoadFailed.value /** * Reads note [noteId] (as a new, unsaved copy with [isCopy]) and hands its blocks to @@ -219,7 +226,7 @@ class NotePersistence( * was already autosaved is deleted instead of leaving a ghost note behind. */ suspend fun discard(): Boolean = saveMutex.withLock { - if (!loaded) return@withLock false + if (!loaded || suspended) return@withLock false val currentNote = _state.value.note val backupNote = _state.value.backupNote @@ -237,7 +244,7 @@ class NotePersistence( /** 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 + if (!loaded || suspended || currentNote.id == -1L) return@withLock false val restored = currentNote.copy(value = contentJson(), isTrash = false) val ok = updateNoteTrashStatusUseCase.invoke(restored, false).isSuccess @@ -251,7 +258,7 @@ class NotePersistence( /** 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 (!loaded || suspended) return@withLock false if (currentNote.id == -1L) { close() return@withLock true @@ -273,7 +280,7 @@ class NotePersistence( */ suspend fun setArchived(archived: Boolean): Boolean = saveMutex.withLock { val currentNote = _state.value.note - if (!loaded || currentNote.id == -1L) return@withLock false + if (!loaded || suspended || currentNote.id == -1L) return@withLock false val updated = currentNote.copy( value = contentJson(), @@ -325,7 +332,7 @@ class NotePersistence( suspend fun delete(): Boolean = saveMutex.withLock { val currentNoteId = _state.value.note.id val deleted = when { - !loaded -> false + !loaded || suspended -> false currentNoteId == -1L -> true else -> notesRepository.deleteNoteById(currentNoteId).isSuccess } 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 index d8757682..07f08aa5 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouter.kt @@ -4,7 +4,9 @@ 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 @@ -16,7 +18,13 @@ 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) +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 @@ -33,7 +41,10 @@ class AppCommandRouter( ) { private var newNote: (() -> Unit)? = null private var tabs: NoteTabs? = null - private var editor: EditorCommands? = 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) { @@ -75,6 +86,8 @@ class AppCommandRouter( 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 { 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 index 841cadf1..4f37e16d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -28,6 +28,7 @@ import com.pasich.encly.core.resources.lock_use_recovery_phrase import com.pasich.encly.core.security.PIN_LENGTH import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.platform.LocalBiometricHost +import com.pasich.encly.platform.LocalSessionSharedAcrossWindows import com.pasich.encly.presentation.designsystem.EnclyButton import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyTextButton @@ -48,14 +49,20 @@ import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult import com.pasich.encly.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import org.jetbrains.compose.resources.stringResource /** 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 = metroViewModel()::canReopenNote + 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 -> @@ -77,6 +84,31 @@ fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) 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. 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()) { + navController.leaveLockScreen(null, viewModel::isSessionLocked, viewModel::canReopenNote) + } + } + } +} + +private const val FOLLOW_UNLOCK_GRACE_MS = 250L + @Composable fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockViewModel = metroViewModel()) { val host = LocalBiometricHost.current 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 index 3493f616..f5a61e60 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt @@ -11,13 +11,23 @@ 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. */ @@ -75,6 +85,9 @@ internal fun rememberNotesPointer(tabs: NotesTabs?, handlers: NoteMenuHandlers): 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( @@ -87,10 +100,30 @@ internal fun rememberNotesPointer(tabs: NotesTabs?, handlers: NoteMenuHandlers): } }, 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 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 index 37c4096a..f40ef528 100644 --- 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 @@ -10,6 +10,10 @@ 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 @@ -19,6 +23,7 @@ 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 @@ -29,17 +34,28 @@ import org.jetbrains.compose.resources.stringResource * Nothing else leaves the app: no share sheet, no temp file. */ @Composable -internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: MarkdownExportViewModel = metroViewModel()) { +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 { - (if (step.zip) zipLauncher else noteLauncher).launch(step.name) + val launcher = when { + step.pdf -> pdfLauncher + step.zip -> zipLauncher + else -> noteLauncher + } + launcher.launch(step.name) } catch (_: PickerUnavailableException) { viewModel.onSystemPickerUnavailable() return@LaunchedEffect @@ -49,7 +65,8 @@ internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: Markdown LaunchedEffect(state.message) { val message = state.message as? BackupMessage.Text ?: return@LaunchedEffect viewModel.clearMessage() - currentOnMessage(getString(message.id)) + // 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) { @@ -58,18 +75,36 @@ internal fun MarkdownExportHost(onMessage: (String) -> Unit, viewModel: Markdown 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) { +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 = stringResource(Res.string.markdown_export_warning_title), - text = stringResource(Res.string.markdown_export_warning_body), + 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/editnote/EditNoteScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteScreen.kt index 61d07afa..e0972449 100644 --- 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 @@ -49,7 +49,6 @@ 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.note_load_failed 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 @@ -66,8 +65,6 @@ 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.CalloutTone -import com.pasich.encly.presentation.designsystem.EnclyCallout import com.pasich.encly.presentation.designsystem.EnclyScrollbar import com.pasich.encly.presentation.designsystem.EnclySnackbarHost import com.pasich.encly.presentation.designsystem.NoteSkeleton @@ -81,6 +78,7 @@ 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 @@ -136,6 +134,7 @@ fun EditNoteScreen( 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) @@ -151,8 +150,10 @@ fun EditNoteScreen( SaveWhenPaused() if (pane != null) PaneTabBinding(pane, exits) - EditorShortcutCommands(overlays) - val blocksStart = HEADER_ITEMS + if (contentLoadFailed) 1 else 0 + 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, blocksStart) FocusNewNoteTitle(titleFocus) @@ -168,6 +169,7 @@ fun EditNoteScreen( EditorOverlays( overlays = overlays, exits = exits, + documents = documents, onManageTags = { navController.navigate(NavRoutes.EditTagRoute.name) }, onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, ) @@ -279,10 +281,12 @@ private fun rememberEditorOverlays(): EditorOverlaysState { } /** 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(), @@ -321,6 +325,7 @@ private fun EditorOverlays( EditNoteBottomSheet( isVisible = overlays.isEditMenuVisible, onDismiss = { overlays.isEditMenuVisible = false }, + documents = documents.available, onAction = { action -> when (action) { EditNoteBottomSheetAction.CLOSE_NO_SAVE -> exits.discardOrConfirm { @@ -338,6 +343,10 @@ private fun EditorOverlays( markdownViewModel.start(MarkdownExportTarget.SingleNote(id)) } + EditNoteBottomSheetAction.EXPORT_PDF -> documents.start(DocumentFormat.PDF) + + EditNoteBottomSheetAction.PRINT -> documents.start(DocumentFormat.PRINT) + EditNoteBottomSheetAction.COPY_MARKDOWN -> copyNoteAsMarkdown( clipboard, viewModel, @@ -381,6 +390,7 @@ private fun EditorBlocks( 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. @@ -415,18 +425,7 @@ private fun EditorBlocks( .padding(bottom = headerGap), ) } - if (contentLoadFailed) { - item(key = "unreadable") { - EnclyCallout( - text = stringResource(Res.string.note_load_failed), - tone = CalloutTone.WARNING, - modifier = Modifier.padding( - horizontal = EnclyTheme.spacing.gutter, - vertical = headerGap, - ), - ) - } - } + editorNotices(contentLoadFailed, editedElsewhere, headerGap) editorBlocks( host, viewModel.blocks, 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/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/EditorShortcutCommands.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommands.kt index d9daee9b..20ba54b2 100644 --- 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 @@ -4,17 +4,22 @@ 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 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), and Esc closes the find bar. + * 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), Esc closes the find bar, and Print and Export as PDF start the document export. */ @Composable -internal fun EditorShortcutCommands(overlays: EditorOverlaysState, viewModel: EditNoteViewModel = metroViewModel()) { - val commands = remember(viewModel, overlays) { +internal fun EditorShortcutCommands( + overlays: EditorOverlaysState, + documents: EditorDocumentExport, + viewModel: EditNoteViewModel = metroViewModel(), +) { + val commands = remember(viewModel, overlays, documents) { EditorCommands( find = { true.also { viewModel.openFind() } }, moveToTrash = { @@ -22,6 +27,8 @@ internal fun EditorShortcutCommands(overlays: EditorOverlaysState, viewModel: Ed allowed.also { if (it) overlays.isTrashDialogVisible = true } }, dismiss = { viewModel.find.value.isOpen.also { open -> if (open) viewModel.closeFind() } }, + print = { documents.start(DocumentFormat.PRINT) }, + exportPdf = { documents.start(DocumentFormat.PDF) }, ) } EditorCommandsBinding(commands) 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..7cccac8b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/NoteClaim.kt @@ -0,0 +1,30 @@ +package com.pasich.encly.presentation.screen.editnote + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.pasich.encly.platform.LocalNoteEditClaims +import com.pasich.encly.presentation.viewmodel.EditNoteViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +/** + * 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 and says so, instead of saving over the other window's work. 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 + DisposableEffect(claims, viewModel, noteId) { + val granted = claims.claim(noteId) + viewModel.setEditedElsewhere(!granted) + onDispose { if (granted) claims.release(noteId) } + } +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index be89c6e8..516fe84e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -86,8 +86,14 @@ sealed interface 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 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 index fcf7c9ac..2c06f69a 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -169,6 +169,23 @@ class EditNoteViewModel( 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 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()) @@ -311,7 +328,7 @@ class EditNoteViewModel( /** 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) } + 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. */ @@ -341,26 +358,26 @@ class EditNoteViewModel( /** 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)) } + 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 (!isReadTrashOnly) persistence.updateNote { it.copy(lineSpacing = value.ordinal) } + 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 (!isReadTrashOnly) persistence.updateNote { it.copy(textSize = null, lineSpacing = null) } + 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 || isReadTrashOnly) return + if (contentLoadFailed.value || readOnly) return _lockEditor.value = !_lockEditor.value } - private fun canEdit(): Boolean = !isReadTrashOnly && !_lockEditor.value + private fun canEdit(): Boolean = !readOnly && !_lockEditor.value // --- blocks ---------------------------------------------------------------------------- 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 index a493a3b2..d5d2275c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -64,6 +64,9 @@ class LockViewModel( 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 session is closed (again); an unlock reveal then must not open Home. */ fun isSessionLocked(): Boolean = sessionLockManager.locked.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 index 53f3f2d0..a32605f0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -11,6 +11,8 @@ 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 @@ -18,10 +20,14 @@ 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 @@ -36,14 +42,22 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.io.IOException -/** What a Markdown export writes. */ +/** 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 } /** @@ -96,12 +110,18 @@ class MarkdownExportViewModel( fun start(target: MarkdownExportTarget) { dropPending() this.target = target - state.go(BackupStep.ConfirmPlainExport) + 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) return + if (state.step != BackupStep.ConfirmPlainExport && state.step !is BackupStep.ConfirmDocumentExport) return val biometric = securityManager.canReauthWithBiometric() state.go(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric)) } @@ -112,27 +132,31 @@ class MarkdownExportViewModel( // 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 { + val bound = try { // Tied to this opening of the vault: after a re-lock it fails instead of reading. - session = store.bindToCurrentSession() + 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, - ) - }, - ) + 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) } /** @@ -164,7 +188,12 @@ class MarkdownExportViewModel( state.launchBusy { try { export(bound, what, uri) - state.finish(BackupMessage.Text(Res.string.markdown_export_done)) + 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) { @@ -184,6 +213,8 @@ class MarkdownExportViewModel( // 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. @@ -199,11 +230,53 @@ class MarkdownExportViewModel( 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) + val tagName = snapshot.tags.firstOrNull { it.id == note.tagId }?.nameTag + return NoteDocument.of(note, tagName, 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() + when (what.renderer.print(document)) { + 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 + Dispatchers.IO) { documents.discard(uri) } 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..6ae62633 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt @@ -0,0 +1,85 @@ +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 +import kotlin.test.assertNull + +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 aNoteBecomesADocumentWithItsTitleAndTag() { + val json = BlockConverter.blocksToJson(listOf(text("Body"))) + val note = Note(id = 1, title = " Plan ", value = json) + + val document = NoteDocument.of(note, tagName = " Work ", untitled = "Untitled", unreadable = "Unreadable") + + assertEquals("Plan", document.title) + assertEquals("Work", document.tag) + assertEquals(listOf(DocumentBlock.Paragraph("Body")), document.blocks) + } + + @Test + fun aNoteWithoutATitleOrTagIsNamedAndUntagged() { + val document = NoteDocument.of(Note(id = 1, title = " ", value = "[]"), null, "Untitled", "Unreadable") + + assertEquals("Untitled", document.title) + assertNull(document.tag) + assertEquals(emptyList(), document.blocks) + } + + @Test + fun aNoteThatCannotBeReadSaysSo() { + val document = NoteDocument.of(Note(id = 1, title = "T", value = "{not json"), "", "Untitled", "Unreadable") + + assertEquals(listOf(DocumentBlock.Paragraph("Unreadable")), document.blocks) + assertNull(document.tag) + } +} diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt index 1f6c5e64..5dba8c9d 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NavigationRequestsTest.kt @@ -16,6 +16,30 @@ class NavigationRequestsTest { 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() 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..14e54f64 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt @@ -0,0 +1,58 @@ +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)) + } +} 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 index 829eba81..4c9df8e7 100644 --- 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 @@ -168,6 +168,29 @@ class NotePersistenceTest { 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 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 index c707efd6..07dd341b 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouterTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/navigation/AppCommandRouterTest.kt @@ -100,6 +100,39 @@ class AppCommandRouterTest { 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) 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 index 7590ffa0..6363d388 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt @@ -137,6 +137,33 @@ class EditNoteViewModelLifecycleTest { 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 aNoteInTheTrashCannotBeUnlocked() = runTest(dispatcher) { storeNote(isTrash = true) From 96db7ed7cbad01711043ab7a137bcbc12901611f Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 21:25:32 +0200 Subject: [PATCH 30/75] feat(ios): crypto, vault and PIN key store for iOS The crypto core runs on iOS with the platform's own primitives: CommonCrypto for SHA-256, HMAC and PBKDF2, SecRandomCopyBytes, and CryptoKit's AES-GCM through a small Swift library. The golden vectors of every vault and backup format now run on JVM, Android and the iOS simulator, and CI runs them there. The PIN factor keeps its secret in the Keychain (this device only), wrapped by a Secure Enclave key where there is one. Links get nameprep on iOS so hosts match java.net.IDN. --- .github/workflows/android.yml | 23 +- .../src/main/kotlin/SwiftStaticLibrary.kt | 72 ++ .../src/main/kotlin/encly.kmp-ios.gradle.kts | 3 +- core/crypto/README.md | 17 +- core/crypto/api/crypto.klib.api | 47 + core/crypto/build.gradle.kts | 45 +- .../encly/core/crypto/CryptoBoundaryTest.kt | 18 +- .../encly/core/crypto/PlatformCrypto.kt | 189 +++ .../com/pasich/encly/core/crypto/Utf8Chars.kt | 88 ++ .../src/iosMain/swift/EnclyAesGcm.swift | 77 ++ .../encly/core/crypto/PlatformCryptoTest.kt | 111 ++ .../nativeInterop/cinterop/enclyaesgcm.def | 5 + .../src/nativeInterop/cinterop/enclyaesgcm.h | 23 + core/keystore/api/keystore.klib.api | 18 + core/keystore/build.gradle.kts | 15 +- .../pasich/encly/core/keystore/SecretStore.kt | 15 +- .../core/keystore/InMemorySecretStore.kt | 4 +- .../SecretStoreUnavailableException.kt | 7 + .../encly/core/keystore/CfDictionary.kt | 118 ++ .../encly/core/keystore/IosPinFactor.kt | 119 ++ .../core/keystore/KeychainSecretStore.kt | 156 +++ .../core/keystore/SecureEnclaveWrapper.kt | 170 +++ .../encly/core/keystore/IosPinFactorTest.kt | 205 ++++ .../core/keystore/KeychainSecretStoreTest.kt | 133 +++ core/security/README.md | 2 +- core/security/api/security.klib.api | 1039 +++++++++++++++++ core/security/build.gradle.kts | 9 +- .../encly/core/backup/BackupCipherTest.kt | 8 +- .../encly/core/backup/BackupFormatTest.kt | 2 +- .../core/backup/BackupPayloadCodecTest.kt | 4 +- .../encly/core/security/Bip39GoldenTest.kt | 52 +- .../core/security/Bip39OfficialVectors.kt | 91 ++ .../encly/core/security/CryptoGoldenTest.kt | 192 ++- .../encly/core/security/GoldenVectors.kt | 0 .../encly/core/security/PinSlotsTest.kt | 6 +- .../pasich/encly/testutil/FakeLockoutClock.kt | 9 + .../pasich/encly/testutil/HmacSha256Test.kt | 32 + .../encly/testutil/InMemoryVaultFileIo.kt | 26 + .../com/pasich/encly/testutil/TestHmac.kt | 17 + .../core/security/SecurityBoundaryTest.kt | 2 +- .../pasich/encly/core/backup/IosJsonBytes.kt | 66 ++ .../encly/core/security/IosVaultFileIo.kt | 119 ++ .../encly/core/security/IosVaultFileIoTest.kt | 100 ++ .../{SecurityFakes.kt => FakePinFactor.kt} | 7 - .../resources/bip39-english-vectors.txt | 26 - .../encly/editor/ui/blocks/AsciiHost.kt | 4 +- .../editor/codec/MarkdownBlockMapperTest.kt | 6 +- .../encly/editor/ui/blocks/AsciiHostTest.kt | 81 ++ .../encly/editor/ui/blocks/AsciiHost.ios.kt | 2 +- .../pasich/encly/editor/ui/blocks/Nameprep.kt | 107 ++ 50 files changed, 3470 insertions(+), 217 deletions(-) create mode 100644 build-logic/src/main/kotlin/SwiftStaticLibrary.kt create mode 100644 core/crypto/api/crypto.klib.api create mode 100644 core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/PlatformCrypto.kt create mode 100644 core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/Utf8Chars.kt create mode 100644 core/crypto/src/iosMain/swift/EnclyAesGcm.swift create mode 100644 core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/PlatformCryptoTest.kt create mode 100644 core/crypto/src/nativeInterop/cinterop/enclyaesgcm.def create mode 100644 core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h create mode 100644 core/keystore/api/keystore.klib.api rename core/keystore/src/{desktopMain => commonMain}/kotlin/com/pasich/encly/core/keystore/SecretStore.kt (67%) rename core/keystore/src/{desktopTest => commonTest}/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt (91%) create mode 100644 core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStoreUnavailableException.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/CfDictionary.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt create mode 100644 core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/IosPinFactorTest.kt create mode 100644 core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt create mode 100644 core/security/api/security.klib.api rename core/security/src/{jvmAndAndroidTest => commonTest}/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt (71%) create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39OfficialVectors.kt rename core/security/src/{jvmAndAndroidTest => commonTest}/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt (69%) rename core/security/src/{jvmAndAndroidTest => commonTest}/kotlin/com/pasich/encly/core/security/GoldenVectors.kt (100%) create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/testutil/FakeLockoutClock.kt create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/testutil/HmacSha256Test.kt create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultFileIo.kt create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/testutil/TestHmac.kt create mode 100644 core/security/src/iosMain/kotlin/com/pasich/encly/core/backup/IosJsonBytes.kt create mode 100644 core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosVaultFileIo.kt create mode 100644 core/security/src/iosTest/kotlin/com/pasich/encly/core/security/IosVaultFileIoTest.kt rename core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/{SecurityFakes.kt => FakePinFactor.kt} (89%) delete mode 100644 core/security/src/jvmAndAndroidTest/resources/bip39-english-vectors.txt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt create mode 100644 editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/Nameprep.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 93346f49..10396a7d 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -509,10 +509,11 @@ jobs: retention-days: 7 if-no-files-found: ignore - # The KMP libraries with iOS targets (:editor, :core:common, :core:database) 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); it runs no iOS tests (the logic is covered on - # the JVM). The job keeps its name so that the name of the check does not change. + # 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. The job keeps its name so + # that the name of the check does not change. editor-ios: if: github.actor != 'dependabot[bot]' name: editor (iOS compile) @@ -544,12 +545,24 @@ jobs: :core:database:compileKotlinIosArm64 :core:resources:compileKotlinIosSimulatorArm64 :core:resources:compileKotlinIosArm64 + :core:crypto:compileKotlinIosArm64 + :core:security:compileKotlinIosArm64 + :core:keystore:compileKotlinIosArm64 + + - name: iOS tests (crypto, security, key store) + run: >- + ./gradlew + :core:crypto:iosSimulatorArm64Test + :core:security:iosSimulatorArm64Test + :core:keystore: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 + run: >- + ./gradlew :editor:checkKotlinAbi :core:common:checkKotlinAbi :core:database:checkKotlinAbi + :core:crypto:checkKotlinAbi :core:security:checkKotlinAbi :core:keystore:checkKotlinAbi # 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 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.kmp-ios.gradle.kts b/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts index 100237f7..fe2b559b 100644 --- a/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts +++ b/build-logic/src/main/kotlin/encly.kmp-ios.gradle.kts @@ -1,6 +1,5 @@ // 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; `:core:crypto` and the modules -// above it have no iOS implementation of AES-GCM yet and stay without it. +// A module applies it only when everything it depends on can run on iOS. plugins { id("org.jetbrains.kotlin.multiplatform") diff --git a/core/crypto/README.md b/core/crypto/README.md index 507b9334..3bdfc912 100644 --- a/core/crypto/README.md +++ b/core/crypto/README.md @@ -11,15 +11,20 @@ The one door to cryptographic primitives, and BIP-39. |---|---|---| | Android | `commonMain`, `jvmAndAndroidMain` | `androidHostTest` | | Desktop JVM | `commonMain`, `jvmAndAndroidMain` | `desktopTest` (also `CryptoBoundaryTest`) | +| iOS (device, simulator) | `commonMain`, `iosMain` | `iosSimulatorArm64Test` (`CryptoTest` and the BIP-39 tests run there too) | `jvmAndAndroidMain` holds the JCA implementation (`JcaCrypto`) both JVM targets share. ## iOS -There are no iOS targets yet, on purpose. Apple's public CommonCrypto gives SHA-256, HMAC, PBKDF2 -(`CCKeyDerivationPBKDF`) and `SecRandomCopyBytes`, but no AES-GCM (`CCCryptorGCM` is private API), and CryptoKit -cannot be called from Kotlin/Native without a Swift bridge. A placeholder that does weaker crypto is not an option, -so the targets are added (`id("encly.kmp-ios")`) together with a real AES-GCM implementation. Until then -`:core:security` has no iOS target either. +`iosMain` uses only Apple's own libraries, no cryptography is written by hand: CommonCrypto for SHA-256, HMAC (HKDF is +RFC 5869 over it, as on the JVM) and PBKDF2 (`CCKeyDerivationPBKDF`), `SecRandomCopyBytes` for randomness, +`timingsafe_bcmp` for the comparison and CryptoKit's `AES.GCM`. CommonCrypto has no public AES-GCM and CryptoKit is +Swift-only, so `src/iosMain/swift/EnclyAesGcm.swift` exports two C functions over it (`@_cdecl`, declared in +`src/nativeInterop/cinterop/enclyaesgcm.h`). Gradle compiles it per SDK with `SwiftStaticLibrary` (`build-logic`; needs +Xcode) and cinterop links the static library into the klib. Keys and data cross as raw pointers into the Kotlin arrays, +so they can be wiped like on the JVM. The golden vectors of `CryptoTest` and `:core:security` pin the bytes: they are +the same on every platform. -Line and branch coverage are gated at 100 %. +Line and branch coverage are gated at 100 % (Kover measures the JVM targets; the iOS code is covered by +`iosSimulatorArm64Test`). diff --git a/core/crypto/api/crypto.klib.api b/core/crypto/api/crypto.klib.api new file mode 100644 index 00000000..1663b139 --- /dev/null +++ b/core/crypto/api/crypto.klib.api @@ -0,0 +1,47 @@ +// 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.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/build.gradle.kts b/core/crypto/build.gradle.kts index 9963da03..0f5a525e 100644 --- a/core/crypto/build.gradle.kts +++ b/core/crypto/build.gradle.kts @@ -1,15 +1,17 @@ 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, shared by the Android and the -// desktop target (jvmAndAndroidMain). -// -// There are no iOS targets: Apple's public CommonCrypto has no AES-GCM (CCCryptorGCM is private API) and -// CryptoKit cannot be called from Kotlin/Native without a Swift bridge, so a correct AES-GCM for iOS needs -// a decision about that bridge first. A placeholder with weaker crypto is not an option, so the targets -// are added together with the real implementation. +// 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") } @@ -32,6 +34,35 @@ kotlin { } } +// The Swift bridge exists only where Xcode does; on other hosts Kotlin does not build the Apple targets. +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 { 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 index 7e6074fd..40f0f4c8 100644 --- 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 @@ -59,7 +59,7 @@ class CryptoBoundaryTest { @Test fun onlyTheJvmSourceSetHoldsPlatformCode() { val platformSets = File(moduleRoot, "src").listFiles().orEmpty().map { it.name }.filter { it.endsWith("Main") } - assertEquals(listOf("commonMain", "jvmAndAndroidMain"), platformSets.sorted()) + assertEquals(listOf("commonMain", "iosMain", "jvmAndAndroidMain"), platformSets.sorted()) } @Test @@ -70,9 +70,17 @@ class CryptoBoundaryTest { } @Test - fun theModuleHasNoIosTargetsUntilAesGcmExistsThere() { - val build = File(moduleRoot, "build.gradle.kts").readText() - assertFalse(Regex("""id\("encly\.kmp-ios"\)""").containsMatchIn(build), "iOS needs a real AES-GCM first") + 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 @@ -100,6 +108,8 @@ class CryptoBoundaryTest { "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( 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..92926c4c --- /dev/null +++ b/core/crypto/src/iosMain/swift/EnclyAesGcm.swift @@ -0,0 +1,77 @@ +import CryptoKit +import Foundation + +// AES-256-GCM for Kotlin/Native. CommonCrypto has no public GCM and CryptoKit is Swift-only, so this file +// is the one Swift seam of the module: two C-callable functions (declared in enclyaesgcm.h) over +// CryptoKit's AES.GCM. 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.. { + 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/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..9a413881 --- /dev/null +++ b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h @@ -0,0 +1,23 @@ +#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); + +#endif diff --git a/core/keystore/api/keystore.klib.api b/core/keystore/api/keystore.klib.api new file mode 100644 index 00000000..185ef2c8 --- /dev/null +++ b/core/keystore/api/keystore.klib.api @@ -0,0 +1,18 @@ +// 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/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 index 1b95f807..f4675ddb 100644 --- a/core/keystore/build.gradle.kts +++ b/core/keystore/build.gradle.kts @@ -8,6 +8,7 @@ import kotlinx.kover.gradle.plugin.dsl.CoverageUnit // every module (the app has its own Keystore implementation); it compiles nothing. plugins { id("encly.kmp-library") + id("encly.kmp-ios") id("encly.quality") } @@ -22,10 +23,15 @@ kotlin { } sourceSets { - getByName("desktopMain").dependencies { - // PinHardwareFactor and PinFactorException are part of the public contract of DesktopPinFactor. + 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) @@ -40,6 +46,11 @@ kotlin { } } +// 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 { diff --git a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt b/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt similarity index 67% rename from core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt rename to core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt index 13617693..d5de1b0e 100644 --- a/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt +++ b/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt @@ -3,9 +3,10 @@ package com.pasich.encly.core.keystore import com.pasich.encly.core.security.PinKeySlot /** - * The operating system's secret store, as far as [DesktopPinFactor] needs it: one 32-byte secret per - * [PinKeySlot], which only the signed-in OS user can read back. Implementations never write the secret - * anywhere unprotected, never log it, and wipe every copy they hold once they have handed it over. + * The operating system's secret store, as far as the PIN factors need it ([DesktopPinFactor] on the desktop, the iOS + * factor on iOS): one 32-byte secret per [PinKeySlot], which only the signed-in OS user can read back. + * 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. */ @@ -13,7 +14,7 @@ internal interface SecretStore { fun read(slot: PinKeySlot): ByteArray? /** - * Generates [SECRET_SIZE] random bytes from SecureRandom, stores them as the secret of [slot] (replacing + * 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) @@ -45,9 +46,3 @@ internal const val APPLICATION_ID = "com.pasich.encly" */ internal class SecretStoreException(val lost: Boolean, message: String, cause: Throwable? = null) : Exception(message, cause) - -/** - * 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/desktopTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt b/core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt similarity index 91% rename from core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt rename to core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt index 70906272..fadb8fd4 100644 --- a/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt +++ b/core/keystore/src/commonTest/kotlin/com/pasich/encly/core/keystore/InMemorySecretStore.kt @@ -1,7 +1,7 @@ package com.pasich.encly.core.keystore +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.core.security.PinKeySlot -import java.security.SecureRandom /** A [SecretStore] in memory, with failures to inject and a record of every array it handed out. */ internal class InMemorySecretStore : SecretStore { @@ -28,7 +28,7 @@ internal class InMemorySecretStore : SecretStore { override fun create(slot: PinKeySlot): ByteArray { createFailure?.let { throw it } - val secret = ByteArray(SecretStore.SECRET_SIZE).also(SecureRandom()::nextBytes) + val secret = Crypto.randomBytes(SecretStore.SECRET_SIZE) secrets[slot] = secret return secret.copyOf().also { handedOut += it } } 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/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..7ab7c072 --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt @@ -0,0 +1,119 @@ +@file:OptIn(ExperimentalForeignApi::class) + +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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.CoreCrypto.CCHmac +import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH +import platform.CoreCrypto.kCCHmacAlgSHA256 + +/** + * [PinHardwareFactor] for iOS. The contract is the one of the Android Keystore and the desktop factors: + * `hw = HMAC-SHA256(deviceSecret, data)`, where the caller passes `data = salt ‖ pin`, so a vault made on one + * platform derives the same PIN key from the same secret. + * + * The `deviceSecret` is 32 random bytes from the platform CSPRNG, created once per slot and kept only in the + * Keychain of this device, available while it is unlocked, never synced or restored onto another device. Where + * the Secure Enclave accepts it, the Keychain holds the secret only encrypted to an Enclave key (see + * [KeychainSecretStore]); otherwise (the Simulator, an old device) it holds the secret itself, which is as safe + * as the Keychain and the passcode. The HMAC is CommonCrypto's, in this process: the Enclave only supports EC + * keys, so it protects the secret at rest rather than computing the MAC. + * + * States, as on the desktop: + * - [ensureKey] creates the secret when there is none and returns true then. A secret that is there but cannot + * be read for good counts as existing (like an invalidated Android key): [mac] reports it as lost and [reset] + * replaces it. + * - [mac] with no secret, or one of the wrong size, fails with `PinFactorException(lost = true)`; any other + * failure of the store (locked device, I/O) is `lost = false` and may pass. + * - [delete] never throws. + * + * Every copy of the secret this class controls is wiped after use. + */ +public class IosPinFactor 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) { + 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. + } + } + + public companion object { + /** The factor on this device's Keychain. */ + public fun create(): IosPinFactor = IosPinFactor(KeychainSecretStore()) + } +} + +internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { + val mac = ByteArray(CC_SHA256_DIGEST_LENGTH) + // An empty array has no first element to point at; CommonCrypto takes a null pointer with a length of 0. + key.usePinnedOrNull { keyPointer -> + data.usePinnedOrNull { dataPointer -> + mac.usePinned { + CCHmac( + kCCHmacAlgSHA256, + keyPointer, + key.size.convert(), + dataPointer, + data.size.convert(), + it.addressOf(0), + ) + } + } + } + return mac +} + +private inline fun ByteArray.usePinnedOrNull( + block: (kotlinx.cinterop.CPointer?) -> R, +): R = if (isEmpty()) block(null) else usePinned { block(it.addressOf(0).reinterpret()) } diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt new file mode 100644 index 00000000..43f8addc --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt @@ -0,0 +1,156 @@ +@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.crypto.Crypto +import com.pasich.encly.core.security.PinKeySlot +import com.pasich.encly.core.security.SensitiveDataCleaner +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.CFRelease +import platform.CoreFoundation.CFTypeRefVar +import platform.Security.SecItemAdd +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAttrAccessible +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +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.kSecReturnData +import platform.Security.kSecValueData + +/** + * [SecretStore] on iOS: one generic-password item per [PinKeySlot] in the app's Keychain (service + * `com.pasich.encly`, account `pin-factor-v1` or `pin-factor-v1-b`), readable only while the device is unlocked + * (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`), so it is not in iCloud Keychain, not in a backup that is + * restored to another device, and not readable in the background of a locked phone. + * + * What the item holds is a format byte and a payload: + * - [FORMAT_KEYCHAIN]: the 32-byte secret itself. This is the only path on the Simulator and on a device + * without a usable Secure Enclave, and the path every test in this repository exercises. + * - [FORMAT_ENCLAVE]: the secret encrypted to a Secure Enclave key by [SecureEnclaveWrapper]; decrypting needs + * the Enclave of this device. Chosen whenever the Enclave accepts the key, and recorded in the item, so a + * read always follows the path that wrote it and a secret never silently moves between them. + * + * Pass `enclave = null` to keep the secret in the Keychain only. + */ +internal class KeychainSecretStore( + private val service: String = APPLICATION_ID, + private val enclave: SecureEnclaveWrapper? = SecureEnclaveWrapper(service), +) : SecretStore { + @Throws(SecretStoreException::class) + override fun read(slot: PinKeySlot): ByteArray? { + val record = copyRecord(slot) ?: return null + try { + if (record.isEmpty()) throw SecretStoreException(lost = true, message = "empty Keychain item") + val payload = record.copyOfRange(1, record.size) + try { + return when (record[0]) { + FORMAT_KEYCHAIN -> payload.copyOf() + + FORMAT_ENCLAVE -> enclave?.unwrap(slot, payload) + ?: throw SecretStoreException(lost = true, message = "no Secure Enclave for a wrapped secret") + + else -> throw SecretStoreException(lost = true, message = "unknown Keychain item format") + } + } finally { + SensitiveDataCleaner.clear(payload) + } + } finally { + SensitiveDataCleaner.clear(record) + } + } + + @Throws(SecretStoreException::class) + override fun create(slot: PinKeySlot): ByteArray { + val secret = Crypto.randomBytes(SecretStore.SECRET_SIZE) + try { + val wrapped = enclave?.wrap(slot, secret) + val record = if (wrapped != null) { + byteArrayOf(FORMAT_ENCLAVE) + wrapped + } else { + byteArrayOf(FORMAT_KEYCHAIN) + secret + } + try { + writeRecord(slot, record) + } finally { + SensitiveDataCleaner.clear(record) + } + return secret + } catch (e: SecretStoreException) { + SensitiveDataCleaner.clear(secret) + throw e + } + } + + @Throws(SecretStoreException::class) + override fun delete(slot: PinKeySlot) { + enclave?.deleteKey(slot) + deleteItem(slot) + } + + private fun deleteItem(slot: PinKeySlot) { + val status = withCfDictionary { query -> SecItemDelete(itemQuery(query, slot).ref) } + if (status != errSecSuccess && status != errSecItemNotFound) { + throw SecretStoreException(lost = false, message = "Keychain delete: $status") + } + } + + private fun copyRecord(slot: PinKeySlot): ByteArray? = withCfDictionary { query -> + itemQuery(query, slot).putBoolean(kSecReturnData, true).put(kSecMatchLimit, kSecMatchLimitOne) + memScoped { + val result = alloc() + when (val status = SecItemCopyMatching(query.ref, result.ptr)) { + errSecSuccess -> { + val data: CFDataRef = checkNotNull(result.value).reinterpret() + cfDataToBytes(data).also { CFRelease(data) } + } + + errSecItemNotFound -> null + + else -> throw SecretStoreException(lost = false, message = "Keychain read: $status") + } + } + } + + private fun writeRecord(slot: PinKeySlot, record: ByteArray) { + // Replace, not update: an item left by an earlier secret must not survive a failed write. + deleteItem(slot) + val status = withCfDictionary { item -> + itemQuery(item, slot) + .put(kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly) + .putBytes(kSecValueData, record) + SecItemAdd(item.ref, null) + } + if (status != errSecSuccess) throw SecretStoreException(lost = false, message = "Keychain write: $status") + } + + private fun itemQuery(query: CfDictionary, slot: PinKeySlot): CfDictionary = query + .put(kSecClass, kSecClassGenericPassword) + .putString(kSecAttrService, service) + .putString(kSecAttrAccount, slot.entryName()) + .putBoolean(kSecAttrSynchronizable, false) + + /** Which path wrote the item of [slot] (a `FORMAT_` value), or null when there is none; for tests. */ + fun recordFormat(slot: PinKeySlot): Byte? = copyRecord(slot)?.let { record -> + record.firstOrNull().also { SensitiveDataCleaner.clear(record) } + } + + internal companion object { + const val FORMAT_KEYCHAIN: Byte = 1 + const val FORMAT_ENCLAVE: Byte = 2 + } +} diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt new file mode 100644 index 00000000..33589b5c --- /dev/null +++ b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt @@ -0,0 +1,170 @@ +@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.SecAccessControlCreateWithFlags +import platform.Security.SecItemCopyMatching +import platform.Security.SecItemDelete +import platform.Security.SecKeyCopyPublicKey +import platform.Security.SecKeyCreateDecryptedData +import platform.Security.SecKeyCreateEncryptedData +import platform.Security.SecKeyCreateRandomKey +import platform.Security.SecKeyRef +import platform.Security.errSecItemNotFound +import platform.Security.errSecSuccess +import platform.Security.kSecAccessControlPrivateKeyUsage +import platform.Security.kSecAttrAccessControl +import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly +import platform.Security.kSecAttrApplicationTag +import platform.Security.kSecAttrIsPermanent +import platform.Security.kSecAttrKeySizeInBits +import platform.Security.kSecAttrKeyType +import platform.Security.kSecAttrKeyTypeECSECPrimeRandom +import platform.Security.kSecAttrTokenID +import platform.Security.kSecAttrTokenIDSecureEnclave +import platform.Security.kSecClass +import platform.Security.kSecClassKey +import platform.Security.kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM +import platform.Security.kSecMatchLimit +import platform.Security.kSecMatchLimitOne +import platform.Security.kSecPrivateKeyAttrs +import platform.Security.kSecReturnRef + +/** + * Wraps the device secret of a [PinKeySlot] with a key that never leaves the Secure Enclave: an EC P-256 private + * key created inside it (`kSecAttrTokenIDSecureEnclave`, usable only while the device is unlocked, never + * synchronized or backed up), and ECIES (`eciesEncryptionCofactorX963SHA256AESGCM`) for the secret itself. The + * public half encrypts without the hardware; only decrypting asks the Enclave. The wrapped blob is what + * [KeychainSecretStore] keeps in the Keychain, so a copy of the Keychain alone no longer holds the secret. + * + * The iOS Simulator has no Secure Enclave and some devices (and a locked one) cannot use it: [wrap] then returns + * null and the store keeps the secret in the Keychain only. Nothing here runs on the Simulator past that first + * check, so the Enclave path itself is exercised only on a device. + */ +internal class SecureEnclaveWrapper(private val service: String) { + /** + * [secret] encrypted to a new Enclave key for [slot] (replacing the key it had), or null when the Secure + * Enclave cannot be used. The blob is not secret. + */ + fun wrap(slot: PinKeySlot, secret: ByteArray): ByteArray? { + deleteKey(slot) + val privateKey = createKey(slot) ?: return null + val blob = try { + encrypt(privateKey, secret) + } finally { + CFRelease(privateKey) + } + if (blob == null) deleteKey(slot) + return blob + } + + /** ECIES with the public half of [privateKey], which needs no Enclave call. */ + private fun encrypt(privateKey: SecKeyRef, secret: ByteArray): ByteArray? { + val publicKey = SecKeyCopyPublicKey(privateKey) ?: return null + val plain = newSecretData(secret) + val sealed = SecKeyCreateEncryptedData(publicKey, ALGORITHM, plain, null) + zeroizeAndRelease(plain) + CFRelease(publicKey) + return sealed?.let { cfDataToBytes(it).also { _ -> CFRelease(it) } } + } + + /** + * The secret [wrap] sealed. A missing Enclave key, or a blob it refuses, means the secret is gone for good + * (`lost`); anything else (a locked device, a cancelled prompt) may pass. + */ + @Throws(SecretStoreException::class) + fun unwrap(slot: PinKeySlot, blob: ByteArray): ByteArray { + val privateKey = + findKey(slot) ?: throw SecretStoreException(lost = true, message = "Secure Enclave key is gone") + try { + val cipher = newSecretData(blob) + try { + memScoped { + val error = alloc() + val plain = SecKeyCreateDecryptedData(privateKey, ALGORITHM, cipher, error.ptr) + if (plain == null) { + val code = error.value?.let { CFErrorGetCode(it).toInt().also { _ -> CFRelease(error.value) } } + throw SecretStoreException(lost = code in LOST_CODES, message = "Secure Enclave decrypt: $code") + } + return cfDataToBytes(plain).also { CFRelease(plain) } + } + } finally { + zeroizeAndRelease(cipher) + } + } finally { + CFRelease(privateKey) + } + } + + /** Removes the Enclave key of [slot]; none is not an error. */ + fun deleteKey(slot: PinKeySlot) { + withCfDictionary { query -> + SecItemDelete( + query.put(kSecClass, kSecClassKey).putBytes(kSecAttrApplicationTag, tag(slot)).ref, + ) + } + } + + private fun createKey(slot: PinKeySlot): SecKeyRef? { + val accessControl = SecAccessControlCreateWithFlags( + null, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecAccessControlPrivateKeyUsage, + null, + ) ?: return null + val privateAttributes = CfDictionary() + val attributes = CfDictionary() + try { + privateAttributes.putBoolean(kSecAttrIsPermanent, true) + .putBytes(kSecAttrApplicationTag, tag(slot)) + .put(kSecAttrAccessControl, accessControl) + attributes.put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom) + .putInt(kSecAttrKeySizeInBits, KEY_BITS) + .put(kSecAttrTokenID, kSecAttrTokenIDSecureEnclave) + .put(kSecPrivateKeyAttrs, privateAttributes.ref) + return SecKeyCreateRandomKey(attributes.ref, null) + } finally { + attributes.release() + privateAttributes.release() + CFRelease(accessControl) + } + } + + 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() + val status = SecItemCopyMatching(query.ref, result.ptr) + when (status) { + errSecSuccess -> result.value as SecKeyRef? + errSecItemNotFound -> null + else -> throw SecretStoreException(lost = false, message = "Secure Enclave key lookup: $status") + } + } + } + + private fun tag(slot: PinKeySlot): ByteArray = "$service.enclave.${slot.entryName()}".encodeToByteArray() + + private companion object { + val ALGORITHM = kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM + const val KEY_BITS = 256 + + // errSecDecode (-26275) and errSecParam (-50): the blob does not belong to the key. + val LOST_CODES = setOf(-26275, -50) + } +} 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..90d7bb08 --- /dev/null +++ b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/IosPinFactorTest.kt @@ -0,0 +1,205 @@ +package com.pasich.encly.core.keystore + +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.assertNull +import kotlin.test.assertTrue + +/** + * The iOS factor over an in-memory store, so the states are tested without a Keychain. The HMAC golden values are + * the ones DesktopPinFactorTest pins, computed with the JCA: the same secret and data give the same bytes on iOS. + */ +class IosPinFactorTest { + private val store = InMemorySecretStore() + private val factor = IosPinFactor(store) + + private val salt = ByteArray(16) { (0xA0 + it).toByte() } + private val pin = "482913".encodeToByteArray() + 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()) + } + + @Test + fun aSecondGoldenVectorWithAnotherSecret() { + store.put(PinKeySlot.B, ByteArray(32) { 0xFF.toByte() }) + + assertEquals( + "412acd40567e42ba035be3fd49c094c6cc015c5a9921e633e52bf0235e0f0755", + factor.mac(PinKeySlot.B, data).toHex(), + ) + } + + @Test + fun hmacHandlesEmptyKeyAndData() { + // RFC 4231 has no empty vector; HMAC-SHA256 of nothing under an empty key is a known constant. + assertEquals( + "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad", + hmacSha256(ByteArray(0), ByteArray(0)).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".encodeToByteArray()))) + } + + @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() { + assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.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 entriesAreNamedPerSlot() { + assertEquals("pin-factor-v1", PinKeySlot.A.entryName()) + assertEquals("pin-factor-v1-b", PinKeySlot.B.entryName()) + } + + @Test + fun createBuildsTheFactorOnTheKeychainWithoutTouchingIt() { + IosPinFactor.create() + } +} + +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/KeychainSecretStoreTest.kt b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt new file mode 100644 index 00000000..0edac458 --- /dev/null +++ b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt @@ -0,0 +1,133 @@ +package com.pasich.encly.core.keystore + +import com.pasich.encly.core.security.PinFactorException +import com.pasich.encly.core.security.PinKeySlot +import platform.Foundation.NSProcessInfo +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.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The real Keychain of the iOS Simulator (an item under a service name of its own, removed afterwards). The + * Simulator has no Secure Enclave, so these tests run the Keychain-only path of [KeychainSecretStore]; the + * Enclave-wrapped path (`FORMAT_ENCLAVE`) is compiled but can only be exercised on a device, and is not. + */ +class KeychainSecretStoreTest { + private val service = "com.pasich.encly.test." + NSUUID().UUIDString + private val keychainOnly = KeychainSecretStore(service, enclave = null) + private val defaultStore = KeychainSecretStore(service) + + /** + * Whether the Keychain answers at all. A plain test executable has no application identifier, and the Simulator + * then refuses every SecItem call with errSecNotAvailable (-25291); such a run cannot touch the Keychain, so the + * tests that need one 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. + */ + private val keychainAvailable: Boolean = try { + keychainOnly.read(PinKeySlot.A) + true + } catch (_: SecretStoreException) { + false + } + + @AfterTest + fun cleanUp() { + if (keychainAvailable) PinKeySlot.entries.forEach { keychainOnly.delete(it) } + } + + @Test + fun withoutAKeychainEveryCallIsAStoreFailureThatIsNotLost() { + if (keychainAvailable) return + + assertFalse(assertFailsWith { keychainOnly.read(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { keychainOnly.create(PinKeySlot.A) }.lost) + assertFalse(assertFailsWith { keychainOnly.delete(PinKeySlot.A) }.lost) + val factor = IosPinFactor(keychainOnly) + assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, ByteArray(4)) }.lost) + } + + @Test + fun aMissingItemReadsAsNull() { + if (!keychainAvailable) return + + assertNull(keychainOnly.read(PinKeySlot.A)) + } + + @Test + fun createStoresASecretThatReadReturns() { + if (!keychainAvailable) return + + val created = keychainOnly.create(PinKeySlot.A) + + assertEquals(SecretStore.SECRET_SIZE, created.size) + assertContentEquals(created, keychainOnly.read(PinKeySlot.A)) + assertNull(keychainOnly.read(PinKeySlot.B)) + } + + @Test + fun createReplacesAnEarlierSecretAndDeleteRemovesIt() { + if (!keychainAvailable) return + + val first = keychainOnly.create(PinKeySlot.A) + val second = keychainOnly.create(PinKeySlot.A) + + assertFalse(first.contentEquals(second)) + assertContentEquals(second, keychainOnly.read(PinKeySlot.A)) + + keychainOnly.delete(PinKeySlot.A) + assertNull(keychainOnly.read(PinKeySlot.A)) + keychainOnly.delete(PinKeySlot.A) + } + + @Test + fun theSlotsHoldSeparateSecrets() { + if (!keychainAvailable) return + + val a = keychainOnly.create(PinKeySlot.A) + val b = keychainOnly.create(PinKeySlot.B) + + assertFalse(a.contentEquals(b)) + assertContentEquals(a, keychainOnly.read(PinKeySlot.A)) + assertContentEquals(b, keychainOnly.read(PinKeySlot.B)) + } + + @Test + fun theDefaultStoreFallsBackToTheKeychainWhereTheEnclaveIsMissing() { + if (!keychainAvailable) return + + val created = defaultStore.create(PinKeySlot.A) + + val format = assertNotNull(defaultStore.recordFormat(PinKeySlot.A)) + val simulator = NSProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != null + // The Simulator has no Secure Enclave, so the secret is in the Keychain as it is. + if (simulator) assertEquals(KeychainSecretStore.FORMAT_KEYCHAIN, format) + assertContentEquals(created, defaultStore.read(PinKeySlot.A)) + } + + @Test + fun theFactorOnTheRealKeychainFollowsTheDesktopContract() { + if (!keychainAvailable) return + + val factor = IosPinFactor(keychainOnly) + val data = ByteArray(22) { it.toByte() } + + 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/security/README.md b/core/security/README.md index 4b89b7b0..601329dd 100644 --- a/core/security/README.md +++ b/core/security/README.md @@ -21,4 +21,4 @@ atomicfu `SynchronizedObject`s (the same critical sections as the `synchronized` | Android | `commonMain`, `jvmAndAndroidMain` | `androidHostTest` | | Desktop JVM | `commonMain`, `jvmAndAndroidMain` | `desktopTest` (also `SecurityBoundaryTest`) | -No iOS targets while `:core:crypto` has none. Line coverage is gated at 85 %. +iOS targets (device and simulator) with the golden tests of every format running on the simulator. Line coverage is gated at 85 %. diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api new file mode 100644 index 00000000..308dfa18 --- /dev/null +++ b/core/security/api/security.klib.api @@ -0,0 +1,1039 @@ +// 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_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 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] + + 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/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] +} + +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/String?, 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.String?;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 tagUid // com.pasich.encly.core.backup/BackupNote.tagUid|{}tagUid[0] + final fun (): kotlin/String? // com.pasich.encly.core.backup/BackupNote.tagUid.|(){}[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/String? // 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/String? = ..., 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.String?;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 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) // com.pasich.encly.core.backup/BackupPayload.|(kotlin.Int;kotlin.Long;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[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 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 copy(kotlin/Int = ..., kotlin/Long = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): 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){}[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_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 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/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 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 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 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/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){}[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 lockNow() // com.pasich.encly.core.security/SessionLockManager.lockNow|lockNow(){}[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/Boolean // com.pasich.encly.core.security/SessionLockManager.onUnlocked|onUnlocked(){}[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/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){}[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): 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){}[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/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){}[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 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.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/PIN_LENGTH // com.pasich.encly.core.security/PIN_LENGTH|{}PIN_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/PIN_LENGTH.|(){}[0] diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts index 7ea37dfd..09da64a7 100644 --- a/core/security/build.gradle.kts +++ b/core/security/build.gradle.kts @@ -7,9 +7,12 @@ import kotlinx.kover.gradle.plugin.dsl.CoverageUnit // 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. // -// There are no iOS targets because :core:crypto has none yet (see its build file). +// 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) @@ -53,6 +56,10 @@ kotlin { 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) diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt index 2e0c0ed1..bba1f931 100644 --- a/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupCipherTest.kt @@ -10,7 +10,7 @@ import kotlin.test.fail class BackupCipherTest { private val words = Mnemonic.generate() - private val plaintext = "{\"notes\":[\"secret note body\"]}".toByteArray() + private val plaintext = "{\"notes\":[\"secret note body\"]}".encodeToByteArray() private fun phrase(chars: CharArray = words) = BackupSecret.RecoveryPhrase(chars.copyOf()) @@ -33,7 +33,7 @@ class BackupCipherTest { @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() assertContentEquals(plaintext, BackupCipher.open(file, phrase(sloppy))) } @@ -60,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 @@ -129,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 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 index 9fe23240..666cacf6 100644 --- 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 @@ -23,7 +23,7 @@ class BackupFormatTest { val bytes = header(ciphertextLength = 0x01020304) assertEquals(BackupFormat.HEADER_LENGTH, bytes.size) - assertContentEquals("ENCLYBAK".toByteArray(Charsets.US_ASCII), bytes.copyOfRange(0, 8)) + 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()) 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 index 77e0c51e..de3081b7 100644 --- 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 @@ -52,8 +52,8 @@ class BackupPayloadCodecTest { @Test fun malformedJsonIsInvalid() { - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{not json".toByteArray()) } - assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{}".toByteArray()) } + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{not json".encodeToByteArray()) } + assertError(BackupError.INVALID_PAYLOAD) { BackupPayloadCodec.decode("{}".encodeToByteArray()) } } @Test diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt similarity index 71% rename from core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt rename to core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt index fece61fc..0b98e4b6 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/Bip39GoldenTest.kt @@ -2,33 +2,25 @@ 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.tempVaultFile -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test +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(tempVaultFile())) + private val seeds = SeedPhraseManager(VaultStore(InMemoryVaultFileIo())) private val abandon = List(11) { "abandon" }.joinToString(" ") + " about" - private fun vectors(): List> { - val text = checkNotNull(javaClass.classLoader!!.getResourceAsStream("bip39-english-vectors.txt")).use { - it.readBytes().decodeToString() - } - return text.lines().filter { it.isNotBlank() && !it.startsWith("#") }.map { - val (entropy, mnemonic) = it.split(' ', limit = 2) - entropy.chunked(2).map { byte -> byte.toInt(16).toByte() }.toByteArray() to mnemonic - } - } + private fun vectors() = Bip39OfficialVectors.all - private fun encode(entropy: ByteArray): String = String(Bip39().encode(entropy)) + private fun encode(entropy: ByteArray): String = Bip39().encode(entropy).concatToString() private fun strict(phrase: String) = RecoveryWords.isValidPhrase(phrase.toCharArray()) @@ -44,7 +36,7 @@ class Bip39GoldenTest { @Test fun officialVectorsDecode() { vectors().forEach { (entropy, mnemonic) -> - assertArrayEquals(mnemonic, entropy, Bip39().decode(mnemonic.toCharArray())) + assertContentEquals(entropy, Bip39().decode(mnemonic.toCharArray()), mnemonic) } } @@ -53,15 +45,15 @@ class Bip39GoldenTest { 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, String(phrase).split(' ').size) - assertArrayEquals(entropy, Bip39().decode(phrase)) + assertEquals(size * 3 / 4, phrase.concatToString().split(' ').size) + assertContentEquals(entropy, Bip39().decode(phrase)) } } @Test fun oddEntropySizesAreRefused() { listOf(0, 15, 17, 33).forEach { size -> - assertThrows(IllegalArgumentException::class.java) { Bip39().encode(ByteArray(size)) } + assertFailsWith { Bip39().encode(ByteArray(size)) } } } @@ -69,21 +61,21 @@ class Bip39GoldenTest { fun decodeWipesNothingItWasGiven() { val phrase = abandon.toCharArray() Bip39().decode(phrase) - assertEquals(abandon, String(phrase)) + assertEquals(abandon, phrase.concatToString()) } @Test fun officialVectorsValidate() { vectors().forEach { (_, mnemonic) -> - assertTrue(mnemonic, strict(mnemonic)) - assertTrue(mnemonic, lenient(mnemonic)) + assertTrue(strict(mnemonic), mnemonic) + assertTrue(lenient(mnemonic), mnemonic) } } @Test fun generatedPhrasesAreTwelveValidWords() { repeat(20) { - val phrase = String(seeds.generateMnemonic()) + val phrase = seeds.generateMnemonic().concatToString() assertEquals(12, phrase.split(' ').size) assertTrue(strict(phrase)) } @@ -109,8 +101,8 @@ class Bip39GoldenTest { val words = abandon.split(' ') listOf(0, 1, 11, 13, 14, 25).forEach { count -> val phrase = List(count) { words[it % 11] }.joinToString(" ") - assertFalse("$count", strict(phrase)) - assertFalse("$count", lenient(phrase)) + assertFalse(strict(phrase), "$count") + assertFalse(lenient(phrase), "$count") } } @@ -154,10 +146,10 @@ class Bip39GoldenTest { assertTrue(seeds.initializeVault(abandon.toCharArray())) val dek = checkNotNull(seeds.copyBootstrapKey()) - assertArrayEquals(dek, seeds.unlockWithSeed(abandon.uppercase().toCharArray())) + assertContentEquals(dek, seeds.unlockWithSeed(abandon.uppercase().toCharArray())) } - private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) } + private fun hex(bytes: ByteArray) = bytes.joinToString("") { it.toUByte().toString(16).padStart(2, '0') } private companion object { const val SEED_HASH = "c557eec878dfd852ba3f88087c4f350f09c55537ab5e549c3cd14320ec3cef38" 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/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt similarity index 69% rename from core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt rename to core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt index 8c2ef3b2..443f0d97 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt @@ -6,20 +6,17 @@ 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.BackupSecret +import com.pasich.encly.core.crypto.Crypto import com.pasich.encly.testutil.FakeLockoutClock -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.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Test -import java.security.MessageDigest -import javax.crypto.Cipher -import javax.crypto.Mac -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec +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 @@ -38,7 +35,7 @@ class CryptoGoldenTest { 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\"]}".toByteArray() + private val payload = "{\"notes\":[\"secret note body\"]}".encodeToByteArray() // --- HKDF ----------------------------------------------------------------------------- @@ -58,44 +55,44 @@ class CryptoGoldenTest { length = 32, ) - assertArrayEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) - assertArrayEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) + assertContentEquals(GoldenVectors.rfc5869Tc1Okm32, tc1) + assertContentEquals(GoldenVectors.rfc5869Tc2Okm32, tc2) } @Test fun hkdfShorterOutputIsThePrefixOfTheLongerOne() { - val full = Hkdf.sha256(dek, salt, "info".toByteArray(), 32) - val short = Hkdf.sha256(dek, salt, "info".toByteArray(), 16) + val full = Hkdf.sha256(dek, salt, "info".encodeToByteArray(), 32) + val short = Hkdf.sha256(dek, salt, "info".encodeToByteArray(), 16) - assertArrayEquals(full.copyOf(16), short) + assertContentEquals(full.copyOf(16), short) } @Test fun hkdfRefusesAnEmptySaltAndAnOutputLongerThanOneBlock() { - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, ByteArray(0), ByteArray(1), 32) } - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, salt, ByteArray(1), 33) } - assertThrows(IllegalArgumentException::class.java) { Hkdf.sha256(dek, salt, ByteArray(1), 0) } + 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 - assertArrayEquals(GoldenVectors.decoyKeyKek, DuressSlots.decoyKeyKek(dek)) - assertArrayEquals(GoldenVectors.pinKek, pinKey(stretchedAndHardware, "encly/pin/kek/v3")) - assertArrayEquals(GoldenVectors.pinWipeKek, pinKey(stretchedAndHardware, "encly/pin/wipe/v3")) - assertArrayEquals(GoldenVectors.pinDecoyKek, pinKey(stretchedAndHardware, "encly/pin/decoy/v3")) + 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.toByteArray(), 32) + 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". - assertArrayEquals(GoldenVectors.sha256Abc, Mnemonic.seedHash("abc".toCharArray())) - assertArrayEquals(GoldenVectors.seedHash, Mnemonic.seedHash(PHRASE.toCharArray())) + assertContentEquals(GoldenVectors.sha256Abc, Mnemonic.seedHash("abc".toCharArray())) + assertContentEquals(GoldenVectors.seedHash, Mnemonic.seedHash(PHRASE.toCharArray())) } @Test @@ -103,15 +100,15 @@ class CryptoGoldenTest { val messy = " Abandon\tabandon ABANDON abandon abandon abandon abandon abandon abandon abandon" + " abandon\n\nAbout \n" - assertArrayEquals(GoldenVectors.seedHash, Mnemonic.seedHash(messy.toCharArray())) + assertContentEquals(GoldenVectors.seedHash, Mnemonic.seedHash(messy.toCharArray())) } @Test fun seedHashOfNonAsciiWordsIsSha256OfTheirUtf8() { val words = "café über" - assertArrayEquals( - MessageDigest.getInstance("SHA-256").digest(words.toByteArray(Charsets.UTF_8)), + assertContentEquals( + Crypto.sha256(words.encodeToByteArray()), Mnemonic.seedHash(words.toCharArray()), ) } @@ -120,8 +117,8 @@ class CryptoGoldenTest { fun backupKeysDeriveTheGoldenRootAndFileKey() { val root = BackupKeys.rootFromMnemonic(PHRASE.toCharArray()) - assertArrayEquals(GoldenVectors.backupRoot, root) - assertArrayEquals(GoldenVectors.backupFileKey, BackupKeys.fileKeyFromRoot(root, backupSalt)) + assertContentEquals(GoldenVectors.backupRoot, root) + assertContentEquals(GoldenVectors.backupFileKey, BackupKeys.fileKeyFromRoot(root, backupSalt)) } // --- PIN, wipe, duress and decoy-key slots -------------------------------------------- @@ -130,8 +127,8 @@ class CryptoGoldenTest { fun theGoldenPinSlotOpensWithItsKek() { val parsed = PinSlots.parseSlot(GoldenVectors.pinSlot)!! - assertArrayEquals(salt, parsed.salt) - assertArrayEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) + assertContentEquals(salt, parsed.salt) + assertContentEquals(dek, PinSlots.openSlotOrNull(parsed, kek)) assertNull(PinSlots.openSlotOrNull(parsed, wipeKek)) } @@ -141,9 +138,9 @@ class CryptoGoldenTest { assertEquals(1 + 16 + 12 + 32 + 16, slot.size) assertEquals(3, slot[0].toInt()) - assertArrayEquals(salt, slot.copyOfRange(1, 17)) - val aad = "encly/pin/slot/v3".toByteArray() + byteArrayOf(3) + salt - assertArrayEquals(dek, Reference.gcmOpen(kek, slot.copyOfRange(17, 29), aad, slot.copyOfRange(29, slot.size))) + 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))) } @@ -161,7 +158,7 @@ class CryptoGoldenTest { assertEquals(1 + 12 + 32 + 16, slot.size) assertEquals(3, slot[0].toInt()) - val aad = "encly/pin/wipe-slot/v3".toByteArray() + byteArrayOf(3) + salt + 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)) @@ -182,8 +179,8 @@ class CryptoGoldenTest { fun theGoldenDuressSlotOpensWithItsKekAndSalt() { val aad = DuressSlots.decoySlotAad(salt) - assertArrayEquals("encly/pin/decoy-slot/v3".toByteArray() + byteArrayOf(3) + salt, aad) - assertArrayEquals(decoyDek, DuressSlots.openDecoy(GoldenVectors.duressSlot, decoyKek, aad)) + 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)))) } @@ -192,15 +189,21 @@ class CryptoGoldenTest { fun theGoldenDecoyKeyOpensOnlyUnderItsOwnAad() { val kek = GoldenVectors.decoyKeyKek - assertArrayEquals(decoyDek, DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_AAD)) + assertContentEquals( + decoyDek, + DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_AAD), + ) assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyActive, kek, DuressSlots.DECOY_KEY_OFF_AAD)) - assertArrayEquals( + assertContentEquals( decoyDek, DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_OFF_AAD), ) assertNull(DuressSlots.openDecoy(GoldenVectors.decoyKeyOff, kek, DuressSlots.DECOY_KEY_AAD)) - assertArrayEquals("encly/decoy/key/v1".toByteArray() + byteArrayOf(3), DuressSlots.DECOY_KEY_AAD) - assertArrayEquals("encly/decoy/key/off/v1".toByteArray() + byteArrayOf(3), DuressSlots.DECOY_KEY_OFF_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 @@ -210,7 +213,10 @@ class CryptoGoldenTest { assertEquals(1 + 12 + 32 + 16, slot.size) assertEquals(3, slot[0].toInt()) - assertArrayEquals(decoyDek, Reference.gcmOpen(decoyKek, slot.copyOfRange(1, 13), aad, slot.copyOfRange(13, 61))) + 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()) } @@ -234,16 +240,16 @@ class CryptoGoldenTest { "backup.slot" to GoldenVectors.backupSlot, ) - assertArrayEquals(dek, manager.unlockWithSeed(PHRASE.toCharArray())) - assertArrayEquals(dek, manager.unlockWithSeed(MESSY_PHRASE.toCharArray())) + assertContentEquals(dek, manager.unlockWithSeed(PHRASE.toCharArray())) + assertContentEquals(dek, manager.unlockWithSeed(MESSY_PHRASE.toCharArray())) assertNull(manager.unlockWithSeed(OTHER_PHRASE.toCharArray())) - assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(dek)) + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(dek)) assertNull(manager.unwrapBackupRoot(seq(32, 1, 1))) } @Test fun theSlotsAVaultWritesOpenWithAnIndependentImplementation() { - val store = VaultStore(tempVaultFile()) + val store = VaultStore(InMemoryVaultFileIo()) val manager = SeedPhraseManager(store) assertTrue(manager.initializeVault(PHRASE.toCharArray())) val bootstrap = manager.copyBootstrapKey()!! @@ -253,22 +259,22 @@ class CryptoGoldenTest { assertEquals(12 + 32 + 16, recovery.size) assertEquals(12 + 32 + 16, backup.size) - assertArrayEquals( + assertContentEquals( bootstrap, Reference.gcmOpen( GoldenVectors.recoveryKek, recovery.copyOf(12), - "encly/recovery/slot/v3".toByteArray(), + "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") - assertArrayEquals( + assertContentEquals( GoldenVectors.backupRoot, Reference.gcmOpen( slotKek, backup.copyOf(12), - "encly/backup/slot/v1".toByteArray(), + "encly/backup/slot/v1".encodeToByteArray(), backup.copyOfRange(12, backup.size), ), ) @@ -276,30 +282,30 @@ class CryptoGoldenTest { @Test fun aVaultOpensItsOwnBackupKeyAndAddsOneForTheRightPhraseOnly() { - val store = VaultStore(tempVaultFile()) + val store = VaultStore(InMemoryVaultFileIo()) val manager = SeedPhraseManager(store) assertTrue(manager.initializeVault(PHRASE.toCharArray())) val bootstrap = manager.copyBootstrapKey()!! - assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) assertFalse(manager.createBackupKey(OTHER_PHRASE.toCharArray(), bootstrap)) assertTrue(manager.createBackupKey(PHRASE.toCharArray(), bootstrap)) - assertArrayEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) + assertContentEquals(GoldenVectors.backupRoot, manager.unwrapBackupRoot(bootstrap)) } // --- Backup file ---------------------------------------------------------------------- @Test fun theGoldenBackupFileOpensWithThePhraseAndWithTheRoot() { - assertArrayEquals( + assertContentEquals( payload, BackupCipher.open(GoldenVectors.backupFile.copyOf(), BackupSecret.RecoveryPhrase(PHRASE.toCharArray())), ) - assertArrayEquals( + assertContentEquals( payload, BackupCipher.open(goldenFile(), BackupSecret.RecoveryPhrase(MESSY_PHRASE.toCharArray())), ) - assertArrayEquals( + assertContentEquals( payload, BackupCipher.open(goldenFile(), BackupSecret.RecoveryRoot(GoldenVectors.backupRoot.copyOf())), ) @@ -322,7 +328,7 @@ class CryptoGoldenTest { val file = BackupCipher.seal(payload, BackupSecret.RecoveryPhrase(PHRASE.toCharArray())) assertEquals(BackupFormat.HEADER_LENGTH + payload.size + 16, file.size) - assertArrayEquals("ENCLYBAK".toByteArray(), file.copyOf(8)) + assertContentEquals("ENCLYBAK".encodeToByteArray(), file.copyOf(8)) assertEquals(1, file[9].toInt()) assertEquals(1, file[10].toInt()) assertEquals(32, file[11].toInt()) @@ -330,9 +336,9 @@ class CryptoGoldenTest { 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)) - assertArrayEquals(payload, opened) + assertContentEquals(payload, opened) val root = BackupSecret.RecoveryRoot(GoldenVectors.backupRoot.copyOf()) - assertArrayEquals(payload, BackupCipher.open(file, root)) + assertContentEquals(payload, BackupCipher.open(file, root)) } @Test @@ -348,8 +354,8 @@ class CryptoGoldenTest { @Test fun theSlotFileIsTheGoldenBytes() { - val file = tempVaultFile() - val store = VaultStore(file) + val io = InMemoryVaultFileIo() + val store = VaultStore(io) assertTrue( store.edit { @@ -358,23 +364,20 @@ class CryptoGoldenTest { }, ) - assertArrayEquals(GoldenVectors.storeFile, file.readBytes()) + assertContentEquals(GoldenVectors.storeFile, io.bytes) } @Test fun theGoldenSlotFileIsReadBackAndAChangedByteMakesItCorrupt() { - val intact = tempVaultFile().also { it.writeBytes(GoldenVectors.storeFile) } - val store = VaultStore(intact) + val store = VaultStore(InMemoryVaultFileIo(GoldenVectors.storeFile)) assertFalse(store.isCorrupt()) - assertArrayEquals(byteArrayOf(1, 2, 3), store.getBytes("a")) - assertArrayEquals(ByteArray(0), store.getBytes("bc")) + 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 = tempVaultFile().also { - it.writeBytes(GoldenVectors.storeFile.copyOf().also { bytes -> bytes[index] = flipped(bytes[index]) }) - } - assertTrue(VaultStore(damaged).isCorrupt()) + val damaged = GoldenVectors.storeFile.copyOf().also { it[index] = flipped(it[index]) } + assertTrue(VaultStore(InMemoryVaultFileIo(damaged)).isCorrupt()) } } @@ -382,7 +385,7 @@ class CryptoGoldenTest { @Test fun theGoldenPinAndDuressSlotsUnlockThroughTheFullKdfChain() { - val store = VaultStore(tempVaultFile()) + val store = VaultStore(InMemoryVaultFileIo()) store.edit { putBytes("pin.slot", GoldenVectors.chainPinSlot) putBytes("pin.decoy.slot", GoldenVectors.chainDuressSlot) @@ -393,8 +396,8 @@ class CryptoGoldenTest { val duress = manager.unlockWithPin("654321".toCharArray()) val wrong = manager.unlockWithPin("000000".toCharArray()) - assertArrayEquals(dek, (real as PinUnlock.Success).dek) - assertArrayEquals(decoyDek, (duress as PinUnlock.Decoy).dek) + assertContentEquals(dek, (real as PinUnlock.Success).dek) + assertContentEquals(decoyDek, (duress as PinUnlock.Decoy).dek) assertEquals(PinUnlock.WrongPin, wrong) } @@ -403,10 +406,7 @@ class CryptoGoldenTest { override fun reset(slot: PinKeySlot) = Unit - override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray = Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) - } + override fun mac(slot: PinKeySlot, data: ByteArray): ByteArray = hmacSha256(key, data) override fun delete(slot: PinKeySlot) = Unit } @@ -414,7 +414,7 @@ class CryptoGoldenTest { // --- Helpers -------------------------------------------------------------------------- private fun vaultWith(vararg slots: Pair): SeedPhraseManager { - val store = VaultStore(tempVaultFile()) + val store = VaultStore(InMemoryVaultFileIo()) store.edit { putInt("vault.version", 3) putBoolean("recovery.enabled", true) @@ -424,29 +424,23 @@ class CryptoGoldenTest { } private fun assertWrongSecret(block: () -> Unit) { - val e = assertThrows(BackupException::class.java) { block() } + val e = assertFailsWith { block() } assertEquals(BackupError.WRONG_SECRET, e.error) } - /** Plain JCA, to open what the code under test sealed. */ + /** + * 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 { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) - cipher.updateAAD(aad) - return cipher.doFinal(ciphertext) - } + 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.toByteArray(), info) + 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 = hmac(salt, ikm) - return hmac(prk, info.toByteArray() + byteArrayOf(1)) - } - - private fun hmac(key: ByteArray, data: ByteArray): ByteArray = Mac.getInstance("HmacSHA256").run { - init(SecretKeySpec(key, "HmacSHA256")) - doFinal(data) + val prk = hmacSha256(salt, ikm) + return hmacSha256(prk, info.encodeToByteArray() + byteArrayOf(1)) } } diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt similarity index 100% rename from core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt rename to core/security/src/commonTest/kotlin/com/pasich/encly/core/security/GoldenVectors.kt diff --git a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt index 26070797..0b9db55a 100644 --- a/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt +++ b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/PinSlotsTest.kt @@ -154,10 +154,10 @@ class PinSlotsTest { @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/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/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 index 575cbbf6..9e2a3340 100644 --- 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 @@ -61,7 +61,7 @@ class SecurityBoundaryTest { @Test fun onlyTwoJvmFilesTouchTheJvm() { val platformSets = File(moduleRoot, "src").listFiles().orEmpty().map { it.name }.filter { it.endsWith("Main") } - assertEquals(listOf("commonMain", "jvmAndAndroidMain"), platformSets.sorted()) + 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) diff --git a/core/security/src/iosMain/kotlin/com/pasich/encly/core/backup/IosJsonBytes.kt b/core/security/src/iosMain/kotlin/com/pasich/encly/core/backup/IosJsonBytes.kt new file mode 100644 index 00000000..623e5b31 --- /dev/null +++ b/core/security/src/iosMain/kotlin/com/pasich/encly/core/backup/IosJsonBytes.kt @@ -0,0 +1,66 @@ +@file:OptIn(UnsafeIoApi::class, ExperimentalSerializationApi::class) + +package com.pasich.encly.core.backup + +import kotlinx.io.Buffer +import kotlinx.io.UnsafeIoApi +import kotlinx.io.unsafe.UnsafeBufferOperations +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.io.decodeFromSource +import kotlinx.serialization.json.io.encodeToSink + +/* + * The iOS side of JsonBytes.kt. There is no java.io here, and a kotlinx-io Buffer hands its segments back to a + * pool without clearing them, so the plaintext is zeroized segment by segment before the buffer is dropped: + * UnsafeBufferOperations gives the segment arrays themselves. What the serializer keeps in its own scratch + * arrays is out of reach, as it is with encodeToStream on the JVM. + */ + +internal actual fun 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/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt similarity index 89% rename from core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt rename to core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt index a8b23f0f..6db5cfe2 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/SecurityFakes.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/testutil/FakePinFactor.kt @@ -1,6 +1,5 @@ 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 @@ -63,12 +62,6 @@ internal class FakePinFactor : PinHardwareFactor { } } -/** 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()) diff --git a/core/security/src/jvmAndAndroidTest/resources/bip39-english-vectors.txt b/core/security/src/jvmAndAndroidTest/resources/bip39-english-vectors.txt deleted file mode 100644 index 6c5a685a..00000000 --- a/core/security/src/jvmAndAndroidTest/resources/bip39-english-vectors.txt +++ /dev/null @@ -1,26 +0,0 @@ -# Official BIP-39 English test vectors (entropy hex, then mnemonic). -# Source: https://github.com/trezor/python-mnemonic/blob/master/vectors.json -00000000000000000000000000000000 abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about -7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f legal winner thank year wave sausage worth useful legal winner thank yellow -80808080808080808080808080808080 letter advice cage absurd amount doctor acoustic avoid letter advice cage above -ffffffffffffffffffffffffffffffff zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong -000000000000000000000000000000000000000000000000 abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent -7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will -808080808080808080808080808080808080808080808080 letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always -ffffffffffffffffffffffffffffffffffffffffffffffff zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when -0000000000000000000000000000000000000000000000000000000000000000 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 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 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 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 ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic -6610b25967cdcca9d59875f5cb50b0ea75433311869e930b gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog -68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c 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 scheme spot photo card baby mountain device kick cradle pact join borrow -6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3 horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave -9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863 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 cat swing flag economy stadium alone churn speed unique patch report train -8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0 light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access -066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad 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 vessel ladder alter error federal sibling chat ability sun glass valve picture -c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05 scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump -f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold 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 index 72663993..9c6cbb94 100644 --- 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 @@ -7,7 +7,9 @@ package com.pasich.encly.editor.ui.blocks * 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 uses the pure-Kotlin [Punycode] encoder. + * 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. */ 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 index 81370243..0fc898a1 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt @@ -5,6 +5,8 @@ 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. */ @@ -144,10 +146,10 @@ class MarkdownBlockMapperTest { @Test fun aLongRunOfBracketsDoesNotHangTheWikiLinkMatch() { - val started = System.nanoTime() + val started = TimeSource.Monotonic.markNow() MarkdownBlockMapper.toBlocks("[".repeat(200_000)) MarkdownBlockMapper.toBlocks("[[a|".repeat(50_000)) - assertTrue(System.nanoTime() - started < 1_000_000_000L, "took ${(System.nanoTime() - started) / 1_000_000} ms") + assertTrue(started.elapsedNow() < 1.seconds, "took ${started.elapsedNow()}") } @Test 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..0ae4b170 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt @@ -0,0 +1,81 @@ +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. + "straße.de" to "strasse.de", + "fußball.de" to "fussball.de", + "ς.gr" to "xn--4xa.gr", + "σασ.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", + "a‍b.com" to "ab.com", + "a‌b.com" to "ab.com", + "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 theInvalidNamesAreRefused() { + invalid.forEach { host -> assertEquals(null, toAsciiHost(host), host) } + } +} 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 index d78c4a90..d085d36f 100644 --- 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 @@ -1,3 +1,3 @@ package com.pasich.encly.editor.ui.blocks -internal actual fun toAsciiHost(host: String): String? = Punycode.toAsciiHost(host) +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 = '。' +} From 4763d97fc9e87470c85c1430352a21ca6c518b8a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 21:25:32 +0200 Subject: [PATCH 31/75] build(desktop): wrap the runtime module list --- desktopApp/build.gradle.kts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 4ce78f10..a91cd2e6 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -104,7 +104,14 @@ compose.desktop { // 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") + 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. From 20a01d4e3cc4c86c1c6c31a7b40e9ff6476f81f7 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 21:55:14 +0200 Subject: [PATCH 32/75] build: build the Apple klibs on macOS only :core:crypto links a Swift library that only Xcode can build, so the Linux CI job must not cross-compile the iOS klibs for the ABI check. The macOS job compiles them, runs the iOS tests and checks their dumps. --- core/crypto/build.gradle.kts | 2 +- gradle.properties | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/core/crypto/build.gradle.kts b/core/crypto/build.gradle.kts index 0f5a525e..ffd5915f 100644 --- a/core/crypto/build.gradle.kts +++ b/core/crypto/build.gradle.kts @@ -34,7 +34,7 @@ kotlin { } } -// The Swift bridge exists only where Xcode does; on other hosts Kotlin does not build the Apple targets. +// 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"), 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 From 2c3aecc4708259aad3206f1dccb83c4b81a0bcb9 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Thu, 29 Oct 2026 22:14:46 +0200 Subject: [PATCH 33/75] feat(ios): Encly as an iOS app on the shared UI The shared module builds for iOS into one framework that a small SwiftUI app hosts. Biometric unlock wraps the data key with a key that the Keychain releases only after a Face ID or Touch ID match for the current biometric set. The vault locks when the app leaves the screen, when the device locks and when the background allowance ends, and a cover hides the app in the switcher and while the screen is recorded. Data lives in Application Support with complete file protection, outside backups. CI runs the shared tests on the simulator and builds the app. --- .github/workflows/android.yml | 31 +- .gitignore | 6 + .../encly/release/NoPlaintextExportTest.kt | 6 +- core/keystore/api/keystore.klib.api | 16 + .../encly/core/keystore/BiometricKeychain.kt | 154 +++++ iosApp/Info.plist | 56 ++ iosApp/README.md | 40 ++ .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 36687 bytes .../AppIcon.appiconset/Contents.json | 14 + iosApp/Sources/Assets.xcassets/Contents.json | 6 + iosApp/Sources/ContentView.swift | 12 + iosApp/Sources/EnclyApp.swift | 27 + iosApp/iosApp.xcodeproj/project.pbxproj | 310 ++++++++++ .../xcshareddata/xcschemes/iosApp.xcscheme | 66 +++ shared/build.gradle.kts | 21 +- .../pasich/encly/core/di/CoroutineModule.kt | 9 +- .../encly/core/di/PlatformIoDispatcher.kt | 6 + .../encly/data/backup/BackupImporter.kt | 4 +- .../encly/data/export/MarkdownFileNames.kt | 2 +- .../data/handoff/MyNotesHandoffMapper.kt | 2 +- .../encly/data/importer/ImportMapper.kt | 2 +- .../pasich/encly/platform/NoteHostServices.kt | 20 +- .../viewmodel/AuthSetupViewModel.kt | 4 +- .../presentation/viewmodel/BackupFlows.kt | 13 +- .../presentation/viewmodel/BackupViewModel.kt | 4 +- .../presentation/viewmodel/LockViewModel.kt | 3 +- .../viewmodel/MarkdownExportViewModel.kt | 6 +- .../viewmodel/OnboardingViewModel.kt | 5 +- .../viewmodel/SecuritySettingsViewModel.kt | 7 +- .../encly/core/backup/BackupAssertions.kt | 2 +- .../encly/data/backup/BackupImporterTest.kt | 2 +- .../data/export/MarkdownFileNamesTest.kt | 8 +- .../com/pasich/encly/SharedBoundaryTest.kt | 1 + .../encly/core/di/PlatformIoDispatcher.kt | 7 + .../pasich/encly/data/export/IosUnicode.kt | 110 ++++ .../encly/data/importer/OutOfMemory.ios.kt | 3 + .../com/pasich/encly/data/zip/IosZip.kt | 553 ++++++++++++++++++ .../pasich/encly/ios/HostViewController.kt | 43 ++ .../kotlin/com/pasich/encly/ios/IosApp.kt | 114 ++++ .../pasich/encly/ios/MainViewController.kt | 89 +++ .../com/pasich/encly/ios/di/IosAppGraph.kt | 104 ++++ .../com/pasich/encly/ios/di/IosModules.kt | 228 ++++++++ .../pasich/encly/ios/platform/IosClipboard.kt | 127 ++++ .../encly/ios/platform/IosDocumentPickers.kt | 154 +++++ .../pasich/encly/ios/platform/IosDocuments.kt | 151 +++++ .../encly/ios/platform/IosHostServices.kt | 194 ++++++ .../encly/ios/security/BackgroundLockGuard.kt | 50 ++ .../encly/ios/security/IosAppLifecycle.kt | 86 +++ .../encly/ios/security/IosBiometricManager.kt | 313 ++++++++++ .../com/pasich/encly/ios/security/IosClock.kt | 74 +++ .../ios/security/IosDeviceLockWatcher.kt | 62 ++ .../encly/ios/security/PrivacyShield.kt | 112 ++++ .../encly/ios/storage/IosDataDirectory.kt | 59 ++ .../encly/ios/storage/IosKeyValueStore.kt | 113 ++++ .../designsystem/PlatformKeys.ios.kt | 4 + .../designsystem/Scrollbars.ios.kt | 22 + .../effects/AnimationSettings.ios.kt | 9 + .../presentation/effects/WindowMetrics.ios.kt | 16 + .../pasich/encly/ui/theme/PointerFirst.ios.kt | 4 + .../encly/ui/theme/WallpaperColors.ios.kt | 8 + .../pasich/encly/utils/LocalizedDate.ios.kt | 64 ++ .../com/pasich/encly/ios/IosActualsTest.kt | 126 ++++ .../encly/core/di/PlatformIoDispatcher.kt | 6 + 63 files changed, 3815 insertions(+), 55 deletions(-) create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/BiometricKeychain.kt create mode 100644 iosApp/Info.plist create mode 100644 iosApp/README.md create mode 100644 iosApp/Sources/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 iosApp/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iosApp/Sources/Assets.xcassets/Contents.json create mode 100644 iosApp/Sources/ContentView.swift create mode 100644 iosApp/Sources/EnclyApp.swift create mode 100644 iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/data/export/IosUnicode.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/data/importer/OutOfMemory.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/data/zip/IosZip.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/HostViewController.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosAppGraph.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocumentPickers.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosHostServices.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosAppLifecycle.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosClock.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosDeviceLockWatcher.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/PrivacyShield.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosDataDirectory.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/PlatformKeys.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/presentation/designsystem/Scrollbars.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/AnimationSettings.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/presentation/effects/WindowMetrics.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/PointerFirst.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ui/theme/WallpaperColors.ios.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/utils/LocalizedDate.ios.kt create mode 100644 shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt create mode 100644 shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/di/PlatformIoDispatcher.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 10396a7d..01b1f873 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -94,9 +94,9 @@ jobs: # 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; :core:crypto and :core:security have none yet (no AES-GCM for iOS), and - # :shared (the domain, the data logic and the whole UI) depends on both, so it has none either. Its - # Compose UI tests run under Robolectric in testAndroidHostTest; it has no public API to dump. + # 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 @@ -512,13 +512,15 @@ jobs: # 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. The job keeps its name so - # that the name of the check does not change. + # 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. editor-ios: if: github.actor != 'dependabot[bot]' name: editor (iOS compile) runs-on: macos-15 - timeout-minutes: 45 + timeout-minutes: 75 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -548,13 +550,16 @@ jobs: :core:crypto:compileKotlinIosArm64 :core:security:compileKotlinIosArm64 :core:keystore:compileKotlinIosArm64 + :shared:compileKotlinIosSimulatorArm64 + :shared:compileKotlinIosArm64 - - name: iOS tests (crypto, security, key store) + - 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 @@ -564,6 +569,18 @@ jobs: ./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 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/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt index c601c2f9..18cb0d19 100644 --- a/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt +++ b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt @@ -84,6 +84,8 @@ class NoPlaintextExportTest { private companion object { val SCANNED = setOf("kt", "java", "xml") 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 = @@ -100,13 +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 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|\brememberCreateDocument\b"""), - allowedIn = setOf(BACKUP_SCREEN, MARKDOWN_HOST, PICKERS, PICKER_API), + allowedIn = setOf(BACKUP_SCREEN, MARKDOWN_HOST, PICKERS, PICKER_API, IOS_PICKERS), ), ) diff --git a/core/keystore/api/keystore.klib.api b/core/keystore/api/keystore.klib.api index 185ef2c8..b5819d45 100644 --- a/core/keystore/api/keystore.klib.api +++ b/core/keystore/api/keystore.klib.api @@ -6,6 +6,22 @@ // - 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] 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/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..c105c056 --- /dev/null +++ b/iosApp/README.md @@ -0,0 +1,40 @@ +# 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. + +## 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 0000000000000000000000000000000000000000..066f3e183cdf4f79804212db401adb66be51afba GIT binary patch literal 36687 zcmeFaXIPV2)Ha%g&=rs_AViTOAVpD7N@4>96a)mM3n&UoRXQXY2SKTd6s0O6QX?Y0 z1cxRdO(95cQl$4lLXxus&O7Hl|IYXK^P05m=!(E;R-9RP3m z#}5AJfF5=K*ufti{9kGZ>B$)-&LB+;KW1nyqaPp7)?jq&m}xXEJgbm5$~GFAG1NEL zCkArVRQ+M}$3$t1OLpyyA8q?rd_O9mp(?$UiyZgyJR#J8yRF_Fd`^RfOZ6m_wf1Ef z$qoi@*G~UnB4ZXlsq1oI+*Ef%>$SODT#kpYZQhFs?bjwxP14PqZH~pYthl}w#5pac z8ZZX2Lt)V@^@oE+wm2(7C&oUY%LI@C%*4jJ&t0FXzRH5$l}i%P=ohOmAlL+n(Gh%t z#4j%}Cp&iHJC zpZOV+KB;<3NrdBwgKCY;XiII;-DMn&t7Z+A8-H&Pt)j z*(2RHhahZ*i_6`Z>Mu6RB$Ne8v@ZyRdqg2Q<|h(0^@QyoK9_(l`H;8M*$p>umo$@c zRwi|U3fN@!`b(Hh55j4AphyS#`n9ib^+h`N)|clqoZEN?o-Z`hNG) ze$q209f)Vm$Q^3yffz`Yh<4g}Ozk~bQ|N3TD@C`&^o*tfdeAz{B-d#upun~M9NO!C zhE@KlUf1}XVnUBg6Ry6)!eP?$DCFvN2R|&6^=k;~!G4)j@#4@KF;SBN=IZuynL(;E z-j696)Uh#N-s+Hw*cEC2{pALB^00T@ciD6x-y)JMc+BOnsxt~IvmVmu?YQW8;rt#m z+i)9bf{kCbk(-=tz$tEw(V4l~A`{KUHe1z24)7KBpvoq!UA^xHniB?pE)W*(TTIZ+ zFSoKS=QU%op$tY})a9c5bp-k&h@7iURvRG=jm#dJUl<`3`6_J_oFQ0t?ih#_R~1eo z#uyvgzAlhc>}wNY={fOkh~Syr48@DFxiYDs)?fl{~F24_%8q zDP{zP)otD~^gDd;Msc*+tJYjGe+>?znJvY=oNy<9=nIMF*hK2$nenYp70i_4*ok)A zOj!xwlR_aLVpR{gZ-}RbwIhYa$PYw_Bb0tIA7Tl9Lyaa=!1T$dL^5OMz|V7-+PjQL zAz5)B4?$rH^6r6Y_xFwQ1d%3CHAdlMe$KK9Eyey`6LC6__z9=vr4e5VJxGC#?2Dbq z_3=hst}jner{9>J>K8&=*YE$FA8BR}!Av!_+2?Dj&qgv;pIed^KKA7i1Z&K)HNDBJ zbNVpK?kx5nqjIAsb(CDOfQ*tV|Ap8Q%lp<9q<$&;^;G-gMt=#uK)zdix`R*~!Nqnw zXw*IMm|(OfZxJ^+mVzdbJGY&t+tu(iwYrR}9H*3D*7AY_a;%g0FlzJ( ztZBlfaiMC`LZnR~Cr_lK>*D!H<``5R`rt+H(!!XCo~hEF4hO2`+r6u1$~ z>ezd_(H8f3GyZPSwPH4x;TKNmB`r8d926S*`>Wux`njcPYf$YHHBe;Ms*70rGpHT+mkRLW-L0_B2g;kxg)&+;j$Y^@eg%trysf4vCLYD6E6)OT}V6Ez@6IYc;z^ zICG{Ay>R;|#G?jDF82^`-1NiccAV5~umsh6qHg*JUNfPjRwV~JFUswm>)qz9i;}PA zU7I4H4jBwrZsiz?f=b>m?Bi)}RavYm35P4tLZ+D^$pUk^W^RGAMRESJ?ZhTO>O3rH z-?HBzeuUTBi4T&dhp8<@Wm{E~NKH**B=-j==I33hzmVILi^K6<*!e^7R7nKX_fWWUwFJs7^ZyDLpOHP`}4;V~RAy_Z|uyYz_AFpcW;;5sx z1-2Fb3zf)2%#=xg*$T+@9Hkrs{Z5x}1zu54aN?*@4Q#Wca?}}U1-Wu)Xn{tGrbV~6 z4+SjMoD|)J9zZOoV`L$m&gN36(PJ(> z!PXAPL{?C`RAv(jsz~Kxkr!yPBO8Tf#VXISI;JR2FLnv%q{37X*NzJ zcyZ}L$3n@G%m6Yuxt~N4a z5$#3Gjm#D9Ixw?hZ*v%hskLHpbcl^}PAG`do|uZ;NIa1@a#x#}p4q%&>*{swQXInl z88{%}-NYgIlqte>E%Fg{&MgovvW}i9LK<1yL^fBAJEF^nBVHgXU~Tu?u15fv=q2j4 zAOlZAZvmy8MqMgVaifgUD0s1<0nuA3@Bl8vho80{YQR`IS?VFkI9tZ0-ZT_$#zBpaVs=q!9SKW(-hnU6cLx-?aYPX$zuEMBi zkWzPSFCbX4c{rNX@I5OVG^HuHM6`6f5#t}Ho7o?BfWj`}Kt6ilLflc*CvDL~J5&dr{&V<2GiN zc?6)qv5jwVp8li-7WN7LF$vnD{W*=nIdoXduD&>6KkTjeSPf9Q1wuxAItT#|&vg1l}m zOieijD;6*eZhxEBretWBth_)KfTLK>R@%s|df?0Bf5MC%Cbe=+vXybeU$nN$H+3Jx z-?yp|%Ah6Ra|!`F(9B+ctKIo{fqSE^N5*Fa6U$jquI?~YGqWkfJN_7m{*^2Q_7mfH z8UCA9@B(vimEXgO3&^d6oVGZNIitZTm%xrs6FHl37WGT%RxZI?9kh!9YeSZ{Th*Vh z1ss!PT|>l}L0M5Y;<%x5zA|LH!ToK|LBZatP2HoAeA(V{BUCb9__H-n_KYz&i(Con zflq{G5|j2&8L#3DA+)+i4a%=JZb94!lChKSRhR6bjN|pR{-)tDk6=wwLa^lRP3vLb zQ)hQfoQnld4s_9Ajh3-2TxAMe8Hbb=aB)u?=G=niZ!?vVGm)a36~vKsDQqCC4rGNugp+Jiwh2A!R#IStUzwV2iM*|@(%v!t*s7I<%l9KH z8NU66)5!-`Mc$^V&y=K9Ju_pKtH8kRy2SH>`~W^3J*kOSk zvx`_=X)Of@v2?TXC3UK2PN~|=Wg<>>#kQdI4g0+s>rcQ-f;27#vO;vo{#IskSLD^A z;&i|T*CYk#m-{oP0zWg3&u+?&kf$^1I<(O{_}u)w)A6{A+UbIDhtEm*ejtRXhM)%d zi>X}(r%X+WH5-KYBWjmeleo;K!N#5sF?R3-J4UJZP0e)nIuk#oMVLTdrI}gCHrV@R z+eOd@mk>AlO^KWw~gaTdiTp4&Xg4fg!p-h%3t zYNg>z#e|5iU=fPKz0Ok+5UhYeSmkR1p8P$sC%15QJTndvQ-y&ewz)v&<$%5SDa}6H z2wx={L`hJwxob5PRvxT(ok;FK2hix{_o&0vmV3$$OzTGF+>az8v>hCv8qPnr&$H`5 z_|H}eB?x2f%d0#WZbxjcHFi)^w#q#!^iu7fs~OwBP$pmSOi_N|w@dk_#HD4K(W<55<_c*U=S7sy-XK+><)IwoNteX)N)ygduhGSY^E0&aJEuW+pjI5_aU!#(>cnh8eT%r8pRQdYuSUeGmF%!j{J^MX*~5 zcey7>O-Pesy#&iSTzw?IYc0#lYaOquMBoPU*&?FI4oBkdJ%c3UYuu^~!6^t?C40iM zc)~8rI)rr?P`)ZOmeZ2bsGrN2)2;H;3f||_+ae&VXiAZhO(~0P(SR5nUS0hQ6fFG8 zA6^wBU`r|F=nPt`Qn!K2#O-8q^k;OGcD_vvzcbx}eS7zc+0_ZpQOXQ{Aez%fG;NZ%tDT>TU}Ig(mCToj6E5OSFFyXUzBE0QM57 z|KxO!7CRKeKYq@hi|k*`oa&F%S|@k-Q&ow0b0vZh5X%a|u@hQn9xqt|vG!Fo8MOj? zFx|4y%93n^NSY&tqSf0e=ovElsMe0$!L{iZ?x)3pon$kPZ=pLiBO3TH#z}qd=Rm|{ z=hkU67s09$ZSzfE<=_6gQRyx2PZeB3d;FCv0cZIdi%KSnMKcwaK@<&cUQ1X5cAOmg zaS7q>kM%6}rbI43?b zz=X!g{pH=;ym*(kDN^JVscmMPrx(iq-tX0UdK$wWb`GOOv5_l0VSCD080I90fT}v! zi5ZM`!#vjUn}r4!OVbP}#kTKlZNl4;S@O;o=q)zio7j~*zP?1$fHl*+D)*a=EN{Vs zI%oW6YgA!MVnO95I2D`~a4cb|$R0vwBHYfSKMP34m8gQ9**qzi*#A3CI#1L=Ovf34C!2d9FjqMfb!-sk6?C829x9S7#9SX}Ens5bk6lgQ~xyAeR% z^i_RDisd~T^9DGVhWFWPM_B8_Xk8olZ)KjxxiO8i7WXGVX3#FNeJBABhr(i;9lPhE z{8PH@V%iu+&mo+ED@M=P4q?LP7 zd#7_GcrJXQ&Bx)^YoT{a@T*eku`9ol?_C<5WOvlweg^4Q$Uh~IYcj$=C^nQi&L={_ z2fC!`00lEM+E3wz$AabFK>}hL{Da|E&@Tpg#Sezzk{)mH6;-R*c&Z*KHREvw2|2|& zEK~XMvvfbvz$3^>QXARPwStDlHMpekAMh;zrd@vVG{RjH)vF$P@BGzZsivIGnD4+LYeB}M5|Bj z$i(lQXtxaO4uxPZ@h0jsQG#(p!<5b50n5_HN1uV>EWD4s{uu3C*3gnjqcqCF3zDW$=9Xvx+)QBOrF{My>E_va z!802KQe^bbvO!}tKF;6anPu*Mx;aQ;$F338(928qnsE24JZvxwP}y z&8NhTGeFzVxw?egSEn}d5vPam`B8nwk9YC%*4e=NB}~6=1-$FmOa_T8C#!xVrS8!% zwfZK}&bK=?qKUJ5juorUS_X^By5ll;iQU9f)@^59q47OUwyzq$er*qREdIhi}Uj23I{BrWt%%wU?)-;4DR2Hf5+x0tg+!%373>twlOK(D2*? zsh}Xhg$T%U z`H)U4*xoM~@&(0?%sa|cR*XW4W=#rs{tS0m4YKZ>2nGGCmv$~-&UYY$;k*CEsJ!9bs$$Hrm0@HMCh)L)0@4=pS_@Luv> z`bD7yDp~%#Sj&e=hFhFe&_}!@k-pgO^a*Zf5iI2S#V6w-R_1bnN>Eri_m?Ot=Tguw z723^+ouaii97puoiNu{&0Oe##uwtq)wJ%vOPSZRk&Qeqg@>Jw;4Lsk2Q(hKxN&@4) z0Q}&HThn`5z{0{n%8*)yjhniN=BE%TxF>LVUp$siQ6d<}XK1S~V&Tuw1?ZiQ9tB*2 z%pyg@!9&adoMbZW(c+jTZ7Qv#)OBe4J5uNYhjty6;) z3BeO)0|P!j!z6nVk{XeQjXnkA1WB#v=5j=B9?WACooyncQgE-HTZPK6dzIGVoTE;+ zJbA96;6|l?I=Y5$`}kB=`&Cv3l=fmyDq3)()PLau^<&?8%L@2s*~ZunN`z;CI=W{p)^$#I2e&TWXM#+Z;iR z+LXAKTZvaYxWA~r78v9-{WMDb41#6xN%KApu`eGi^UwEqm|-ZdaE5}mI6D9&8s&v~ zxFZh|i_QcwYZDhYIfxFQ(iRs@1E#O;+eC^sKA|-q(`=B0vj{SEYobOaSV1Oak=s-8 zk=o)!4fI;@FSkHn7MpjVCwYjdg`x{I)j8Ut7$vlQk&s6D>_$`zrPKv@aMmIkNy+t3 zXo=V^I_e3Wo4bspFbG7b2r5qsRkk%DXTvm;U?oG!nkm1%n-qVCe=H0d{}8MyqTd+X z{6R%D(*9mr;*YuI#Y&G>H~kg0nG$V}qNSfd^JvX2-!;F_!C9&lz!u7K`JRDIT1;F> zBX`YEZ10|C&r~HP08HK-VDcyqaKP}Dcpjhi#lW>(O8e)<0b2k^4Y{F%eyBZ-(_vz& zzUoatkGu7YEFOBb!wo#50<=r}r%gJSHo5B;zOZ80+*;2}V-~9v5nbu%zL2Hnb}n9DHcTe`5rT+LgK>S4bs#(P(mZ3erAeb%FxAK3)>bT9dM zmw?7W`_zK3#vF_{2t!&q8SFCQb2@4(eGRjR~h1;*Ec~;-JD+d7eNv)Hq zC(bxw?~UT;XEZrh9L{$Rue`qG{g}43p?Y2QY0otgRJ~-i4&-s24#P)|hM^y;$+@$0 zfIbBuH*-^8eCSX!JT|&)^K<$JOmUtF!V}Xo$Y5j_WkAjHSsC(4D{djyq)Kb_5?#S> zZdt+%K=)=&MRSb-crVddbi*&&Uq!|S_Gc@5qqO1CHUA8_1r_!Vk8C%ZJ+5{Y(W=K@ z%B$~C#rr0+ie$n2p4EBS$567_JNqs*Nbvv;z zWtZ8OB8kmu;xDw+bL{ZBK}kU;(m>OpeH@dTtIa6iQBV3sQ3B8blM5x^5!k=ex%|Y{w>Ckscf? zTl{tN^>3Jgv!Xjp5D;q%P_UFojSsZXWr&U)MYmm^8o)y6Eh>gPRDIZ_^Zf~)v?s%~ zHa!=meVs;K3;2_Mn)&X7yk(S4v*=Bqk?m^qceNjt#Kls3tV5B z{h_|}I1Co(YXF2cKqywDUYi48GCb2>`HD2K`aq+-@n#NPWOT_P`{x^5(ZQNSb&FFAI*@*~ zQU`zB<*y^RP#t}aaUeYb6B~eJSN%%k96VLRFvmotW;&oq77{mGRI)Z)1RswdX-*H? zBA~Y-+X*Q?!L$+J-22LDmm?;{#7Vp9I~c<02&npaOvNfXVIfADMmde}+BV(l1HCZ* zib5ws*^27fZGeIFl~I8w97KOz<15`6rfIJE4J9l~?b%cxSbf6r*)@J^knLMhJGC8l z(m);6)9Cqn#%d!xG?1GiXwyD|ra&d(K9w#l22%_ia&R|0C^ZW5+3>wF=;jcfbnKZM zR*;re5tv<3Wg+*H;`7w{u<$SB?bWmW1M`cnr7CVshREv8G2b6)<3*rBMpps+9jK7? zvF~&W7(cF{HC=bojuEkfzwp&XJwpSeaU6xx-abW5@o9Wi^D-!_+l1Z?_#cPzT7KQc@99OB@aCAKPuG_%Wt^;v@Exh=e zh03yX`0ZTn0JScfle*B=#y;OP(ch2y0T89#c)&+O z-cTAO*q+5UIVyU^TCYbpN4_&Y;BMN^P$j1AU&9AgRtBkPQ4j8C*6VZs?`dFV?UsYY zOj#~%ni-O`qDlmak%`MG22ARS7as4>w00jby8-nV?txFKeOX7|K=dP;@oL|m$W`}% zj$8RR{K93qVyUJ{vW2`YlbLUXAy;{Rscs_=ax#M?FJBFWOrHPsdJ2 zfsKfpi&bJ^4W5%Nb7@ayA%!0?ZEBAs8@kZ?ivvS_JO3cJ3)s)x#i?BYjG#%w;@ z6?&8rp4<=!l}nnkLKHwug!0YpC8l|^oT*2JiPJMSgG<5}sBVqFXk-TJ48=M;ob-zf`~9wemj%J(svSI@mY3u?av z%1!yNHAg+Ls^g#+A4gbSCDHi@=L=u>K5DNbfvh8ewvBXl8}1L9&L6ubr3KYk!>u#^ z-qv5h31xYGyxz`u1p2<)o(VI9B9c#yMVi26C$Y1-R~*#5p(m~*1h_ICiX zvw@4WeAPSJ?T`xE9_Wpp-VG}9N(AUhxKXFkL&Fsh$>C{BV#DFA7z5*$)4%5`2RUMU zWWRufT+BnLvwYJVBCY7n7BvE|tu*c=SC`>ExJAxesMg*+8Z1_S17CflJIKiy*2jlQ z!j;38=gS(T(-u_MM=B`!v~`@Cnop?qkdxbz^>4>~9tb1w&!Qd=57+@^XsZ^rZzS>= znzZofTxrm&Bfl*IAd5g})TtUJ4JZ+kEP0#aO3p`K5_&oX~$?w_Tgo(^xLiLO1 z4o#UM+Gzr^arVU)GKV@KqinUY_NPRn{R7mhK@E#j6A~n@UDmPzARTznOB|#ZNrvyg zSh4ZyP7)e(ffJ8&er=xIde$Z|^YKu7FPfZoX^CcDzSaL8bP4Huv8)efI_X#SGJx)~ zO!6DRzDq5!gT!|vTnTNzlQ#T06%^^Ft9zXCL@D~hE?uymGDtU+DF%2Ex9pl(WjL0qHYX&cKV`jQ#Oc7=5&kjNngeEK`(WfVzUkl8)Do?n<*Dvo6rE;Qedky6?D*<&LjbfF%H8@7^;Ji3Inl_~f@D1ULoc#Q0@boln-P)qrGO`*$L8ea1JD*a3DUp}RL`Wjj)>tB_1ykJWH6R|0>9l3F)Lr)j zEaEuO;@tB4!4S5KM4J6;bNP7L2G5;$f{Zgqg_IzjzUm?z0Sh;AJh9N^s+;uwCVd7p z*yEiho&5@02sI+U!U7*yZdDNfbW%bEIuc#ysC;&?@1EO`WJMmW5Pur1ER52D*@~W8kK;7b)uks0M2ZN&N|Ni{6f}vdFKFC~Q zeakkQtd4egKpG)$_O4UYrUkQwch}_m7;_Diik-+0q(?ek3H~gaSRW^dlpLJ)O4JVV z6MJ@pJqo}{og?}(eo&YaXcuFmt}==M3%{0#bp zXu~PU1&GFs{az}3^yuiQ8UC?=ro`;tr2*mm3qY(l<@EGonQe=&?m4WZAVBI!o9Fn~z(UTK8-;^)^AKlf z-g_=^w%Um$-{01wa{{O!#+`b5s==&puXL2Te=i_b~Lp#~WY z3mU;Oe?nf-fP_+_WFb;VO?QJN${!fpy8XMxX~|JhOw)S@4$L()^y|qpmgnFN;v=Ci)NW1N3>lhxsD4VmWZqFiCDwO^r|n9%EcW29mpCDB&`m)E!Rq>a)X zC;!PA58pL$2=*9guo(Yy4A2^O2LlVc1Qz!6nz7TKA<}` z54RDpQ;B5z|3)?fBfk|q^>8m0r~h6t6X2n`stvn}-d*=w;Ei>ZoM-j_M+`e%;3QtS zKmF}!z+*b}{<3{{_sf4J#niq4VjLUe{p||DaQ}8XPW~sx9|iqU&~K~yV?lq8*uM_u zj}Q9er2maSf1=o*Fu$8Mf~?{HG!J@VocHN0k18{^nX;q`+8*bFX>z>blm12Ds=E8j zuHBWN7C7I^S_Q{88*L`EI-U@15}W9fHl4_vZ@R;a)UNw%RHY))cv@j>AdrsriGr#? z9dk3TmH`%Gj-*bjTCS+_OZU!-k>;cIwK95X{}|{ua_G+U!`}hIFzp&zQFMPmXu!l+ zy3Ysn&PmIq#f5zVLjj;Uz7fv| z5i}yR_~--&o8)N)6$_6rKsOwJftCp7AT>B^aAR&RNpoYm>{J&yAJ^G<-%TGu)+okU zA5>i^{SgH+G6Rsat6y$#fe+(nM5W3nWH5BB-=+lh+A!NRAzmiVLY(^Qpxah?X(%#@;dsf#8EwRNpzVsz)yE71z_yq`)`n6-y) zmhwFL$Gy|%)aZ753Fu}hVWlXMYd^GX5P+e-HNCxMJCl6cwuqIh%p7Z9XzC{4Bh+IM z?0r|gX8V^|MEm)L`~mIIfDx(z9>Am(Ym*(?{_cej*B9U{|0`wP=90c`#@doOf8v~9 z_REKy7Bck@F;m47Nsbc#z6&5JL^soKnlSoaW8QmWqf4=XX{InSy865<>sQbuPkh~8A3<)hkEr*L1d)-{){KAwwUMpS>>5;p0mE`6V1#MnbQiNIvp&d zHF^Zp69U;wmAmys?K}*$@JU%UZryXI3+@5DJMfoEu*Bki!3P1Z?5RTpDK2vzPyw8O zd_9BirV$39Vzh#XvBSVMf{^pC3%!G~&0pYg^4>2dKuqpX<2E;IO($r*_nh)Sl>v$cI)WJlN&R>zYOz5wHLgr0;V z`x|yH@DV4{KV3gGOy+>s(~d zhM@S&mfpU7{`>^ZSb+Z&K;9mZD&8yOi&#u%+j`OSP6J6xuE7fikE^3>P(^k!dWeTW z!$#5Rq}{3%JI(IM@TkD5z`DZXu5xQJ$w>df%sIgJVcelkpIYs|LeB{OxB&|H&ek=e-XIDjXiKunj_A)2UF<_0bps+LS`$KsDV|D_n4cu2!hE+gPw3_#@YP-Xr z><45@Qp==gHQyRTy*iY=OC}PWvj+Ii6h1K_e@#O1phCkq-IOl@`VTwk7&$xyX9rK~ z7fy>j2IJ4X)eo+9u>(D>!^}_b*~t_3`pdNQg;oXmRtH5NI^0RGPs$*H5?*{%1ub;y z*6I;1fhKFQ4!sY>vEa2Ix^c6OO~Vlqrk>sRt^mgX`m>`ofB%NvG~wg*=*(iPyZOAj z9Q2t~YvZNj&6|(^ z^_b^N7C>RrJbIn!7WSnYRmi0RZTe`U%x1Xg!vs0Q1IBiO_FC=PpPZpwTmr7z;!6eW zjNk_Yt}h8-XnOqFJFi6e#|t!FiFosyfOPRcS37pyFBujx1LHM@DkmI;5HIgsMo0mR zpfj)bJf1jkuK+K6i4pTyc~Sch`^;wP@?ipMch+S%Hjyp8-&|BV-eHo}E#L`YH}bhN z+IcwS-lF?v)aVVE2MeDjI%1J_ zoG-U+(i~iB>qfW;V4Hwri%$9tpZ(NMj~Rj|4>8ZPp@RP^$n*p8<7W_OC)J6S_o-eX zaAqiQ2|+S;<&I$MH^+~)`Q6HNhLpSeTKeOMyGxA$7s_VSEU7nK;*Kk?p?IQf*{AvtAAzh{APhm3ygNEq8vq7 z40t=vmsnsL23-Wxz0E(OD9YI%S}0e}JPNQ)Y#>H(+xe^;(=&)Z48CRez+i-3O^ zg*}E^FGF1R`qLnv`bw=t&!ucmRrV>@f@bE=%XD9S$r3mbU?8E9n%KUx zT_|wp1Wkj@Fp~XAECQsG{4J|sw zY1iaKxWX1`9t^e1chJ*!(7nHCAz|5Cfcgv!lRfrWj5zkZ$1#3-BTw!rN`ykJ3>^v> zD5d=*stuGnAkr`0ed!INCk)2nY6EF~w`=H#``G3(v~TACUPaxRGr-ym@-6CJKTo15 zQ03jp7kf}&Hfw!Z8vVkqzuQh)m)(a?COY|yv*7lk9!Q(3;1|V!uLQ;bx z#+nDsEE$1E-v$RW!EKgjZ(pkD7O{M{YB8z8PU(Q`$QsP5(U`kB$WZIv;F- z%aC+~R@X*EQI~2qq(Ca_Q?nJSO)QCKrukhx;Kqaa@B;|a5QQVqcLNp0Xx~4wBE3jl zxHl3hjAkC%?EGqbX84eDO6?Qi&;R~s53Dzr2E_C-^t77C3~h2`Cv<42EqL1f^{Xr; z5FTVfDr$Q5%&r`uedVzVIszOOYCdX~K2e*cpu(7Q*{AH=Tl0Zuxu)NOA=oD{g4 zNZQQgp_Dd-omkr_DpEV3(&JQZ72b(+NtosBVF2%nUv=KI^s7=_kEr$Kw6SYnw<^7) zw5U&n(UCh|XU|$5_@8Sqcj5MV_v&sLFl6n6!UQDU0~gW6J8tAP{3iMqkvd#`N@!iO zai{77X3HM7MsMXcy?@WM=N@hX`aDty^sDklXp6U1DyU2MBegEXQfofE1^yA->HRQs zfKYQ|4{WEqtiMo$tCi#wH!b`w_u>Juq|(wyTr4r7@mRO@A;4Y(H|A=~J;^6_wX(d* z_@)DmQ<;BQX<0hZwcs=jcsxF;GjsJ@tJy(Eupz(%f^2nTk@-Q&kb&TvE|l zN+WYqK|*{P*p^hbZ{(i2rh)V8L-ZkslHF)cXnHY3&O)osl!R)h%&_oYfV9~L^?-L3 zoB?L>^{$;s`0i8n&D6|hN5QPkrmj9Tm-e;?fi`1@D|#r)jRCiGuhGJ|!J2GLK=1aA zeMv_w<|Ss0_x^neMIYFN$I|4H%BOB4%iM+=j{_doLbt`6oGo*!T_;2L5^v>ili`l)NEL}7^Eno=|n5Vl% z#O|VWE`x()^r`zl>1!i^7zd%?^$WZ4hVD$BgM1?MPPXiSV*F9i9|iq3k3Sal=ZO95 zDgOANKTditghF9|qS&7>PlpKpf9FAd+|sj%BoIN@b3ULVJXJ-qIzOV5m*7shK+~XvH7qrO{)3#&xQZyZd^kkd3 zp2XU17JbQyF+{xF{$Hc^)<% z^Wy$OqC_+m&Op|&;L3z_oP!7BCAWjezvL}kNj&o* zK>yPDP5)Y_^Yj#MI|sVLV!`VS1fc4R?7ZXGWSqkNAd5*`dtx4y0_X~T$~A7l;hLc% zM$F?O=ZpJ0a?Xc`IN$d(w6u|V#j(Y}_j~=v0XF^ZX+nQ$7V0Z}b65k?2t9i^IZ!IhQ*piulY(zQfykiC`y>R|8bH-Xs9bS(zGU?s+e) z6YCa{eBi3+XPW!izoF8SHD>P zOXgiuNN4ZdWt8fGmlo_N7dkpXuUNr-5o{snGl%oKEgL+`&1DX<&p?GhkA`lzP_PZ$ zEYD>COA?@khX!SPUxE8xjdezaRVIq_LHti=wvBCX9OCqEE1rAZ-8b~qO=bF2ltUMpMqUQ4BuKbnR2$b{e}0(9^P z9nrmfgfVIf{^Lyl0Jtq7eiOVvWt7LvmiD(ARP@=(i@hSJ93zj~RlFVcvK5oyy2BGb zsR}VRtuaa==8azG1zf;y{`c2X47z+;P1D(@3qW@7z1(Eu&C6#hzV;FmWa5gW0}cLt zWx=k@qc1_R;x@MoTjs#{V*m=Z+5xG zaJG;#N~u+Tue^0ZeqJLZHtYV(9)ygtE1)Yd*Zuw1K~|9wh7Z>78v|>BH7cJ~61-Df zyfY?7$^{hp0ZUrCPbc~Td&h6RZ_{5qVyoYMClzkLoOTT_pqKa6xOys6KEysd*zfq_ zGwJItN=nPVf_J&!`#p*B-#eUAp!!VxGJXl%iUY0lO+x<6gs7|`c+r*(s#lYG+vono zLK3dQ9ktLl;7|lZw*UMsj{&S=q1{-y`xX^$FGTNu$LF!fghQg4r5EAv2@aa0#3i_N zg)zxP{I?g_2DdTnYQ|rmUXxT5jpSGi1Q%O4{k40>5Ra=Oa-nOc&FnaX^5H;=Hq~N~ z)!m&u)RpctwsGxutvGrRC{UfD$rAqNLIAj76QND<02FBG+$gm^=*=>%)Mpy$nd}qX zR9TTzkrVx^y!o}x6YfjR-8bDnX|7&4qSgmLM_`vi#fYtXQS)}5L(X|);pd(=+%5{k zwiP_8|M5dOfKTtuJzd^oqFx>A*R6XVP8we^B7AnZ{bR^jqoYzosAK(snMTK)pQ`Z) zNi)=XV<0y;plwr`2SxttfVc+XiPQlU1PbSp4#7||dgFDkaj{`hml@?6ASHA8)zsr{ z44anW@omxxxYO6K8)F3$oFf~lO=2dw1ZlyaH#U0&Ry1HN2n+j$*64*< zy7_ml1nOl~oqsUJ7|{{KhEz!E#HpPIK74gvCRUVoBJ~n|P80;o%@PBZJ|}MY_c!)C z=B@vfvkUF}Ijy7Toz z_yUgwVYjLbg{j=-ld|^@KtAzTPyO?LGYxd zl|091&ZL&+$iDh?%ALYm&1L|cPN@@&WwNi_AwTW=Q2!&z9 z&##=wV0_!=<6FpLZ9s8u{4_BqyZy zHsh-a<_zTI% zyyV*9RSNzaY$O6xE64Z+^3^EGs||;;>7sp8j{Hkmh(7~k{)?I&nVk=*3XyoyW7h6; zL)80hHWin+&u~7smSJ~Cq42BMF#BqcjeE_4E*uAhU7;%Z)(!H`r8e)BqHi5#WxB8* z{UTp&E7n_PCwsk-+X%f0xyvk&+k+g*9D6o_V~>GuRj_s-mVKc^Tj(-=!^YLkXerXS zq6QtrfUQNGrFGhOW!4OJzLZOX%M#Kx=l1kn0&|4io@?uyx`CRYro77StDVMGPx#I= zEzg&T7%(jt%Nx!OW@ZZD7K2u6OyMmj!Cj`LD6>6vD{27i_+B*m#pw#l=cD$yj{W}S zk3UMY*4n3cxZae2=H<*0%m~FX&++f?p+vnoKgoCc%0r&j7BTuU?i~Og0R)4hhY^SG zZN>+z!rmOQid9=FVn1w#yzk|+Jh<(mR{r!Z`h!Krq=MY(`1a%072nFe#Cx4{_GP?E zRs{do3cB#4=0Jw1hKBGq{x4pUc9%a1qf~9!zvOyQ&~G`Eh7bQJ*p4h-GwsR2B^TXu z^8Ju|h_A5@`-MT|@-u5eg0GR9m^e2RKSEi_x_|@;XK9%>!vu<`)H5s za_`{DI2RYQk+W56PrR8r+gsx7?;DElnGHfw4Ln{{G^Ou!0mXoQW7D*fL>j->eMhTU z+M9Wemr0tzurMdTcz;kzgw~A{E%x!uDc8i^h3ID)3O|Zs!nk5;Js6)p$@fr}BfwT~ zu+RAV3&GyLMr~|k<+CeYACM7Q)*Fl7O190{`kosSM!IhDoLB=y?DM*Y_BsX(MS3C){oHf{l5iw z*9U?(!6znJepYPQ<}tqb`y4mpsgBZnhEWQJ`Oc~_BQ{-&&TSj)4MwloRgO1Z`HSl7 z$ymuKmiEHu_kmRgBWQb#UuW4dt^FoxdVS!2es#>gM}%_rJjQj)q=}%+R_asZ+~d^` z9!|Ctzp{19cYClnLijW~apwwgMBbN+$+F!%5_PSZB) z1FxQ6TgiR3jbirl>-Y2Ww2Li-_7!X0d8325bUv*k!Dnxb<%eJ$wkl%>;ZcZmWz?&N zy3v0IM!a_#4-D7prAitWFA&PZ^%QCl?6`l|7q_#|C-u@c9Rew^o|OMC{mOnDhLQlK*|9dNbeEp zL5fHhl8ERL1r?AEMvyLqj?xlTMCrXsm)=Y0N$$dP?t9c z3mvzFN1lEyR-~QRV9-BQ8#fi;?a)%Z+xPHvWmff%+`*Qfjg~2L7@5UI$Y=Q%a}{F+ zb-qE5esT05A~m2fXqW3>j1e-xUXMLYityS~_#Wh>e7JUKY77Ct>L_x$?+Rw+b|XYzE`*p+{F{pdN85M;6n0%<#m z;(OJ&>ghp#>(Q@3Q;z&*Nj$4B%{u7T6K4XByVA(eKk0_N$QqvV zRbG*_p-l<7%&Z_I(sz${&M@&?ed)`!y&Dpjk9qu)2uLDO7$j66DfgC@$T2MyVu(j! z#qoz*(tSO@d%RfX);8ipkP$2vzx>auZGTy>HgRaFW=6>(Jo0#gosiNGTz@X1j z=M-X1rOZR-R90M9jZ?q5sLs2E@IMP(Ub@XI%v!adm~ot}(s=YqO+ngYf6wfi`iYa~ zk1zI$X0!iUr-&W_iM;t;kaOoT(o0SGdP!kPp3g#wreESl_%!`Sd>Ynapqzb0sh&V+ z!-UOgsC5j}#*sW>d=N=fd|q(YNcSe|P~VJ_E3a+VU41xfBP;JN&dsVhjVl0P;39oE zCR8h}z~5}|nYmlhodf=s@R^HCH1%5dt%xtj*tm8M2w5k|8HtD+HpuigMGScZq$mLC z>OC97i8`!u0aH%L4L0C|f^iQ!3U|IVO} zjf8`?Bos0Xrl;_=*GLWfM~2!6u|{d*m)v|_?FDc&?!S^+MfHf>{Nm|)`%#|7*~o{w zT)Q$;@Lz>$wb0#faT|1Cu~?*6mzsAd4z~WoV|Fy%(8Hxa)zITZ`WuI;#GZ6aicsm5aNPq*_NlnPFQzO=| zK+J7Yu~1*$Cxv{;+;7-rWF+C#rzlC=>jtMT!ov~ON68mcKdMo;cgB+R($>^|j-jNE zG3;B+q1#nH1z4Y{Y8}2^Ch9K`%`caZ(Qk5L=7iiZjJua~3Qx3otE&4nW!N?UQmgqw zVpIYo!;Nb9^!rybrM62^QRd}D>W5Gh0ty{THWfuY4+fW#5^gCa)-IA9aZ(IGex-N~Ay;0KmBV+1ww`4{e&I&a^$g&EHAC?nhbSB&c_tIO znY5SmTN{^WfUN&cQvCyxw?tM^HRpz8wjdqpIs26I(eQ=*-aVGY`7X071aBV#IA3e+>hs}odO+;>T*F*Ha zUpO(QSy=s(7vNxKsXRG30n0%9?P5~4G+x)bo*`9!iDCM1XGP?WL&IQYj(N4EmDEAY znSx++VU>%@t)e!+qMr~BIH!L#0KtZ;RcV+6pX64YUYDjR&A6eX6eV%9K^I~Iznmla z2j;-(rV;vTS7XXsa`^p)poYyu9r}g2tPP{>bmK%xk2prnX2&9&FhaR^%TKD^iEx{vRi< zhBd;R>8{^r;q>KjYa>*|ZD!3K2Kygjlq!wimrgu7R$*Ey`+Cd1`+~ZFz=ydnUYf#V zKV1*L868@rT$P+!fX(2`DHBNf{(h@s#pMliL*DJ}Cql;$O^a+QFpr(=<+rWOrGMP% zIa?OT5M){3i3Hv|#sb)BEw(NWapMNw?8z;bPBsp|vg7f^(8y7J-$XHW35I6ep{i?O z=Dq%8(8gPRqoDNiYxjCdwxkR%{u0^tdo})@`rsj+UQ+Ez#bGZo2$C;*$(eofH}~$C zUvd!3bn>*h^=EHTDg|4t?zW$P)oeVEbr57LmDOivfgw~m#~wUM#ge+=>sEOXu?|mv zyed+r&#E-}G^x8(Sq|*#supyKyWQ-^`;iRehidujk6RJ%x`4C(gbA=2<$rz=S0GJ%=tJuzhr55} z<#{|H(f|9F`tS+BRq;|Ha@J6>l=P5>t@-r2_me?o+Cf5eH?}|gYeqlIT;m}$L6WZB z%7*a4ynkiJD^pA02VMIWRv;8m{*3s&t|LFBe)_8=y7sVb`SII+vTFXnj!6fVVYXLM zAdFOc2FzucZeuopLdE-|7@Veg5i#aKXeU@cD^6j9MyT8@>5x?F?~En6@D3^v;@Lj_ zmItm2Ho=9(83Sp^IhQ|Y>XqNqdpx0ZsAFZyl5aIu7e#oD%r;eyMAV zud2{#gO5uBf4mFHbhvn-fn?_Z-w!EsZnWu`!FHh^ZEHTy<8ms-7k$0u(0mt|Bw_7O zPmyoC3GFfIrq8;hJ)s2@=8BMB>_98{vx-5D!zy6_G zC zXJfpOHf_WWf>7x`F898oJP*oicjO$kqSvAKNFOUbxu*+HU)<_op1_N%k z$HVvY?6SPfe5~)A{(OS!QehzLKyY&`s>w|H9+z#btBOD9Mnyy8uc|mnCV{1k%MerN zwZP4r6<52aAyTFVIjv=Z*U}Ad=BSc253~Mtd^{6s#@u&xfK7(=@}kghmj@OOe2q)qqReyy}c=eqs3QyXTo`nDi4QNA!{ zh+9QeMwMbn#u82^w3flXb$KzF3D?CBM8u#B2^`xb`8z?RJ~9L)CDI9zFAoZuv4j?1 zldCzbvBXDVx$_ePrgnUSosIElQo_ET(!X_RW49xE4r$0(hdOF6%I{*+M%_s7L|B|# zh2tr>&!NnAJoWm!*UU(k0DJdvb&}ZHjn<#Kpze3*kMeY3NId6{lGDs@QoAgylCiUT8k&4;j z%5@f}|7QH7AT;408`D`yn(Phgobh061opGbS!P!}$UCn>4o*<&jfV(|CT#@kfKUpQ zb&~ASS585{%0XTon34TN1kE!bf^=0u}+P2$yIy&0g#I-bY)_RIBKv7b|W=}$S` z_ut0v0SK&y?e45q>x@$n%}Ed_sk-dwl)@d6+a=k3HB<@Atc-MIp!F0~mfAY%h=GXi z!`_0`elq<|D7TXKwKgh#%Lo$(8ZN$ju-nImUide(GaD1{G>6(wEruEpq8-fhM?^T} z2>E)SxsYR}^x;)BmphMs*AvWz5%Ho(2$FK?*zwoYj3RZ#nLZ)3V`j)jcH2x7ZLJ*9DW7_2%W$}zNNKam5_9Qlj= zc@<}kr|E=PqK+K;)t*?QlWrPEFq9Fd4W9F8&o85`mxym2tF3LAiC)XQ#4f9$o$qQp zPGu%Trx=t(TdpgBxYLGJQZX@56g%l=eSjWF>XSD0`kcbd~{WUS?c6!ggMdYj~4cOdn*~2w~l>kk2fuEVl7U&b3|{qb@^y%~Pj-9it5Q8e#g>7E-D;_?XP;Y|AWCfYmd*|Zs=ITWP4 z0B5nrzexu4@<&9Bg+&&ZUF=Ax?ON^6>>R%2vOcSBS!is&DkKM+?z(;%KvN!U@5(W@ z%01L-oB4gadGbRLj9L4uhVbb-B5h~AIDhTUy;Dgg`b~b_Yurm36vNjn%JAMLS(!Z7 zICR)(p1&sFINorfoCrSuIsL(D`1GOjbWaR_e9F@vXQz&yf=%4D#SA^Jx!JuPg_9MBP6AEvQA%&7L6H2r7>bAtQZAMestcPVMh*BaCK?8M_UjE`I zZieu&3@D)H07p4!1oBSUf!<$l$4n^Tyi!XY)bf>}_!`-e{~dYl-c$oG$S>fzgb zltx~X9sz;f>{Mmo3XeRC*F+|Zx%hvImPn^AU&-gZ~E!NWQztG0Bj zks|~opPr$n*p`o2W%TXv7Z8{P|6fn*THaC3(rM)S=Nw-Ix#InRbpt%q8()zO;OpVk zIB^4FwB|T60BbMbUrPvEUChllhZd-fNMWBtb&7r~fNYWltK20x&?Y5S``I|QH_6Gp zp?0FH2LK1!JbrK;j@*_%wiNPcxHu(6aHTJC=?HOqTcBy-T*x+~dyR{VY;@>Mj?5V; z7(z$~nRl9FtDK=K2}Jj){?S{6sY=XXngwbjidI$Vuk{F&|ZX%oeRq<)PwDHg@ zp`pV!>FGj(baA+)UIblg*pD_YFfIrlUPcRIH1Ipe{){OJ(_%|^XF~U|j&UdY-bpzf zn8m(b_sTFekM=(J@d{-hTMI!>Kjv9qqKj5<(Hu`;L3~b5bdIp-izRFRu6_I4_IpivpW!JLhP((D31!z1Utv zAbFemycgjrT~MgqUR^3Wr3t}FFge$^wQz*I4-yJeyovx7I>f;T({)wYQ&3qv+SzWz zgUdoVyS%jXd6|t-YNb=P5S3)ji_L&)6xb4s$b|`ltGt?rO(qB4_LXw^4v+Hjk@Bk( za_!Vv2Wbfh4OBVUutR!%C_d2eq2DS-e`b?$P_09YvF}K%h;OQ;B277U||Q(O0GySC_ETK3S-t{Z-eH^kpBBOb{jA*;&twxHTL1Di~;|GdQ_JfEW^ zG&@2=z0dauT?%qJ>K{R$H9bT=B{-(~OA)J<_ikYgG@O!av6uFGtZA#Bib}tFzK!Zg z(ExF!WWI+xZ~|_2O-i2uJmxCO9V@%-=kW-SLfxH=swmv)^rO`hKNW*=hwn`=I1V`= z_-6ei>TuM55bYIBKj=8SZ+WLd{EXF}U=P)3>)2uZKqeXxBKio`WI=tmBlsk$F(|B| z3cXD+V3OiO!kG)nI+*rUBLJnQm8`99k%Tg9F$QZVK;OT&lQ#ZG*9jS zg+(z2BGo%vl_6y}g4jN-0S)AAJ zbg#JP5w>YNqpnl$YHkx2#KU~As4b8)Ll8oiAnWwoS3{wb^x-~bQZ7;74T>y1#rhfp zLsNo4TCs#ZDB9WP+C(_xo2Bu5scMSI{Z#Q*p#`g(39lPG+BlRlsgy zsux;V8OJTpuZ0|IW}fXNR{5A7p%C|O(=ri|t>uLTXgH_cpSbHzpar6R{_Y5MBdEASso?EwIU zPr(9GU#7`3D@@PSJ?lNtP5;}A#?vt+;$#fW@UdK)tN~==L!!*}XF(@0AF4@PIxBOo zqh!m$YbY?mvaC1F5#d}8zPl_$r;4*i^C*S!PsAXSm2nSw?_)NvTkl~uooFY!E8h)= zP87h=)a1`kGc}_5L?H1WC3LO>Cg=uyed3l?QTnQq1SiAZkQ?K0E&$!Wd`0WgydKLzBQq%IG`biV zWSM+CZa-kp(7YhseH53)GCP0uxldn!#tH)j!v0=YQ{BiGz3<#oRJR;_e^aHuHtW3F z8wN^)R;$;+*cMHciFyJj!NLhq9eDZ<#5ylLJQ*1!6teZ)!8}M&P2d&9YjePWReyv3 zoRtX*1Zm!0^}{tj2PXd38hv|)eiwh{`*kO|;zgnP^WLii4VAOBMJKfmEm~d6LS+g5 zBvR|I*M+?^LS?75WWZM)$TNvJ4H48!l9Ggehbk^Rdp)!nTU#idwc<+uUMB%p34=6j zl-79~*Wnfh>sg@=47`kHd_%ng8iCKKhUIww4Sy=uCg4$u{rYk&!i5o~!YM^6|R zGx||3T2s(DCnCrkLG{+OY(Gw+Wk+Jof?7ja#_a#fkeoZkNE_X#H|6}-uhzV8CM`Fd z;ExV1yYu=2U20awHS~+TMCEqpa|J1bYn>r#x$V8M3?j z=otjIaY}!2T4mPd26$In@CE9~(kuB-wAC%P;gu8QeE|w#O|5d_UHvceyEyTTWCvc5 z`|joGi^H3F{N*fx;5p~S@Nj6fBLdU#>Xh|_Q~LX>3vEPMN|*D2&%xMkq5oBZvqptA zc3tPC&fTHcP8`_PtawGdXtS7EdCt+)=@87tyhFC@x=Sb^ zSX=K6ZDpHS?V(MJPqAhnsp9Mp6oZm@&g_C=bG$nmkk`&9)}(bq1R>6nW1c^*)*5!t zv?Gkp!XcY5z>lxN0>p$}6p&5f;qNuyaX3XZSBj{wII@=M4Wj!+*~3A2$AnjsIaIV3_}(5Xb|JJ@XUpx$Bep Q5b&p~bxX5A1NHd-0HyNtt^fc4 literal 0 HcmV?d00001 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/shared/build.gradle.kts b/shared/build.gradle.kts index 237f5f98..b84b1a5a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,6 +1,7 @@ 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 @@ -8,11 +9,12 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode // 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). // -// There are no iOS targets because :core:crypto and :core:security have none yet (no AES-GCM for iOS), and -// this module depends on both. Everything else it uses (Compose, Navigation, Lifecycle, Metro, DataStore, -// Room, kotlinx-io, Lucide, Reorderable) already has them, so iOS comes with the key store. +// 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) @@ -99,6 +101,10 @@ kotlin { 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) @@ -126,6 +132,15 @@ kotlin { } } +// 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" 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 index bedc435c..9ead78d7 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/core/di/CoroutineModule.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/CoroutineModule.kt @@ -8,7 +8,6 @@ import dev.zacsweers.metro.Qualifier import dev.zacsweers.metro.SingleIn import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob /** @@ -25,7 +24,7 @@ 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 + * 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. */ @@ -39,16 +38,16 @@ annotation class IoDispatcher object CoroutineModule { /** - * A [SupervisorJob]-backed scope on [Dispatchers.IO]. A failure in one child does + * 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() + Dispatchers.IO) + fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + platformIoDispatcher) @Provides @IoDispatcher - fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + 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/data/backup/BackupImporter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt index 5e07bebd..5e4a53a7 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt @@ -157,7 +157,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 -> @@ -172,7 +172,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 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 index 86cbda37..f71c1315 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownFileNames.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownFileNames.kt @@ -34,7 +34,7 @@ class MarkdownFileNames(reserved: Collection = emptyList(), private val /** [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 + val room = maxBytes - EXTENSION.length - suffix.encodeToByteArray().size return cutBytes(base, room.coerceAtLeast(1)).trimEnd('.', ' ') + suffix } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt index 28c98548..9f40db1b 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt @@ -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 = Crypto.sha256(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/importer/ImportMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt index ee3e47ba..ad232f43 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt @@ -158,7 +158,7 @@ object ImportMapper { } private fun sha256Hex(text: String): String { - val digest = Crypto.sha256(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/platform/NoteHostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt index 9a0e9403..00a7cd37 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt @@ -2,6 +2,8 @@ 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.io.Sink /** What came of a print request that was not an error. */ @@ -45,6 +47,7 @@ val LocalNoteWindows = staticCompositionLocalOf { null } * (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 class Holder(val window: Any, var count: Int) @@ -53,10 +56,9 @@ class NoteEditRegistry { fun forWindow(window: Any): NoteEditClaims = NoteEditClaims(this, window) /** Claims [noteId] for [window]; false when another window holds it. */ - @Synchronized - internal fun claim(window: Any, noteId: Long): Boolean { + internal fun claim(window: Any, noteId: Long): Boolean = synchronized(lock) { val holder = holders[noteId] - return when { + when { holder == null -> { holders[noteId] = Holder(window, 1) true @@ -72,16 +74,16 @@ class NoteEditRegistry { } /** Gives back one claim of [window] on [noteId]. */ - @Synchronized internal fun release(window: Any, noteId: Long) { - val holder = holders[noteId] ?: return - if (holder.window != window) return - if (--holder.count <= 0) holders.remove(noteId) + synchronized(lock) { + val holder = holders[noteId] ?: return + if (holder.window != window) return + if (--holder.count <= 0) holders.remove(noteId) + } } /** Whether any window edits [noteId]. */ - @Synchronized - fun isClaimed(noteId: Long): Boolean = holders.containsKey(noteId) + fun isClaimed(noteId: Long): Boolean = synchronized(lock) { holders.containsKey(noteId) } } /** The claims of one window of a [NoteEditRegistry]. */ diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt index ed703a89..56f0e819 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt @@ -2,6 +2,7 @@ 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 @@ -10,7 +11,6 @@ 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.NonCancellable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -67,7 +67,7 @@ class AuthSetupViewModel( // 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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index 516fe84e..6aac90e9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -4,6 +4,7 @@ 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 @@ -255,7 +256,7 @@ 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(Res.string.backup_export_done)) @@ -298,7 +299,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() { @@ -311,7 +312,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)) } } @@ -378,7 +379,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() } } @@ -410,7 +411,7 @@ class ImportFlow( 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) @@ -480,7 +481,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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index cec4fb4b..116b7ed2 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -2,6 +2,7 @@ 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.resources.Res import com.pasich.encly.core.resources.backup_error_no_picker import com.pasich.encly.core.resources.backup_phrase_exists @@ -16,7 +17,6 @@ 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.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -120,7 +120,7 @@ class BackupViewModel( 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) { state.ui.update { it.copy(step = BackupStep.Idle, erased = true) } 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 index d5d2275c..e997594d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -3,6 +3,7 @@ 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.SecurityManager import com.pasich.encly.core.security.SensitiveDataCleaner @@ -130,7 +131,7 @@ class LockViewModel( return@requestBiometricKey } launchUnlock(onResult) { - val ok = withContext(Dispatchers.IO) { + val ok = withContext(platformIoDispatcher) { try { securityManager.unlockWithRawKey(dek) } finally { 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 index a32605f0..ebbc2bd3 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -6,6 +6,7 @@ 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 @@ -35,7 +36,6 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.binding import dev.zacsweers.metrox.viewmodel.ViewModelKey import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -221,7 +221,7 @@ class MarkdownExportViewModel( 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) { + withContext(NonCancellable + platformIoDispatcher) { documents.write(uri) { out -> when (what) { MarkdownExportTarget.AllData -> MarkdownExport.writeAll(snapshot, labels, out) @@ -279,7 +279,7 @@ class MarkdownExportViewModel( /** Removes the empty document the save dialog created, off the main thread. */ private suspend fun discard(uri: DocumentRef) = - withContext(NonCancellable + Dispatchers.IO) { documents.discard(uri) } + withContext(NonCancellable + platformIoDispatcher) { documents.discard(uri) } fun cancel() { sessionLockManager.endSystemPicker() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 6524e0e0..0c2e4e6b 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -6,6 +6,7 @@ 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.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 @@ -260,7 +261,7 @@ class OnboardingViewModel( 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(Res.string.onboarding_error_seed_generation)) } } @@ -382,7 +383,7 @@ class OnboardingViewModel( 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, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index e8490521..3c39a497 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -3,6 +3,7 @@ package com.pasich.encly.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 @@ -67,7 +68,7 @@ class SecuritySettingsViewModel( /** 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, @@ -146,7 +147,7 @@ class SecuritySettingsViewModel( /** 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) } @@ -169,7 +170,7 @@ class SecuritySettingsViewModel( /** 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) } 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 index 94247746..aad85ee2 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupAssertions.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupAssertions.kt @@ -1,7 +1,7 @@ package com.pasich.encly.core.backup -import org.junit.Assert.fail 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) { diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt index bff2441b..76a05f6a 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -19,10 +19,10 @@ 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.fail import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlin.test.fail class BackupImporterTest { diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt index 4d23e282..5a0b84a0 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/MarkdownFileNamesTest.kt @@ -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/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt index 9bc9c571..8feaf109 100644 --- a/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt @@ -171,6 +171,7 @@ class SharedBoundaryTest { "java.text.Normalizer and Character categories for file names", "com/pasich/encly/utils/LocalizedDateJvm.kt" to "java.text date formats, the same on both JVM targets", "com/pasich/encly/data/importer/OutOfMemory.kt" to "OutOfMemoryError, a JVM class with no common name", + "com/pasich/encly/core/di/PlatformIoDispatcher.kt" to "Dispatchers.IO, which common code cannot name", ) /** The Android actuals. */ 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/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..4a9a9c0c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt @@ -0,0 +1,114 @@ +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.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, + 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..a9847a0e --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt @@ -0,0 +1,89 @@ +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.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() + // 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..54f62413 --- /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`, the Keychain)| + * | 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..b8abbd5b --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt @@ -0,0 +1,127 @@ +@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.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) + } + + /** + * Something copied by the system's own text editing (a field's copy or cut) is on the pasteboard now: it gets + * the same policy. Items that are not text are left to the system. + */ + fun adoptCurrent() { + val text = UIPasteboard.generalPasteboard.string ?: return + copy(text) + } + + /** 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. + */ +private 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?) { + delegate.setClipEntry(clipEntry) + if (clipEntry == null) policy.clearNow() else policy.adoptCurrent() + } +} + +/** 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..fd2b9bcf --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt @@ -0,0 +1,151 @@ +@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. */ + @Suppress("ReturnCount") // Guards: a name and a path are needed. + fun adopt(source: NSURL, folder: Path): String? { + val name = source.lastPathComponent ?: return null + val target = Path(folder, name).toString() + val moved = NSFileManager.defaultManager.moveItemAtPath(source.path ?: return null, target, null) + return if (moved) target else source.path + } + + 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..2833ff52 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt @@ -0,0 +1,50 @@ +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) : + 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. */ + fun begin() { + if (task != UIBackgroundTaskInvalid || !isVaultOpen()) return + task = UIApplication.sharedApplication.beginBackgroundTaskWithName(TASK_NAME) { + // Out of time: close the vault now, while the app still runs, then give the task back. + lockNow() + end() + } + } + + /** Gives the background task back, if there is one. */ + fun end() { + val running = task + if (running == UIBackgroundTaskInvalid) return + task = UIBackgroundTaskInvalid + UIApplication.sharedApplication.endBackgroundTask(running) + } + + private companion object { + const val TASK_NAME = "encly.auto-lock" + } +} 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..98a8ff9c --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt @@ -0,0 +1,313 @@ +@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 + } + } + + 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. + if (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() + 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() + 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() + 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/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..781c2d70 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt @@ -0,0 +1,113 @@ +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.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 cannot be read starts as empty (the keys are non-secret + * flags). + */ +internal class IosKeyValueStore( + private val io: VaultFileIo, + private val writer: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)), +) : KeyValueStore { + constructor(path: String) : this(IosVaultFileIo(path)) + + private val stateLock = SynchronizedObject() + private val writeLock = SynchronizedObject() + private val values: MutableMap = load().toMutableMap() + + override fun getString(key: String, default: String?): String? = synchronized(stateLock) { values[key] } ?: default + + override fun getBoolean(key: String, default: Boolean): Boolean = + synchronized(stateLock) { values[key] }?.toBooleanStrictOrNull() ?: default + + override fun getLong(key: String, default: Long): Long = + synchronized(stateLock) { values[key] }?.toLongOrNull() ?: default + + override fun contains(key: String): Boolean = synchronized(stateLock) { values.containsKey(key) } + + override fun edit(block: KeyValueStore.Editor.() -> Unit) { + apply(block) + writer.launch { persist() } + } + + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { + apply(block) + return persist() + } + + /** Waits for the writes queued by [edit] (tests). */ + internal fun flush() = runBlocking { writer.launch { }.join() } + + private fun apply(block: KeyValueStore.Editor.() -> Unit) { + synchronized(stateLock) { MapEditor(values).block() } + } + + /** 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() } + try { + io.replace(Json.encodeToString(SERIALIZER, snapshot).encodeToByteArray(), null) + true + } catch (_: IOException) { + false + } + } + + private fun load(): Map { + if (!io.exists()) return emptyMap() + return try { + Json.decodeFromString(SERIALIZER, io.read().decodeToString()) + } catch (_: IOException) { + emptyMap() + } catch (_: IllegalArgumentException) { + // Damaged JSON. + emptyMap() + } + } + + 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 { + 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..e9202b62 --- /dev/null +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt @@ -0,0 +1,126 @@ +package com.pasich.encly.ios + +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")) + } + + 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/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 From 1ea1e594a6807899e56b39ffb2dc95e8a79f6776 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 10:46:32 +0200 Subject: [PATCH 34/75] feat: hide note previews, what's new after an update, trash empties itself after 28 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Security can hide the text of notes in every list and in search results; search still matches the full text. After an update the first unlock shows the store notes of the new version once, in the app's language, and Settings → About keeps them. Notes stay in the trash for 28 days and are then deleted the way Delete forever does; notes already in the trash start their 28 days at the update. Refs: #105, #97 --- .../com/pasich/encly/core/di/AppGraphTest.kt | 8 +- .../src/main/kotlin/GenerateChangelogs.kt | 92 +++++ config/detekt/detekt.yml | 4 + .../7.json | 343 ++++++++++++++++++ .../data/database/AppDatabaseSchemaTest.kt | 26 ++ .../encly/data/database/NotesDaoTest.kt | 18 + .../encly/data/database/VaultMigrationTest.kt | 28 ++ .../pasich/encly/data/database/AppDatabase.kt | 2 +- .../pasich/encly/data/database/VaultSchema.kt | 26 +- .../encly/data/database/dao/NotesDao.kt | 4 + .../com/pasich/encly/data/model/Note.kt | 5 + .../data/database/DesktopMigrationTest.kt | 20 + .../commonMain/strings/values-de/strings.xml | 4 + .../strings/values-de/strings_security.xml | 2 + .../commonMain/strings/values-es/strings.xml | 4 + .../strings/values-es/strings_security.xml | 2 + .../commonMain/strings/values-fr/strings.xml | 4 + .../strings/values-fr/strings_security.xml | 2 + .../commonMain/strings/values-it/strings.xml | 4 + .../strings/values-it/strings_security.xml | 2 + .../commonMain/strings/values-nl/strings.xml | 4 + .../strings/values-nl/strings_security.xml | 2 + .../commonMain/strings/values-pl/strings.xml | 4 + .../strings/values-pl/strings_security.xml | 2 + .../commonMain/strings/values-pt/strings.xml | 4 + .../strings/values-pt/strings_security.xml | 2 + .../commonMain/strings/values-uk/strings.xml | 4 + .../strings/values-uk/strings_security.xml | 2 + .../src/commonMain/strings/values/strings.xml | 4 + .../strings/values/strings_security.xml | 2 + shared/build.gradle.kts | 13 + .../encly/ui/screens/LocalizedScreensTest.kt | 2 +- .../encly/ui/screens/NotePreviewsTest.kt | 124 +++++++ .../encly/ui/screens/SecurityScreensTest.kt | 3 +- .../encly/ui/screens/TagsAndTrashTest.kt | 2 + .../com/pasich/encly/ui/screens/TestApp.kt | 23 ++ .../encly/ui/screens/WhatsNewScreenTest.kt | 82 +++++ .../data/repository/NotesRepositoryImpl.kt | 6 + .../pasich/encly/domain/model/NotePreviews.kt | 31 ++ .../domain/repository/NotesRepository.kt | 3 + .../usecase/note/PurgeExpiredTrashUseCase.kt | 39 ++ .../note/UpdateNoteTrashStatusUseCase.kt | 7 +- .../presentation/components/tiles/NoteItem.kt | 14 +- .../presentation/dialogs/WhatsNewSheet.kt | 52 +++ .../encly/presentation/navigation/EnclyApp.kt | 19 +- .../encly/presentation/screen/AboutScreen.kt | 24 ++ .../encly/presentation/screen/MainScreen.kt | 1 + .../presentation/screen/UnlockedStartup.kt | 30 ++ .../screen/settings/NotePreviewsSetting.kt | 33 ++ .../screen/settings/SecuritySettingsScreen.kt | 1 + .../screen/trash/TrashNotesList.kt | 25 +- .../viewmodel/NotePreviewsViewModel.kt | 20 + .../viewmodel/StartupViewModel.kt | 53 +++ .../com/pasich/encly/whatsnew/Changelogs.kt | 48 +++ .../pasich/encly/whatsnew/WhatsNewTracker.kt | 56 +++ .../viewmodel/ArchiveViewModelTest.kt | 5 +- .../encly/testutil/TestNotesRepository.kt | 5 + .../encly/domain/model/NotePreviewsTest.kt | 38 ++ .../domain/usecase/note/TrashExpiryTest.kt | 95 +++++ .../com/pasich/encly/whatsnew/WhatsNewTest.kt | 240 ++++++++++++ 60 files changed, 1703 insertions(+), 21 deletions(-) create mode 100644 build-logic/src/main/kotlin/GenerateChangelogs.kt create mode 100644 core/database/schemas/com.pasich.encly.data.database.AppDatabase/7.json create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/WhatsNewScreenTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NotePreviews.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/PurgeExpiredTrashUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/WhatsNewSheet.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/UnlockedStartup.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/NotePreviewsSetting.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NotePreviewsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StartupViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/WhatsNewTracker.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/NotePreviewsTest.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt 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 index a8d2366c..3a465261 100644 --- a/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -41,11 +41,13 @@ 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.TagListViewModel import com.pasich.encly.presentation.viewmodel.TasksViewModel @@ -150,10 +152,10 @@ class AppGraphTest { } @Test - fun theViewModelsAreTheTwentyThreeOfTheApp() { + fun theViewModelsAreTheTwentyFiveOfTheApp() { assertEquals(PLAIN_VIEW_MODELS, graph.viewModelProviders.keys) assertEquals(ASSISTED_VIEW_MODELS, graph.assistedFactoryProviders.keys) - assertEquals(23, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) + assertEquals(25, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) assertTrue(graph.manualAssistedFactoryProviders.isEmpty()) } @@ -234,11 +236,13 @@ class AppGraphTest { MainListStateViewModel::class, MarkdownExportViewModel::class, NoteListViewModel::class, + NotePreviewsViewModel::class, NoteSearchViewModel::class, NoteTabsViewModel::class, OnboardingViewModel::class, SecuritySettingsViewModel::class, SettingsViewModel::class, + StartupViewModel::class, StatisticViewModel::class, TagListViewModel::class, ThemeViewModel::class, 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/config/detekt/detekt.yml b/config/detekt/detekt.yml index b3d02e2c..e933dbe4 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -59,6 +59,10 @@ Compose: - LocalFontStyle - LocalSimpleEdit - LocalFontStyles + # 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 # 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). 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/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/AppDatabaseSchemaTest.kt index f1c9aab4..48f453e9 100644 --- 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 @@ -193,6 +193,31 @@ class AppDatabaseSchemaTest { } } + @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() @@ -205,6 +230,7 @@ class AppDatabaseSchemaTest { VaultSchema.MIGRATION_3_4, VaultSchema.MIGRATION_4_5, VaultSchema.MIGRATION_5_6, + VaultSchema.MIGRATION_6_7, ), ).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 index 845e8153..dd64cd4b 100644 --- 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 @@ -199,7 +199,25 @@ class NotesDaoTest { assertEquals(null, dao.observeNoteWithTag(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) + } + private companion object { + const val EARLIER = 1_000L + const val STAMP = 2_000L const val TAG = 1L const val OTHER_TAG = 2L } 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 index 5de781e0..85392ab6 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -109,6 +110,32 @@ class VaultMigrationTest { } } + @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( @@ -155,6 +182,7 @@ class VaultMigrationTest { } private companion object { + 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/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 index 73a84668..9a8f32d5 100644 --- 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 @@ -14,7 +14,7 @@ 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 = 6 +public const val DB_VERSION: Int = 7 @Database( entities = [Note::class, Tag::class, Task::class, Subtask::class], 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 index 7be6d3d2..94b4d0b1 100644 --- 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 @@ -36,6 +36,9 @@ public object VaultSchema { /** 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 + private fun triggers(table: String) = listOf( "CREATE TRIGGER IF NOT EXISTS `${table}_uid_on_insert` AFTER INSERT ON `$table` " + "WHEN NEW.uid = '' BEGIN " + @@ -141,15 +144,32 @@ public object VaultSchema { } } + /** + * 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", + ) + } + } + /** * 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) - .addCallback(CALLBACK) + 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) + .addCallback(CALLBACK) /** Installs the triggers on a freshly created database. */ public val CALLBACK: RoomDatabase.Callback = object : RoomDatabase.Callback() { 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 index 82b27d34..468a73c0 100644 --- 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 @@ -74,6 +74,10 @@ public interface NotesDao { ) public suspend fun updateArchived(noteId: Long, archived: Boolean): Int + /** 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 + @Insert(onConflict = OnConflictStrategy.ABORT) public suspend fun insertNote(note: Note): Long 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 index a86a3abb..63babc51 100644 --- 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 @@ -36,6 +36,11 @@ public data class Note( 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, ) { /** 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 index a2c2c1b5..5ea1d8f2 100644 --- 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 @@ -191,6 +191,26 @@ class DesktopMigrationTest { 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)) + } + private companion object { const val UID_LENGTH = 32 const val SCHEMA_DIR = "com.pasich.encly.data.database.AppDatabase" diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index f39a7dfc..5f44b5d0 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -673,4 +673,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml index 95063cb2..b5cf3207 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -119,4 +119,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index 48a4d90f..27d3f8c0 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -679,4 +679,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml index fb3a18b8..88e12971 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -124,4 +124,6 @@ ¿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. diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index 842c0761..a1aabe6b 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -679,4 +679,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml index e603440f..5b8ec6aa 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -124,4 +124,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 423796d8..c6540086 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -679,4 +679,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml index ba9a4ec1..4789ac25 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -124,4 +124,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index c82583bb..ec77ca06 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -673,4 +673,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml index 2af80eb7..2d7b8c58 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -119,4 +119,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index 5ab620d3..e8950e68 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -685,4 +685,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml index 0b2711ba..0886a179 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -129,4 +129,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index 3fff995c..7ab83ea9 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -679,4 +679,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml index e97abf45..05e4decf 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -124,4 +124,6 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index 8f5aa9cc..94331040 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -685,4 +685,8 @@ PDF збережено Надіслано на принтер Ця нотатка відкрита для редагування в іншому вікні. Тут її можна лише читати. + Що нового + Зміни в цій версії + Зрозуміло + Нотатки остаточно видаляються через 28 днів у кошику. diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml index 01f893d4..ce5f826b 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -129,4 +129,6 @@ Вимкнути PIN під примусом? Підставне сховище та нотатки в ньому буде видалено. Ваше справжнє сховище це не зачепить. Вимкнути + Показувати попередній перегляд нотаток + Список нотаток показує перші рядки кожної нотатки. Вимкніть, щоб бачити лише заголовок, мітку й дату; пошук і далі знаходить увесь текст. diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index adc14de6..95449292 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -690,4 +690,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml index 1da0ef2f..dda7566d 100644 --- a/core/resources/src/commonMain/strings/values/strings_security.xml +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -119,4 +119,6 @@ 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. diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index b84b1a5a..b92e4c7e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -38,6 +38,16 @@ 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. @@ -58,6 +68,9 @@ kotlin { } sourceSets { + commonMain { + kotlin.srcDir(generateChangelogs.flatMap { it.output }) + } commonMain.dependencies { api(project(":core:common")) api(project(":core:crypto")) 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 index 962a0172..81c8b63f 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt @@ -134,7 +134,7 @@ class LocalizedScreensTest(private val language: String) : ComposeScreenTest() { @Test fun security() { setNavScreen( - viewModels(app.securitySettings(), app.backup()), + viewModels(app.securitySettings(), app.notePreviewsVm(), app.backup()), route = NavRoutes.SecuritySettingsRoute.name, ) { nav -> SecuritySettingsScreen(nav) 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..486948c1 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt @@ -0,0 +1,124 @@ +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.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.NoteWithTag +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 item = NoteListItem.of(NoteWithTag(note(1, "Groceries", "milk and bread"), Tag(id = 1, nameTag = "Home"))) + setScreen { + CompositionLocalProvider(LocalShowNotePreviews provides show) { + NoteItem(onItemClick = {}, onItemLongClick = {}, item = item) + } + } + } + + @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 item = NoteListItem.of(NoteWithTag(note(1, "Plan", "body").copy(description = "A short summary"), null)) + 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.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/SecurityScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt index 904118e9..fabe996a 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -101,7 +101,8 @@ class SecurityScreensTest : ComposeScreenTest() { 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 -> + val owner = viewModels(app.securitySettings(), app.notePreviewsVm(), vault) + setNavScreen(owner, route = NavRoutes.SecuritySettingsRoute.name) { nav -> SecuritySettingsScreen(nav) } waitForText(str(Res.string.security_encryption_active)) 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 index 1c51d453..485410e7 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt @@ -28,6 +28,7 @@ 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 @@ -150,6 +151,7 @@ class TagsAndTrashTest : ComposeScreenTest() { 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 diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 47a68092..fbbc1d1b 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -28,10 +28,12 @@ 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.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,6 +42,7 @@ 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 @@ -50,11 +53,13 @@ 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 @@ -65,6 +70,7 @@ 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 @@ -190,6 +196,21 @@ internal class TestApp { fun mainListState() = MainListStateViewModel(settings) + val notePreviews = NotePreviews(InMemoryKeyValueStore()) + + fun notePreviewsVm() = NotePreviewsViewModel(notePreviews) + + /** 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, CleanTrashNotesUseCase(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)) @@ -249,6 +270,8 @@ internal class TestApp { /** The ViewModels the home screen and its drawer, chips and task card ask for. */ fun home(noteList: NoteListViewModel = noteList()): Array = arrayOf( mainListState(), + notePreviewsVm(), + startup(), settingsVm(), noteList, search(), 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/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt index 5c84019a..37b4b51b 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -64,4 +64,10 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note 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 + } } 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/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt index d12e79d5..a77be7c8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -37,4 +37,7 @@ interface NotesRepository { suspend fun insertNote(note: Note): Result suspend fun updateNote(note: Note): 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 } 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..20f9194e --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/usecase/note/PurgeExpiredTrashUseCase.kt @@ -0,0 +1,39 @@ +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 +import kotlinx.coroutines.flow.first + +/** 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, the same way "Delete + * forever" does ([CleanTrashNotesUseCase]). Run when the vault is open: on unlock, and while the app is used. + */ +@Inject +class PurgeExpiredTrashUseCase( + private val repository: NotesRepository, + private val cleanTrash: CleanTrashNotesUseCase, +) { + /** 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. + val trashed = repository.stampUntimedTrash(now).mapCatching { repository.getTrashNotes().first() } + .getOrElse { return Result.failure(it) } + val expired = trashed.filter { it.isExpired(now) } + return cleanTrash.cleanSelectedNotes(expired).map { expired.size } + } +} + +private fun Note.isExpired(now: Long): Boolean { + val trashedAt = trashedAt ?: return false + return now - trashedAt > TRASH_RETENTION_MILLIS +} 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 index 3ff801e4..adcbefbf 100644 --- 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 @@ -1,12 +1,13 @@ 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, or back out of it. */ +/** Moves the note to the trash, remembering when, or back out of it. */ @Inject class UpdateNoteTrashStatusUseCase(private val repository: NotesRepository) { - suspend operator fun invoke(targetNote: Note, isTrash: Boolean): Result = - repository.updateNote(targetNote.copy(isTrash = isTrash)) + suspend operator fun invoke(targetNote: Note, isTrash: Boolean, now: Long = currentTimeMillis()): Result = + repository.updateNote(targetNote.copy(isTrash = isTrash, trashedAt = now.takeIf { isTrash })) } 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 index 66b22457..7218a53e 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -39,6 +40,13 @@ 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. @@ -61,7 +69,11 @@ fun NoteItem( onCommandClick: ((Note) -> Unit)? = null, ) { val note: Note = item?.note ?: itemNote ?: Note() - val preview = rememberNotePreview(item, note) ?: stringResource(Res.string.note_preview_unreadable) + val preview = if (LocalShowNotePreviews.current) { + rememberNotePreview(item, note) ?: stringResource(Res.string.note_preview_unreadable) + } else { + "" + } val hapticFeedback = LocalHapticFeedback.current val menu = noteMenuEntries(note, contextActions) 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/navigation/EnclyApp.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt index 8b3051ae..4ea12485 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -14,6 +14,7 @@ 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 @@ -28,21 +29,28 @@ 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.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.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 @@ -65,6 +73,10 @@ fun EnclyApp( ) { val destination = startStatus?.let(::startRoute) ?: return val navController = rememberNavController() + // 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 @@ -285,11 +297,16 @@ private fun AppContent( .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) { + val showPreviews by metroViewModel().show.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) { + CompositionLocalProvider( + LocalUnlockReveal provides unlockReveal, + LocalShowNotePreviews provides showPreviews, + LocalVaultLocked provides (locked || shielded), + ) { // The whole app layer moves with the unlock reveal: it slides in from the // right and pushes the logo colour out (see UnlockRevealOverlay). Box( 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 index 53c7494f..91683758 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/AboutScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/AboutScreen.kt @@ -11,6 +11,10 @@ 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 @@ -41,6 +45,8 @@ 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 @@ -56,14 +62,21 @@ 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, @@ -85,6 +98,16 @@ fun AboutScreen(navController: NavHostController, modifier: Modifier = Modifier) ) 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), @@ -93,6 +116,7 @@ fun AboutScreen(navController: NavHostController, modifier: Modifier = Modifier) modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), ) } + if (whatsNewOpen && whatsNew != null) WhatsNewSheet(whatsNew, onDismiss = { whatsNewOpen = false }) } } } 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 index 5609ac9d..2cd92888 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/MainScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/MainScreen.kt @@ -82,6 +82,7 @@ fun MainRootScreen(navController: NavHostController, modifier: Modifier = Modifi WidthClass.Medium -> MediumMainRoot(navController, modifier) WidthClass.Expanded -> ExpandedMainRoot(navController, modifier) } + UnlockedStartup() } /** The phone layout: the notes, with the navigation drawer sliding over them. */ 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/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/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 index db6274a1..85af25ca 100644 --- 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 @@ -207,6 +207,7 @@ private fun SecurityRows( } } PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) + NotePreviewsSetting() ProtectionSection() EraseSection(busy = vaultBusy, onErase = { actions.onVaultAction(BackupAction.ERASE) }) } 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 index 9678c53e..525cebdb 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -22,6 +23,7 @@ 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 @@ -38,8 +40,8 @@ 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. + * 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( @@ -99,12 +101,21 @@ private fun LazyListScope.trashCards( onToggle: (Int) -> Unit, ) { item(key = "hint") { - Text( - text = stringResource(Res.string.trash_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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( 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/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/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..0e2e5bfd --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt @@ -0,0 +1,48 @@ +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.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/presentation/viewmodel/ArchiveViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt index e289e744..e406d857 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt @@ -23,6 +23,7 @@ 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) @@ -88,7 +89,9 @@ 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(note.copy(isTrash = true, trashedAt = trashed.trashedAt), trashed) } @Test diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt index 9e9293b0..97255c17 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -102,6 +102,11 @@ internal class TestNotesRepository : NotesRepository { 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) + } + private companion object { const val DEFAULT_INSERT_ID = 42L } 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/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..9ad5821c --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt @@ -0,0 +1,95 @@ +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.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +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` + +class TrashExpiryTest { + private val repository = TestNotesRepository() + private val purge = PurgeExpiredTrashUseCase(repository, CleanTrashNotesUseCase(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 aTrashThatCannotBeReadIsAFailureAndDeletesNothing() = runTest { + val notes = mock(NotesRepository::class.java) + `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.success(Unit)) + `when`(notes.getTrashNotes()).thenReturn(flow { throw IllegalStateException("vault locked") }) + + assertTrue(PurgeExpiredTrashUseCase(notes, CleanTrashNotesUseCase(notes))(NOW).isFailure) + verify(notes, never()).deleteNoteById(1) + } + + @Test + fun aFailedStampStopsBeforeAnythingIsRead() = runTest { + val notes = mock(NotesRepository::class.java) + `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.failure(IllegalStateException("disk"))) + + assertTrue(PurgeExpiredTrashUseCase(notes, CleanTrashNotesUseCase(notes))(NOW).isFailure) + verify(notes, never()).getTrashNotes() + } + + private companion object { + const val NOW = 2_000_000_000_000L + } +} 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..2a89797f --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt @@ -0,0 +1,240 @@ +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", + ) + 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("ar")) + assertEquals("en-US", Changelogs.folderFor("ja-JP")) + 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, Arabic is not an app language: both read English. + assertEquals("• English change", Changelogs.notes("fr", bundled)?.body) + assertEquals("• English change", Changelogs.notes("ar", 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(9, 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, CleanTrashNotesUseCase(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) + } +} From 2461ba075ea19dd95a6c011fec10b2ba74b1b751 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 10:59:22 +0200 Subject: [PATCH 35/75] fix(editor): keep the creation time, steadier lists and find in note A new note kept its creation time only until the first autosave, which wrote the row back with none; it is now carried from the first insert. Notes that already lost it sort by their last edit instead of as the oldest. Scrolling over a list no longer leaves its toolbar button active, a quote has the same gap below as above, find highlights are stronger and stepping through matches scrolls to them inside long blocks, holding delete keeps going across list items, and opening and closing a note is quicker. Refs: #103, #76 --- editor/api/android/editor.api | 1 + editor/api/desktop/editor.api | 1 + editor/api/editor.klib.api | 2 + .../encly/editor/ui/EditorReportsUiTest.kt | 167 ++++++++++++++++++ .../pasich/encly/editor/state/ListEditing.kt | 45 ++--- .../encly/editor/ui/EditorBlocksHost.kt | 30 +++- .../pasich/encly/editor/ui/FindHighlights.kt | 31 ++++ .../encly/editor/ui/blocks/FieldHighlight.kt | 32 +++- .../encly/editor/ui/blocks/QuoteBlock.kt | 4 +- .../encly/editor/state/BlockEditingTest.kt | 31 +++- .../encly/editor/state/UndoCaretTest.kt | 16 ++ .../encly/domain/enums/NoteSortOption.kt | 12 +- .../editor/persistence/NotePersistence.kt | 21 ++- .../encly/presentation/effects/Animation.kt | 13 +- .../effects/ContainerTransform.kt | 13 +- .../screen/editnote/EditNoteScreen.kt | 2 +- .../screen/editnote/EditorEffects.kt | 12 +- .../encly/domain/enums/NoteSortOptionTest.kt | 29 +++ .../editor/persistence/NotePersistenceTest.kt | 16 ++ 19 files changed, 416 insertions(+), 62 deletions(-) create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/EditorReportsUiTest.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index d8fafcc9..697a84b5 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -861,6 +861,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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/TapBelowBlocksKt { diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index d8fafcc9..697a84b5 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -861,6 +861,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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/TapBelowBlocksKt { diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index fd15f233..dfd0358a 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -751,6 +751,8 @@ final class com.pasich.encly.editor.ui/EditorBlocksHost { // com.pasich.encly.ed 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] } sealed class com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.model/Block|null[0] 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/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt index 3cad25fb..b20c342d 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt @@ -108,8 +108,7 @@ private fun BlockEditorState.exitList(index: Int, list: Block.ListBlock, at: Int /** * 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. + * 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 @@ -131,30 +130,32 @@ public fun BlockEditorState.listBackspaceAtStart(listId: String, itemId: String) 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) - } + 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) + removeAt(at - 1) + } + batch { setValue(list.id, list.items, newItems) } + 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). 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 index 32a0a67e..298e3a6c 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -1,6 +1,7 @@ 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 @@ -23,7 +24,10 @@ 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 @@ -336,12 +340,24 @@ private fun BlockContent( } } -/** 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() - } +/** + * 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/FindHighlights.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt index 63b835f9..7c70683d 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt @@ -1,9 +1,11 @@ package com.pasich.encly.editor.ui import androidx.compose.runtime.State +import androidx.compose.runtime.withFrameNanos import com.pasich.encly.editor.state.FindMatch import com.pasich.encly.editor.state.FindState 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 @@ -17,4 +19,33 @@ public class FindHighlights(private val state: State) { /** The match "n of m" refers to, if any. */ internal val current: FindMatch? get() = state.value.currentMatch + + // 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 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 index dbc2f7c4..0ea85db4 100644 --- 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 @@ -1,13 +1,17 @@ package com.pasich.encly.editor.ui.blocks +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester 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.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.unit.Density @@ -15,8 +19,8 @@ import com.pasich.encly.editor.focus.CaretGeometry import com.pasich.encly.editor.focus.geometry import com.pasich.encly.editor.ui.FindHighlights -private const val MATCH_ALPHA = 0.18f -private const val CURRENT_MATCH_ALPHA = 0.4f +private const val MATCH_ALPHA = 0.4f +private const val CURRENT_MATCH_ALPHA = 0.65f /** * Tints the Find matches of one text field behind its text. Pass [onTextLayout] to the field and @@ -41,7 +45,22 @@ internal class FieldHighlight( if (layout == null) layout = getResult } - val modifier: Modifier = Modifier.drawBehind { + 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(Rect(from.left, from.top, to.right, to.bottom)) + return true + } + + val modifier: Modifier = Modifier.bringIntoViewRequester(bringIntoView).drawBehind { val matches = highlights.matchesOf(blockId, itemId) if (matches.isEmpty()) return@drawBehind val result = layout?.invoke() ?: return@drawBehind @@ -62,7 +81,7 @@ internal fun rememberFieldHighlight( itemId: String? = null, ): FieldHighlight { val primary = MaterialTheme.colorScheme.primary - return remember(highlights, blockId, itemId, primary) { + val field = remember(highlights, blockId, itemId, primary) { FieldHighlight( highlights = highlights, blockId = blockId, @@ -71,4 +90,9 @@ internal fun rememberFieldHighlight( currentMatch = primary.copy(alpha = CURRENT_MATCH_ALPHA), ) } + DisposableEffect(field) { + highlights.register(blockId, itemId, field) + onDispose { highlights.unregister(blockId, itemId, field) } + } + return field } 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 index 2fc32768..998185c4 100644 --- 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 @@ -68,11 +68,11 @@ internal fun QuoteBlock( ) // A tonal card between an opening and a closing quote mark in `primary`, 8 dp below the block - // above (design spec §4.4). + // above and 8 dp above the one below (design spec §4.4). Column( verticalArrangement = Arrangement.spacedBy(spacing.labelGap), modifier = modifier - .padding(top = spacing.xs) + .padding(vertical = spacing.xs) .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.medium) .padding(horizontal = spacing.fieldGap, vertical = spacing.m), diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt index 902c91ac..6ac5605e 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt @@ -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 diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt index 479c677a..f621c8e1 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt @@ -187,4 +187,20 @@ 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()) + } + } } 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 index 785290ac..c6fa7670 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt @@ -39,10 +39,18 @@ enum class NoteSortOption(val labelRes: StringResource, val shortLabelRes: Strin 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 } + CREATED_ASC -> compareBy { it.createdOrEdited() } + CREATED_DESC -> compareByDescending { it.createdOrEdited() } } + /** + * 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/presentation/editor/persistence/NotePersistence.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt index 2706f1bb..1b8c1e9e 100644 --- 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 @@ -206,11 +206,16 @@ class NotePersistence( stored?.let { note -> updateNote { it.copy(date = note.date, description = note.description) } } stored != null } else { - val insertedId = withContext(ioDispatcher) { notesRepository.insertDraft(currentNote, blocksJson) } + 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. + // 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 = Clock.System.now().toEpochMilliseconds()) } + updateNote { + it.copy(id = id, value = blocksJson, date = draft.date, dateCreate = draft.dateCreate) + } } insertedId != null } @@ -398,11 +403,13 @@ private fun List.frozen(): List = map { 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( +/** 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, tagId = note.tagId) - .copy(isPinned = note.isPinned, color = note.color, textSize = note.textSize, lineSpacing = note.lineSpacing), -) + .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 } 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 index 7df4c9b5..2da56b9f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt @@ -37,19 +37,22 @@ fun axisExit(forward: Boolean): ExitTransition = } + fadeOut(tween(AXIS_FADE_OUT)) /** - * Duration of the card-to-editor container transform. Longer than a screen slide: the card travels + * 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 = 450 +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 = 300 +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 = 90 +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 = 180 +const val CONTAINER_FADE_OUT = 120 /** * The cross-fade of the screens behind a container transform, paced with it: the screen being left 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 index 296f0c18..81084c77 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt @@ -2,6 +2,7 @@ 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 @@ -85,7 +86,7 @@ private fun rememberContainerBounds( animatedVisibilityScope = visibility, enter = if (end == ContainerEnd.EDITOR) fadeIn(ContainerFadeIn) else EnterTransition.None, exit = if (end == ContainerEnd.EDITOR) fadeOut(ContainerFadeOut) else ExitTransition.None, - boundsTransform = ContainerBounds, + boundsTransform = containerBounds(visibility, end), resizeMode = if (end == ContainerEnd.CARD) { SharedTransitionScope.ResizeMode.RemeasureToBounds } else { @@ -102,7 +103,15 @@ private val ContainerFadeIn = private val ContainerFadeOut = tween(CONTAINER_FADE_OUT, easing = FastOutLinearInEasing) -private val ContainerBounds = BoundsTransform { _, _ -> tween(CONTAINER_DURATION, easing = ContainerEasing) } +/** + * 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 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 index e0972449..9a731a98 100644 --- 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 @@ -155,7 +155,7 @@ fun EditNoteScreen( ClaimNoteForEditing() val blocksStart = HEADER_ITEMS + (if (contentLoadFailed) 1 else 0) + (if (editedElsewhere) 1 else 0) CarryOutFocusRequests(focusRegistry, listState, blocksStart = blocksStart) - ScrollToFindMatch(listState, blocksStart) + ScrollToFindMatch(listState, highlights, blocksStart) FocusNewNoteTitle(titleFocus) BackHandler { exits.back() } CloseFindOrSlashOnBack() 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 index e350349d..bf7e73d5 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -148,12 +149,14 @@ internal fun rememberFindOpen(viewModel: EditNoteViewModel = metroViewModel()): } /** - * 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. + * 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(), ) { @@ -161,9 +164,10 @@ internal fun ScrollToFindMatch( 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 } + val found = match ?: return@LaunchedEffect + val index = viewModel.blocks.indexOfFirst { it.id == found.blockId } if (index >= 0) listState.reveal(currentBlocksStart + index) + highlights.reveal(found) } } 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..fe186802 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt @@ -0,0 +1,29 @@ +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 }) + } +} 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 index 4c9df8e7..30dac6ee 100644 --- 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 @@ -46,6 +46,22 @@ class NotePersistenceTest { 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() From 3795237bee6a270d256aa92fce3ac2dd257f345d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:15:43 +0200 Subject: [PATCH 36/75] fix(security): biometric unlock after a background lock, 5 and 10 minute auto-lock, screenshots until the app locks A lock that happened while Encly was in the background started the fingerprint prompt before the activity was back on screen; the prompt was dropped without an answer and the guard stayed closed, so the button did nothing. The prompt now waits for the screen and the guard opens again when the activity stops. Auto-lock can be 5 or 10 minutes. Screenshots can be allowed after re-authentication until the next lock; the lock, PIN and recovery phrase screens stay protected and the allowance is never stored. Refs: #104 --- .../encly/platform/AndroidHostServices.kt | 72 +++++- .../com/pasich/encly/core/di/AppGraphTest.kt | 14 ++ .../encly/platform/AndroidHostProvideTest.kt | 57 +++++ .../encly/platform/AndroidHostServicesTest.kt | 28 +++ config/detekt/detekt.yml | 1 + .../commonMain/strings/values-de/strings.xml | 2 +- .../strings/values-de/strings_security.xml | 6 + .../commonMain/strings/values-es/strings.xml | 2 +- .../strings/values-es/strings_security.xml | 6 + .../commonMain/strings/values-fr/strings.xml | 2 +- .../strings/values-fr/strings_security.xml | 6 + .../commonMain/strings/values-it/strings.xml | 2 +- .../strings/values-it/strings_security.xml | 6 + .../commonMain/strings/values-nl/strings.xml | 2 +- .../strings/values-nl/strings_security.xml | 6 + .../commonMain/strings/values-pl/strings.xml | 2 +- .../strings/values-pl/strings_security.xml | 6 + .../commonMain/strings/values-pt/strings.xml | 2 +- .../strings/values-pt/strings_security.xml | 6 + .../commonMain/strings/values-uk/strings.xml | 2 +- .../strings/values-uk/strings_security.xml | 6 + .../src/commonMain/strings/values/strings.xml | 2 +- .../strings/values/strings_security.xml | 6 + .../pasich/encly/core/security/AutoLock.kt | 2 + .../core/security/ScreenshotAllowance.kt | 79 +++++++ .../encly/core/security/SessionLockManager.kt | 4 + .../encly/core/security/AutoLockTest.kt | 21 ++ .../core/security/ScreenshotAllowanceTest.kt | 101 ++++++++ .../core/security/SessionLockManagerTest.kt | 91 +++++++- .../presentation/viewmodel/BackupFlowsTest.kt | 2 + .../viewmodel/BackupViewModelTest.kt | 70 +++++- .../ui/screens/LockScreenAutoPromptTest.kt | 100 ++++++++ .../screens/ScreenshotAllowanceScreensTest.kt | 217 ++++++++++++++++++ .../com/pasich/encly/ui/screens/TestApp.kt | 6 +- .../com/pasich/encly/platform/HostServices.kt | 12 +- .../designsystem/ProtectFromCapture.kt | 35 +++ .../designsystem/RecoveryPhraseInput.kt | 1 + .../presentation/designsystem/WordGrid.kt | 1 + .../encly/presentation/screen/LockScreen.kt | 12 +- .../screen/backup/BackupDialogs.kt | 3 + .../screen/onboarding/PhraseCheckFields.kt | 2 + .../screen/pincode/PinEntryScaffold.kt | 2 + .../screen/settings/AutoLockSetting.kt | 4 + .../screen/settings/SecuritySections.kt | 55 ++++- .../screen/settings/SecuritySettingsScreen.kt | 41 +++- .../presentation/viewmodel/BackupFlows.kt | 5 +- .../presentation/viewmodel/BackupViewModel.kt | 18 ++ .../viewmodel/BiometricPromptGuard.kt | 20 +- .../viewmodel/SecuritySettingsViewModel.kt | 10 + .../kotlin/com/pasich/encly/ui/theme/Theme.kt | 3 +- .../com/pasich/encly/testutil/TestScreen.kt | 18 +- .../viewmodel/LockViewModelTest.kt | 50 ++++ .../SecuritySettingsViewModelTest.kt | 15 ++ 53 files changed, 1206 insertions(+), 38 deletions(-) create mode 100644 core/security/src/commonMain/kotlin/com/pasich/encly/core/security/ScreenshotAllowance.kt create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/core/security/ScreenshotAllowanceTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenAutoPromptTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ProtectFromCapture.kt diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt index e436afdd..4f4f00e1 100644 --- a/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt +++ b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt @@ -12,12 +12,15 @@ 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 @@ -25,6 +28,7 @@ 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 @@ -46,6 +50,7 @@ class AndroidHostServices( 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 @@ -57,6 +62,7 @@ class AndroidHostServices( 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, @@ -64,6 +70,7 @@ class AndroidHostServices( LocalToaster provides toaster, LocalSensitiveClipboard provides clipboard, LocalBiometricHost provides biometricHost, + LocalScreenshotAllowance provides screenshots, LocalAppLocales provides locales, LocalAppInfo provides info, LocalDocumentPickers provides AndroidDocumentPickers, @@ -75,6 +82,33 @@ class AndroidHostServices( } } +/** + * 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. @@ -84,7 +118,43 @@ class AndroidAppWindow(private val activity: Activity, private val view: View) : override val isChangingConfigurations: Boolean get() = activity.isChangingConfigurations - override fun enforceSecure() = window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + /** + * 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 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 index 3a465261..c56d02b3 100644 --- a/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -16,6 +16,7 @@ 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 @@ -62,6 +63,7 @@ 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 @@ -90,6 +92,7 @@ interface ContributionsGraph : ViewModelGraph { val seedPhraseManager: SeedPhraseManager val biometricManager: BiometricManager val autoLock: AutoLock + val screenshotAllowance: ScreenshotAllowance val backupManager: BackupManager val notesRepository: NotesRepository val settingsRepository: SettingsRepository @@ -133,6 +136,7 @@ class AppGraphTest { 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) @@ -145,6 +149,16 @@ class AppGraphTest { 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) diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt index 748e698b..ae3c310b 100644 --- a/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt +++ b/app/src/test/java/com/pasich/encly/platform/AndroidHostProvideTest.kt @@ -3,19 +3,23 @@ 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 @@ -40,6 +44,7 @@ class AndroidHostProvideTest { private var scenario: ActivityScenario? = null private val factory = mock(MetroViewModelFactory::class.java) + private val allowance = ScreenshotAllowance() @Before fun setUp() { @@ -57,6 +62,7 @@ class AndroidHostProvideTest { locales = FakeLocales, info = AppInfo.Default, viewModelFactory = factory, + screenshots = allowance, ) } @@ -84,6 +90,55 @@ class AndroidHostProvideTest { 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()) } } @@ -98,6 +153,7 @@ class SeenServices( val pickers: DocumentPickers, val biometric: ScreenBiometricHost?, val factory: MetroViewModelFactory?, + val screenshots: ScreenshotAllowance?, ) class ProvideHostActivity : AppCompatActivity() { @@ -122,6 +178,7 @@ class ProvideHostActivity : AppCompatActivity() { pickers = LocalDocumentPickers.current, biometric = LocalBiometricHost.current, factory = LocalMetroViewModelFactory.current, + screenshots = LocalScreenshotAllowance.current, ) } diff --git a/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt b/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt index 011b93c3..20696a39 100644 --- a/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt +++ b/app/src/test/java/com/pasich/encly/platform/AndroidHostServicesTest.kt @@ -208,6 +208,34 @@ class AndroidHostServicesTest { 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/config/detekt/detekt.yml b/config/detekt/detekt.yml index e933dbe4..ea0fe5bb 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -98,6 +98,7 @@ Compose: - LocalToaster - LocalSensitiveClipboard - LocalBiometricHost + - LocalScreenshotAllowance - LocalAppLocales - LocalAppInfo - LocalNavigationRequests diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index 5f44b5d0..bf495ca3 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml index b5cf3207..a236860b 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -52,6 +52,10 @@ 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 @@ -59,6 +63,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index 27d3f8c0..0010ced3 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -133,7 +133,7 @@ ¿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. + 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml index 88e12971..8003f352 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -57,6 +57,10 @@ 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 @@ -64,6 +68,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index a1aabe6b..c96feac8 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml index 5b8ec6aa..2b8227e0 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -57,6 +57,10 @@ 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 @@ -64,6 +68,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index c6540086..02805b03 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml index 4789ac25..8bae03b2 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -57,6 +57,10 @@ 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 @@ -64,6 +68,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index ec77ca06..fc54fa32 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml index 2d7b8c58..cc3f271a 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -52,6 +52,10 @@ 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 @@ -59,6 +63,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index e8950e68..270a8926 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml index 0886a179..eba5d574 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -62,6 +62,10 @@ 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 @@ -69,6 +73,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index 7ab83ea9..dc7dd115 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -133,7 +133,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml index 05e4decf..62b47b69 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -57,6 +57,10 @@ 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 @@ -64,6 +68,8 @@ 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. diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index 94331040..5c4dfbc7 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -133,7 +133,7 @@ Як зберігаються зміни? Нотатки, завдання, локальне шифрування та відновлення доступу без хмарної синхронізації. Відповіді на найпоширеніші запитання - Ні. Захист екрана увімкнений постійно: Encly блокує знімки екрана, запис екрана та незахищений попередній перегляд у списку нещодавніх застосунків. + Типово ні. Захист екрана увімкнений: Encly блокує знімки екрана, запис екрана та незахищений попередній перегляд у списку нещодавніх застосунків. Якщо знімок потрібен, Налаштування → Безпека → «Дозволити знімки екрана до блокування» знімає захист після введення PIN; екрани блокування, PIN і фрази відновлення лишаються захищеними, а дозвіл вимикається сам, коли Encly блокується. Чи можна робити скріншоти Encly? Основні функції Безпека та відновлення diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml index ce5f826b..8f3ad179 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -62,6 +62,10 @@ Приватність Сувора приватність клавіатури Просить клавіатуру не показувати підказки, не використовувати хмарне передбачення й не читати текст біля курсора. Вимикає автовиправлення, а деякі клавіатури ховають емодзі чи голосовий ввід. + Дозволити знімки екрана до блокування + Знімки й запис екрана заблоковані, доки ви не дозволите їх тут. Вимикається саме, коли Encly блокується. + Дозволити знімки екрана? + Знімки екрана можуть потрапити в галерею та хмарні резервні копії разом із вашими нотатками. Екран блокування, введення PIN-коду та фраза відновлення залишаються захищеними. Дозвіл закінчується, коли Encly блокується. Блокувати після виходу Скільки Encly лишається відкритим, коли ви переходите в інший застосунок. Вимкнення екрана чи блокування телефона завжди блокує його одразу. Одразу @@ -69,6 +73,8 @@ Через 30 секунд Через 1 хвилину Через 2 хвилини + Через 5 хвилин + Через 10 хвилин Через 15 хвилин Через 30 хвилин Довший час залишає сховище відкритим у фоні. Людина з доступом до розблокованого телефона може побачити ваші нотатки. diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index 95449292..421c320b 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -149,7 +149,7 @@ 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. + 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 diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml index dda7566d..5d34817b 100644 --- a/core/resources/src/commonMain/strings/values/strings_security.xml +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -52,6 +52,10 @@ 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 @@ -59,6 +63,8 @@ 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. 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 index cdee2e83..7cecb11a 100644 --- 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 @@ -16,6 +16,8 @@ public enum class AutoLockDelay(public val millis: Long) { 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), ; 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/SessionLockManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SessionLockManager.kt index 23ac5fc3..53428591 100644 --- 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 @@ -53,6 +53,7 @@ public class SessionLockManager( systemClock: LockoutClock = JvmMonotonicClock, private val autoLock: AutoLockPolicy = AutoLockPolicy.Immediately, private val mainThread: MainThreadTimer = MainThreadTimer.None, + private val screenshots: ScreenshotAllowance = ScreenshotAllowance(), ) : DefaultLifecycleObserver, VaultLockEvents { @@ -128,6 +129,7 @@ public class SessionLockManager( */ @MainThread public fun requireUnlock() { + screenshots.revoke() _locked.value = true } @@ -229,6 +231,8 @@ public class SessionLockManager( private fun isOpenAndLockable(): Boolean = securityManager.isLockable() && securityManager.isDatabaseUnlocked() private fun relock() { + // The screenshot allowance ends with every lock, before anything else can show. + screenshots.revoke() securityManager.lock() _locked.value = true } 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 index 68f73392..aa1f660c 100644 --- 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 @@ -21,6 +21,27 @@ class AutoLockTest { 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() 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/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt index e3960a99..2471b1f5 100644 --- 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 @@ -18,12 +18,13 @@ 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) + manager = SessionLockManager(security, screenshots = screenshots) manager.onStart(owner) } @@ -329,6 +330,7 @@ class SessionLockManagerTest { autoLock = object : AutoLockPolicy { override val delayMillis: Long = millis }, + screenshots = screenshots, ) manager.onStart(owner) withFakeTime() @@ -415,6 +417,93 @@ class SessionLockManagerTest { 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) + } + private class FakeDeviceLock : DeviceLockWatcher { var locked = false var watching = false diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index bc635a0a..3a34d482 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -10,6 +10,7 @@ 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 @@ -434,6 +435,7 @@ class BackupFlowsTest { securityManager = security, sessionLockManager = SessionLockManager(security), documents = documents, + screenshots = ScreenshotAllowance(), ) private fun state() = viewModel.uiState.value 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 index 890b3cf7..e24741ae 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -5,6 +5,7 @@ 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 @@ -22,6 +23,7 @@ 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 @@ -36,17 +38,21 @@ 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()), phraseSetup = BackupPhraseSetup(security), securityManager = security, - sessionLockManager = SessionLockManager(security), + sessionLockManager = sessionLock, documents = BackupDocuments(FakeDocuments()), + screenshots = screenshots, ) viewModel.start(BackupAction.IMPORT) } @@ -171,6 +177,68 @@ class BackupViewModelTest { 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) 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/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..d95d1a09 --- /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.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) + + typePin("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/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index fbbc1d1b..361059fa 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -14,6 +14,7 @@ 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.SessionLockManager import com.pasich.encly.core.security.WipePinChange @@ -94,7 +95,8 @@ internal class TestApp { val markdownDocuments: MarkdownDocuments = mock(MarkdownDocuments::class.java) val store = InMemoryVaultDataStore() val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) - val sessionLock = SessionLockManager(security) + 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. @@ -228,6 +230,7 @@ internal class TestApp { security, KeyboardPrivacy(InMemoryKeyValueStore()), AutoLock(InMemoryKeyValueStore()), + screenshots, ) fun backup() = BackupViewModel( @@ -236,6 +239,7 @@ internal class TestApp { securityManager = security, sessionLockManager = sessionLock, documents = documents, + screenshots = screenshots, ) fun markdownExport() = MarkdownExportViewModel( diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt index 5e4a884e..f716cd11 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt @@ -4,6 +4,7 @@ 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 @@ -56,7 +57,10 @@ 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). */ + /** + * 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. */ @@ -145,6 +149,12 @@ val LocalSensitiveClipboard = staticCompositionLocalOf { NoC /** 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 } + /** The in-app language controller. */ val LocalAppLocales = staticCompositionLocalOf { FixedLocale } 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt index 34b45b46..8c6706b9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt @@ -188,6 +188,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) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt index 54439a35..8377f8f6 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt @@ -34,6 +34,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) 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 index 4f37e16d..2d9887b0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.backhandler.BackHandler import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.lifecycle.Lifecycle import androidx.navigation.NavHostController import com.pasich.encly.core.resources.Res import com.pasich.encly.core.resources.lock_back_to_pin @@ -51,6 +52,7 @@ import com.pasich.encly.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import org.jetbrains.compose.resources.stringResource /** The app's lock screen: unlocking opens Home (or the note that was open), see [leaveLockScreen]. */ @@ -138,8 +140,14 @@ fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockV } } - LaunchedEffect(Unit) { - if (biometricEnabled) promptBiometric() + // 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() + } } Box(modifier = modifier.fillMaxSize()) { 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 index 051b6986..a24797ac 100644 --- 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 @@ -82,6 +82,7 @@ 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.ProtectFromCapture import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput import com.pasich.encly.presentation.designsystem.RecoveryPhraseState import com.pasich.encly.presentation.designsystem.StepHeading @@ -202,6 +203,8 @@ private fun MessageDialog( */ @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() val host = LocalBiometricHost.current // Wiped once submitted or when the dialog closes, never a String (see PinBuffer). val input = remember { PinBuffer() } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt index ccc6749f..1d5d665c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt @@ -17,6 +17,7 @@ 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 @@ -35,6 +36,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) -> diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt index 5b3dc28a..f88c3b64 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt @@ -37,6 +37,7 @@ 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 +88,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( 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 index 952de564..4c293647 100644 --- 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 @@ -12,12 +12,14 @@ 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 @@ -98,6 +100,8 @@ private fun AutoLockDelay.label(): StringResource = when (this) { 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/SecuritySections.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySections.kt index 3e869eae..f4f8c855 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -45,6 +46,10 @@ 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 @@ -248,9 +253,22 @@ internal fun ReplacePhraseRow(busy: Boolean, onStart: (BackupAction) -> Unit) { ) } -/** Privacy options that are not about unlocking: strict keyboard privacy (off by default). */ +/** + * "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) { +internal fun PrivacySection( + strictKeyboard: Boolean, + onStrictKeyboard: (Boolean) -> Unit, + screenshots: ScreenshotsSetting? = null, +) { SectionOverline( text = stringResource(Res.string.security_privacy_section), modifier = Modifier.padding(top = EnclyTheme.spacing.s), @@ -264,5 +282,38 @@ internal fun PrivacySection(strictKeyboard: Boolean, onStrictKeyboard: (Boolean) onCheckedChange = onStrictKeyboard, modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), ) + if (screenshots != null) { + 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 index 85af25ca..6212ffc2 100644 --- 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 @@ -36,6 +36,7 @@ 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 @@ -65,8 +66,7 @@ fun SecuritySettingsScreen( vaultViewModel: BackupViewModel = metroViewModel(), ) { val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() - val strictKeyboard by securityViewModel.strictKeyboard.collectAsStateWithLifecycle() - val autoLockDelay by securityViewModel.autoLockDelay.collectAsStateWithLifecycle() + val options = rememberSecurityOptions(securityViewModel, vaultViewModel) val vaultState by vaultViewModel.uiState.collectAsStateWithLifecycle() val host = LocalBiometricHost.current @@ -102,7 +102,7 @@ fun SecuritySettingsScreen( if (securityState.loaded) { SecurityContent( securityState = securityState, - options = SecurityOptions(strictKeyboard = strictKeyboard, autoLock = autoLockDelay), + options = options, vaultBusy = vaultState.busy, actions = SecurityActions( onOpen = { route -> navController.navigate(route.name) }, @@ -132,8 +132,33 @@ private class SecurityActions( 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) +/** 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( @@ -206,7 +231,11 @@ private fun SecurityRows( ReplacePhraseRow(busy = vaultBusy, onStart = actions.onVaultAction) } } - PrivacySection(strictKeyboard = options.strictKeyboard, onStrictKeyboard = actions.onStrictKeyboard) + PrivacySection( + strictKeyboard = options.strictKeyboard, + onStrictKeyboard = actions.onStrictKeyboard, + screenshots = options.screenshots, + ) NotePreviewsSetting() ProtectionSection() EraseSection(busy = vaultBusy, onErase = { actions.onVaultAction(BackupAction.ERASE) }) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index 6aac90e9..2db29eba 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -38,9 +38,10 @@ 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 { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index 116b7ed2..e1777ce8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -7,6 +7,7 @@ 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 @@ -38,6 +39,7 @@ class BackupViewModel( private val securityManager: SecurityManager, private val sessionLockManager: SessionLockManager, documents: BackupDocuments, + private val screenshots: ScreenshotAllowance, ) : ViewModel() { private val state = BackupFlowState(viewModelScope, backupManager.lastExportAt(), ::dropPending) @@ -51,6 +53,9 @@ class BackupViewModel( /** 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 @@ -70,6 +75,7 @@ class BackupViewModel( fun start(action: BackupAction) { dropPending() this.action = action + reauthGeneration = sessionLockManager.sessionGeneration val biometric = securityManager.canReauthWithBiometric() state.go(BackupStep.Reauth(action, biometric)) } @@ -82,6 +88,16 @@ class BackupViewModel( // 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 -> { @@ -123,6 +139,8 @@ class BackupViewModel( 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(Res.string.security_erase_failed)) 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 index 56cf797b..2932d6ed 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt @@ -8,10 +8,15 @@ 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. + * 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. */ @@ -26,12 +31,13 @@ internal class BiometricPromptGuard { * 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 + // 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 val observer = object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { + override fun onStop(owner: LifecycleOwner) { owner.lifecycle.removeObserver(this) if (current === token) current = null } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 3c39a497..93b3d1e3 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -16,6 +16,7 @@ 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 @@ -40,6 +41,7 @@ class SecuritySettingsViewModel( private val securityManager: SecurityManager, private val keyboardPrivacy: KeyboardPrivacy, private val autoLock: AutoLock, + private val screenshots: ScreenshotAllowance, ) : ViewModel() { private val _uiState = MutableStateFlow(SecuritySettingsUiState()) @@ -61,6 +63,14 @@ class SecuritySettingsViewModel( 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() } 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 index a2a734a9..fb670910 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt @@ -26,7 +26,8 @@ fun AppTheme(themeViewModel: ThemeViewModel = metroViewModel(), content: @Compos 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. This is intentionally not user-configurable. + // 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() } diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt index 85f928bf..92469ab2 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestScreen.kt @@ -8,22 +8,32 @@ 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 - * resumed lifecycle and a host of its own, so calls made for one screen are told from another's. [destroy] - * ends it, as a rotation does. + * 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 { +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 = Lifecycle.State.RESUMED + 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 } 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 index 96b1c303..8f36306c 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -1,5 +1,6 @@ package com.pasich.encly.presentation.viewmodel +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -241,6 +242,55 @@ class LockViewModelTest { 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 anAnswerFromAPromptCutOffByAStopIsDroppedAndItsKeyWiped() { + 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.stop() + + 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 aLateAnswerFromTheDestroyedActivityIsDroppedAndItsKeyWiped() { val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index efe79d98..a1f95a7f 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -13,6 +13,7 @@ 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.InMemoryKeyValueStore @@ -51,6 +52,7 @@ class SecuritySettingsViewModelTest { private lateinit var security: SecurityManager private lateinit var viewModel: SecuritySettingsViewModel private val screen = TestScreen() + private val screenshots = ScreenshotAllowance() @Before fun setUp() { @@ -65,11 +67,24 @@ class SecuritySettingsViewModelTest { security, 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() From 8819a2eaff1d055a7dd88ceea6a312facca5020b Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:15:43 +0200 Subject: [PATCH 37/75] ci: check that the fdroid build is reproducible, build only the app on release F-Droid publishes an update only when its own build of the tag matches the APK on the GitHub release. A new job builds the fdroid release twice in different directories without the build cache and compares the APKs. The release workflow builds the app module only, not every module of the multiplatform project. --- .github/workflows/android.yml | 62 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 01b1f873..6fa6292c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -516,6 +516,68 @@ jobs: # :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@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + with: + build-root-directory: first + + # No build cache and no shared daemon: the second build must compute everything again. + - name: Build twice + run: | + set -euo pipefail + for dir in first elsewhere/second; do + (cd "$dir" && chmod +x ./gradlew && ./gradlew --no-daemon --no-build-cache \ + --no-configuration-cache :app:assembleFdroidRelease) + done + + - 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) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7c7c479..fa9d4e79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: 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: From 688e7c6e1f23d11acac358326f5504ad9f09fe14 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:15:43 +0200 Subject: [PATCH 38/75] chore(release): 2.4.0 --- CHANGELOG.md | 27 +++++++++++++++++++ .../android/de-DE/changelogs/20400.txt | 9 +++++++ .../android/en-US/changelogs/20400.txt | 9 +++++++ .../android/es-ES/changelogs/20400.txt | 9 +++++++ .../android/fr-FR/changelogs/20400.txt | 9 +++++++ .../android/it-IT/changelogs/20400.txt | 9 +++++++ .../android/nl-NL/changelogs/20400.txt | 9 +++++++ .../android/pl-PL/changelogs/20400.txt | 9 +++++++ .../android/pt-PT/changelogs/20400.txt | 9 +++++++ .../metadata/android/uk/changelogs/20400.txt | 9 +++++++ version.properties | 4 +-- 11 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/20400.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/20400.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/20400.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/20400.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/20400.txt create mode 100644 fastlane/metadata/android/nl-NL/changelogs/20400.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/20400.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/20400.txt create mode 100644 fastlane/metadata/android/uk/changelogs/20400.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 835382a9..f5f65c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,33 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +## [2.4.0] - 2026-11-07 + +versionCode 20400. + +### Added + +- 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)). +- 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 + +- 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)). +- 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/fastlane/metadata/android/de-DE/changelogs/20400.txt b/fastlane/metadata/android/de-DE/changelogs/20400.txt new file mode 100644 index 00000000..e248520b --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Fingerabdruck-Entsperrung klappt wieder nach Sperre im Hintergrund. +• Neue Notizen behalten ihr Erstelldatum. +• Neu: Notiztext in Listen ausblenden (Einstellungen → Sicherheit). +• Neu: Screenshots erlauben, bis die App sperrt. +• Automatische Sperre nach 5 oder 10 Minuten. +• Der Papierkorb leert sich nach 28 Tagen. +• Suche scrollt zu jedem Treffer; Löschen läuft durch Listen; Notizen öffnen schneller. +• Neuer Kern; dein Tresor bleibt unverändert. diff --git a/fastlane/metadata/android/en-US/changelogs/20400.txt b/fastlane/metadata/android/en-US/changelogs/20400.txt new file mode 100644 index 00000000..115f8556 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Fingerprint unlock works again after Encly locks in the background. +• New notes keep their creation date. +• New: hide note text in lists (Settings → Security). +• New: allow screenshots until the app locks. +• Auto-lock after 5 or 10 minutes. +• The trash empties itself after 28 days. +• Find in note scrolls to each match; holding delete runs through lists; notes open faster. +• Rebuilt on a new core; your vault stays as it is. diff --git a/fastlane/metadata/android/es-ES/changelogs/20400.txt b/fastlane/metadata/android/es-ES/changelogs/20400.txt new file mode 100644 index 00000000..f8f28e54 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• El desbloqueo con huella vuelve a funcionar tras el bloqueo en segundo plano. +• Las notas nuevas conservan su fecha de creación. +• Nuevo: ocultar el texto de las notas en las listas (Ajustes → Seguridad). +• Nuevo: permitir capturas hasta que la app se bloquee. +• Bloqueo tras 5 o 10 minutos. +• La papelera se vacía sola a los 28 días. +• Buscar desplaza a cada resultado; las notas abren más rápido. +• Nuevo núcleo; tu bóveda sigue igual. diff --git a/fastlane/metadata/android/fr-FR/changelogs/20400.txt b/fastlane/metadata/android/fr-FR/changelogs/20400.txt new file mode 100644 index 00000000..f5b8fdda --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Le déverrouillage par empreinte refonctionne après un verrouillage en arrière-plan. +• Les nouvelles notes gardent leur date de création. +• Nouveau : masquer le texte des notes dans les listes (Paramètres → Sécurité). +• Nouveau : autoriser les captures jusqu'au verrouillage. +• Verrouillage après 5 ou 10 minutes. +• La corbeille se vide après 28 jours. +• La recherche défile vers chaque résultat ; notes plus rapides à ouvrir. +• Nouveau moteur ; votre coffre reste intact. diff --git a/fastlane/metadata/android/it-IT/changelogs/20400.txt b/fastlane/metadata/android/it-IT/changelogs/20400.txt new file mode 100644 index 00000000..46603fe6 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Lo sblocco con impronta funziona di nuovo dopo il blocco in background. +• Le nuove note mantengono la data di creazione. +• Novità: nascondi il testo delle note negli elenchi (Impostazioni → Sicurezza). +• Novità: consenti gli screenshot fino al blocco. +• Blocco dopo 5 o 10 minuti. +• Il cestino si svuota da solo dopo 28 giorni. +• La ricerca scorre a ogni risultato; le note si aprono più in fretta. +• Nuovo motore; la tua cassaforte resta com'è. diff --git a/fastlane/metadata/android/nl-NL/changelogs/20400.txt b/fastlane/metadata/android/nl-NL/changelogs/20400.txt new file mode 100644 index 00000000..2cb6284b --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Ontgrendelen met vingerafdruk werkt weer na vergrendeling op de achtergrond. +• Nieuwe notities houden hun aanmaakdatum. +• Nieuw: notitietekst in lijsten verbergen (Instellingen → Beveiliging). +• Nieuw: screenshots toestaan tot de app vergrendelt. +• Vergrendelen na 5 of 10 minuten. +• De prullenbak leegt zichzelf na 28 dagen. +• Zoeken scrolt naar elke treffer; notities openen sneller. +• Nieuwe basis; je kluis blijft zoals hij is. diff --git a/fastlane/metadata/android/pl-PL/changelogs/20400.txt b/fastlane/metadata/android/pl-PL/changelogs/20400.txt new file mode 100644 index 00000000..03c7b062 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Odblokowanie odciskiem znów działa po blokadzie w tle. +• Nowe notatki zachowują datę utworzenia. +• Nowość: ukryj tekst notatek na listach (Ustawienia → Bezpieczeństwo). +• Nowość: zezwól na zrzuty ekranu do blokady. +• Blokada po 5 lub 10 minutach. +• Kosz opróżnia się sam po 28 dniach. +• Wyszukiwanie przewija do każdego wyniku; notatki otwierają się szybciej. +• Nowy rdzeń; twój sejf pozostaje bez zmian. diff --git a/fastlane/metadata/android/pt-PT/changelogs/20400.txt b/fastlane/metadata/android/pt-PT/changelogs/20400.txt new file mode 100644 index 00000000..b5ebf105 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• O desbloqueio por impressão digital volta a funcionar após bloqueio em segundo plano. +• As notas novas mantêm a data de criação. +• Novo: ocultar o texto das notas nas listas (Configurações → Segurança). +• Novo: permitir capturas de ecrã até a app bloquear. +• Bloqueio após 5 ou 10 minutos. +• O lixo esvazia-se após 28 dias. +• A pesquisa desloca até cada resultado; as notas abrem mais depressa. +• Novo núcleo; o seu cofre fica igual. diff --git a/fastlane/metadata/android/uk/changelogs/20400.txt b/fastlane/metadata/android/uk/changelogs/20400.txt new file mode 100644 index 00000000..93dc79a3 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• Розблокування відбитком знову працює після блокування у фоні. +• Нові нотатки зберігають дату створення. +• Нове: приховати текст нотаток у списках (Налаштування → Безпека). +• Нове: дозволити скриншоти до блокування. +• Автоблокування через 5 або 10 хвилин. +• Кошик очищається сам через 28 днів. +• Пошук у нотатці прокручує до збігу; delete проходить списки; нотатки відкриваються швидше. +• Нове ядро; ваше сховище не змінюється. diff --git a/version.properties b/version.properties index 29be6532..a70f476c 100644 --- a/version.properties +++ b/version.properties @@ -7,5 +7,5 @@ # 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_MINOR=4 +VERSION_PATCH=0 From 955b5e9c3c30e522c2d59297950cb119e456d957 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:19:46 +0200 Subject: [PATCH 39/75] test: give the screenshot allowance screen test the note previews view model --- .../pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d95d1a09..b3ba486d 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt @@ -47,7 +47,7 @@ class ScreenshotAllowanceScreensTest : ComposeScreenTest() { private fun showSecurity(offered: Boolean = true) { app.withAuth(recoveryPhrase = true, biometric = BiometricStatus.UNAVAILABLE) setNavScreen( - viewModels(app.securitySettings(), app.backup()), + viewModels(app.securitySettings(), app.notePreviewsVm(), app.backup()), route = NavRoutes.SecuritySettingsRoute.name, ) { nav -> CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots.takeIf { offered }) { From c70acbaf6b98869a1084604117cfc2e86dfd3bdb Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:43:00 +0200 Subject: [PATCH 40/75] fix: the duress vault never sees the real vault's tag, a lock saves the open note first The tag filter of the note list was kept for the whole process, so after a lock and the duress PIN the decoy list was filtered by, and named, a tag of the real vault. It is now cleared on every lock and ignored when the open vault has no such tag. A lock waits up to one second for the open editor to save before it closes the vault, so the last seconds of typing are no longer lost. The trash, search and tag screens drop their contents on a lock like the other screens, and the 28-day purge deletes in one statement that re-checks that a note is still in the trash. Refs: #97 --- .../encly/data/database/NotesDaoTest.kt | 24 +++ .../encly/data/database/dao/NotesDao.kt | 7 + .../encly/core/security/SessionLockManager.kt | 59 ++++++ .../core/security/SessionLockFlushTest.kt | 117 +++++++++++ .../com/pasich/encly/ui/screens/TestApp.kt | 2 +- .../data/repository/NotesRepositoryImpl.kt | 5 + .../domain/repository/NotesRepository.kt | 6 + .../usecase/note/PurgeExpiredTrashUseCase.kt | 25 +-- .../viewmodel/EditNoteViewModel.kt | 12 ++ .../viewmodel/NoteSearchViewModel.kt | 39 +++- .../viewmodel/SelectedTagHolder.kt | 21 +- .../viewmodel/TagListViewModel.kt | 22 ++- .../presentation/viewmodel/TrashViewModel.kt | 12 +- .../viewmodel/LockClearsViewModelsTest.kt | 185 ++++++++++++++++++ .../encly/testutil/TestNotesRepository.kt | 11 ++ .../domain/usecase/note/TrashExpiryTest.kt | 52 +++-- .../viewmodel/EditNoteViewModelComfortTest.kt | 22 +++ .../com/pasich/encly/whatsnew/WhatsNewTest.kt | 2 +- 18 files changed, 581 insertions(+), 42 deletions(-) create mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockFlushTest.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt 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 index dd64cd4b..3232ff7a 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -215,6 +216,29 @@ class NotesDaoTest { 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 STAMP = 2_000L 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 index 468a73c0..cdc51b1b 100644 --- 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 @@ -78,6 +78,13 @@ public interface NotesDao { @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 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 index 53428591..39733cdf 100644 --- 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 @@ -7,9 +7,17 @@ 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 /** @@ -20,8 +28,18 @@ import kotlin.concurrent.Volatile 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() @@ -59,6 +77,14 @@ public class SessionLockManager( 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. */ + @Volatile + private var flushing = false + /** Emits true when the app was re-locked in the background and must show the lock screen. */ override val locked: StateFlow = _locked.asStateFlow() @@ -95,6 +121,9 @@ public class SessionLockManager( @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 @@ -230,9 +259,39 @@ public class SessionLockManager( 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() { // 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) return + flushing = true + 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 = false + } + } + } + + private fun closeVault() { securityManager.lock() _locked.value = true } 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/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 361059fa..fbbb0f64 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -205,7 +205,7 @@ internal class TestApp { /** 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, CleanTrashNotesUseCase(notes)), + PurgeExpiredTrashUseCase(notes), ) /** A tracker that has already recorded the version, as after the install: no "What's new" is due. */ 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 index 37b4b51b..e83394e1 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -70,4 +70,9 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note 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) + } } 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 index a77be7c8..d7189cfc 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -40,4 +40,10 @@ interface NotesRepository { /** 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/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 index 20f9194e..158b3622 100644 --- 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 @@ -1,10 +1,8 @@ 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 -import kotlinx.coroutines.flow.first /** How long a note stays in the trash before it is deleted for good. */ const val TRASH_RETENTION_DAYS = 28 @@ -15,25 +13,18 @@ private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000 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, the same way "Delete - * forever" does ([CleanTrashNotesUseCase]). Run when the vault is open: on unlock, and while the app is used. + * 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, - private val cleanTrash: CleanTrashNotesUseCase, -) { +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. - val trashed = repository.stampUntimedTrash(now).mapCatching { repository.getTrashNotes().first() } - .getOrElse { return Result.failure(it) } - val expired = trashed.filter { it.isExpired(now) } - return cleanTrash.cleanSelectedNotes(expired).map { expired.size } + 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) } } - -private fun Note.isExpired(now: Long): Boolean { - val trashedAt = trashedAt ?: return false - return now - trashedAt > TRASH_RETENTION_MILLIS -} 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 index 2c06f69a..43caecb2 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -237,6 +237,8 @@ class EditNoteViewModel( initialValue = false, ) + private var unregisterLockFlush: () -> Unit = {} + init { when { copySource != -1L -> loadNote(copySource, isCopy = true) @@ -247,6 +249,15 @@ class EditNoteViewModel( addTag != 0L -> persistence.updateNote { it.copy(tagId = 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 @@ -292,6 +303,7 @@ class EditNoteViewModel( } 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. 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 index 41ab03c9..50fe6729 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModel.kt @@ -5,6 +5,8 @@ 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 @@ -15,15 +17,16 @@ 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.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.launchIn import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.onEach /** * Searches every note that is not in the trash and whose tag is not hidden, whatever tag chip @@ -33,8 +36,11 @@ import kotlinx.coroutines.flow.stateIn @ContributesIntoMap(AppScope::class, binding()) @ViewModelKey @Inject -class NoteSearchViewModel(observeNotesUseCase: ObserveNotesUseCase, @IoDispatcher dispatcher: CoroutineDispatcher) : - ViewModel() { +class NoteSearchViewModel( + observeNotesUseCase: ObserveNotesUseCase, + @IoDispatcher dispatcher: CoroutineDispatcher, + lockEvents: VaultLockEvents = NeverLocked, +) : ViewModel() { private val _query = MutableStateFlow("") val query: StateFlow = _query.asStateFlow() @@ -45,9 +51,25 @@ class NoteSearchViewModel(observeNotesUseCase: ObserveNotesUseCase, @IoDispatche .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) - val results: StateFlow = - combine(_query.debounce { if (it.isBlank()) 0L else SEARCH_DEBOUNCE_MS }, notes) { query, all -> + 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, @@ -55,7 +77,9 @@ class NoteSearchViewModel(observeNotesUseCase: ObserveNotesUseCase, @IoDispatche ) } .flowOn(dispatcher) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), SearchResults()) + .onEach { _results.value = it } + .launchIn(viewModelScope) + } fun onQueryChange(query: String) { _query.value = query @@ -70,6 +94,5 @@ class NoteSearchViewModel(observeNotesUseCase: ObserveNotesUseCase, @IoDispatche private companion object { const val SEARCH_DEBOUNCE_MS = 200L - const val STOP_TIMEOUT_MS = 5_000L } } 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 index ee12464b..d75975b0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt @@ -1,20 +1,37 @@ 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.Inject import dev.zacsweers.metro.SingleIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch -/** The tag chip the note list is filtered by, shared by the tag row and the note list. */ +/** + * The tag chip the note list is filtered by, shared by the tag row and the note list. 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. + */ @SingleIn(AppScope::class) @Inject -class SelectedTagHolder { +class SelectedTagHolder(lockEvents: VaultLockEvents = NeverLocked) { private val _selectedTagFlow = MutableSharedFlow(replay = 1) val selectedTagFlow: SharedFlow = _selectedTagFlow.asSharedFlow() + init { + // Unconfined: the reset runs inside the lock itself, before any later unlock can publish. + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined).launch { + lockEvents.locked.collect { locked -> if (locked) _selectedTagFlow.tryEmit(Tag()) } + } + } + fun selectTag(tag: Tag) { _selectedTagFlow.tryEmit(tag) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt index c935fb3c..15a658d0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt @@ -5,6 +5,8 @@ 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 @@ -68,6 +70,7 @@ class TagListViewModel( private val reorderTagsUseCase: ReorderTagsUseCase, private val selectedTagHolder: SelectedTagHolder, notesRepository: NotesRepository, + lockEvents: VaultLockEvents = NeverLocked, ) : ViewModel() { private val _state = MutableStateFlow(TagListState()) @@ -90,11 +93,20 @@ class TagListViewModel( 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() } } } @@ -194,11 +206,19 @@ class TagListViewModel( } } + /** 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt index 23fde1fa..fae2c968 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TrashViewModel.kt @@ -5,6 +5,8 @@ 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 @@ -14,6 +16,7 @@ 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 @@ -26,17 +29,24 @@ 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/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..24826b95 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt @@ -0,0 +1,185 @@ +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.NoteWithTag +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(NoteWithTag(Note(id = 1, title = "Shopping"), null)) + 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( + NoteWithTag(Note(id = 1, title = "salary", value = """[{"blockType":"TEXT","text":"salary 100"}]"""), null), + ) + 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/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt index 97255c17..b878ea2c 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -107,6 +107,17 @@ internal class TestNotesRepository : NotesRepository { 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/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/TrashExpiryTest.kt index 9ad5821c..9a9ecbab 100644 --- 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 @@ -3,11 +3,11 @@ 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.flow.flow 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 @@ -15,7 +15,7 @@ import org.mockito.Mockito.`when` class TrashExpiryTest { private val repository = TestNotesRepository() - private val purge = PurgeExpiredTrashUseCase(repository, CleanTrashNotesUseCase(repository)) + private val purge = PurgeExpiredTrashUseCase(repository) private fun trashed(id: Long, trashedAt: Long?) = Note(id = id, isTrash = true, trashedAt = trashedAt) @@ -71,22 +71,52 @@ class TrashExpiryTest { } @Test - fun aTrashThatCannotBeReadIsAFailureAndDeletesNothing() = runTest { - val notes = mock(NotesRepository::class.java) - `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.success(Unit)) - `when`(notes.getTrashNotes()).thenReturn(flow { throw IllegalStateException("vault locked") }) + 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) - assertTrue(PurgeExpiredTrashUseCase(notes, CleanTrashNotesUseCase(notes))(NOW).isFailure) - verify(notes, never()).deleteNoteById(1) + assertEquals(listOf(NOW - TRASH_RETENTION_MILLIS), repository.purgeCutoffs) } @Test - fun aFailedStampStopsBeforeAnythingIsRead() = runTest { + fun aFailedStampStopsBeforeAnythingIsDeleted() = runTest { val notes = mock(NotesRepository::class.java) `when`(notes.stampUntimedTrash(NOW)).thenReturn(Result.failure(IllegalStateException("disk"))) - assertTrue(PurgeExpiredTrashUseCase(notes, CleanTrashNotesUseCase(notes))(NOW).isFailure) - verify(notes, never()).getTrashNotes() + 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 { diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt index f89c4e1d..2d872d82 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt @@ -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, @@ -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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt index 2a89797f..ccd45fdd 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt @@ -171,7 +171,7 @@ class WhatsNewTest { private fun startup(version: String, repository: TestNotesRepository = TestNotesRepository()) = StartupViewModel( tracker(version), - PurgeExpiredTrashUseCase(repository, CleanTrashNotesUseCase(repository)), + PurgeExpiredTrashUseCase(repository), ) @Test From d6a26664e53be0e10dd03b1896bd091ce8695999 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:43:54 +0200 Subject: [PATCH 41/75] build: keep native libraries as shipped, stricter reproducibility check The APK no longer strips the already stripped native libraries, which made its bytes depend on whether the build machine has an NDK and so on F-Droid's server matching CI. The reproducibility job builds with the release workflow's command and a second Gradle home. The release checkout drops its git credentials, and the licences screen lists Compose Multiplatform and the Lucide icon library. --- .github/workflows/android.yml | 14 +++++++++----- .github/workflows/release.yml | 3 +++ app/build.gradle.kts | 3 +++ .../encly/presentation/screen/LicensesScreen.kt | 2 ++ 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6fa6292c..f36ab0c9 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -547,14 +547,18 @@ jobs: with: build-root-directory: first - # No build cache and no shared daemon: the second build must compute everything again. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + # 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 - for dir in first elsewhere/second; do - (cd "$dir" && chmod +x ./gradlew && ./gradlew --no-daemon --no-build-cache \ - --no-configuration-cache :app:assembleFdroidRelease) - done + (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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa9d4e79..f2623a89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,9 @@ 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aceb1ba2..78f5b282 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,9 @@ android { 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( 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 index 0ee6b624..82105f2e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -50,6 +50,7 @@ private val bundledFonts = listOf( 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"), @@ -63,6 +64,7 @@ private val bundledLibraries = listOf( 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. */ From de59c8de6e4a9c45052fb61accb1a194a87a00c8 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 11:49:03 +0200 Subject: [PATCH 42/75] fix(editor): forget undo history on a lock, keep emoji whole, safer link cards Clearing the editor on a lock left the caret history of the session, with the text of every step, reachable; it is cleared with the undo and redo stacks and pruned as steps fall out of them. Undo and redo no longer put the caret between the two halves of an emoji. A link whose host has characters that browsers resolve differently from the card is shown as blocked. Typing in a long list no longer rescans the whole list for every row, and pasting a heading with a long run of spaces is fast. --- .../encly/editor/codec/MarkdownBlockMapper.kt | 26 +++++- .../encly/editor/state/BlockOperations.kt | 25 +++++- .../encly/editor/state/BlockSelection.kt | 4 +- .../pasich/encly/editor/state/CaretTarget.kt | 15 +++- .../encly/editor/ui/blocks/FieldHighlight.kt | 6 +- .../encly/editor/ui/blocks/ListBlock.kt | 88 +++++++++++++++---- .../pasich/encly/editor/ui/blocks/NoteLink.kt | 14 ++- .../pasich/encly/editor/ui/blocks/TextSync.kt | 3 +- .../editor/codec/MarkdownBlockMapperTest.kt | 17 ++++ .../encly/editor/state/BlockOperationsTest.kt | 62 +++++++++++++ .../encly/editor/state/ChecklistLockTest.kt | 18 ++++ .../encly/editor/state/UndoCaretTest.kt | 24 +++++ .../encly/editor/ui/blocks/AsciiHostTest.kt | 25 ++++-- .../encly/editor/ui/blocks/ItemValuesTest.kt | 44 ++++++++++ .../encly/editor/ui/blocks/SpanningTest.kt | 21 +++++ .../encly/editor/ui/blocks/TextSyncTest.kt | 9 ++ .../screen/editnote/EditorShortcutCommands.kt | 13 ++- .../editnote/EditorShortcutCommandsTest.kt | 15 ++++ 18 files changed, 388 insertions(+), 41 deletions(-) create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ItemValuesTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/SpanningTest.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommandsTest.kt 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 index 869405c2..41564d13 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -19,7 +19,9 @@ import com.pasich.encly.editor.model.ItemListBlock * go and their text stays, while other angle-bracket text (`Map`, ``) is kept. */ public object MarkdownBlockMapper : MarkdownBlockParser { - private val HEADING = Regex("""^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$""") + // 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]*""") @@ -59,6 +61,23 @@ public object MarkdownBlockMapper : MarkdownBlockParser { override fun parsePaste(text: String, plainFirstLine: Boolean, claim: (String) -> Block?): List = pasteBlocks(text, plainFirstLine, claim) + /** 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. */ @@ -128,7 +147,7 @@ public object MarkdownBlockMapper : MarkdownBlockParser { } private fun block(raw: String) { - val heading = HEADING.matchEntire(raw) + val heading = Heading.of(raw) val check = CHECK.matchEntire(raw) val glyph = GLYPH_ITEM.matchEntire(raw) val bullet = BULLET.matchEntire(raw) @@ -148,8 +167,7 @@ public object MarkdownBlockMapper : MarkdownBlockParser { heading != null -> { flushAll() - val level = heading.groupValues[1].length - blocks += ImportBlocks.heading(readable(heading.groupValues[2]), level) + blocks += ImportBlocks.heading(readable(heading.title), heading.level) } check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ") diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt index 441f1a4a..4204fbe5 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockOperations.kt @@ -97,12 +97,13 @@ internal 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 } @@ -116,7 +117,7 @@ internal class BlockOperations( val operations = batch.operations if (operations.isEmpty()) return pushUndo(operations.singleOrNull() ?: CompositeOperation(operations)) - redoStack.clear() + dropAll(redoStack) breakMerge = false } @@ -181,6 +182,9 @@ internal 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 { last -> @@ -224,6 +228,8 @@ internal class BlockOperations( fun clear() { undoStack.clear() redoStack.clear() + caretSteps.clear() + lastMoved = null breakMerge = false } @@ -240,7 +246,7 @@ internal class BlockOperations( private fun record(operation: Operation) { execute(operation) pushUndo(operation) - redoStack.clear() + dropAll(redoStack) breakMerge = false } @@ -250,7 +256,18 @@ internal 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 index 5db86958..558e692e 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt @@ -126,7 +126,9 @@ public class BlockSelection(private val blocks: List) { internal fun focusAt(index: Int, cursorToEnd: Boolean = false, caret: Int? = null, itemId: String? = null) { val block = blocks.getOrNull(index) ?: return val blockId = block.id - onFocused(blockId) + // 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 } diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt index 5b20f569..6fc6b247 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt @@ -34,9 +34,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 } 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 index 0ea85db4..0bc3ab48 100644 --- 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 @@ -56,7 +56,7 @@ internal class FieldHighlight( // 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(Rect(from.left, from.top, to.right, to.bottom)) + bringIntoView.bringIntoView(spanning(from, to)) return true } @@ -96,3 +96,7 @@ internal fun rememberFieldHighlight( } return field } + +/** 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/ListBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListBlock.kt index dcf312ed..16c46154 100644 --- 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 @@ -65,6 +65,10 @@ import com.pasich.encly.editor.theme.markerColumn 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. */ @@ -147,6 +151,34 @@ private fun rememberListFocus(block: Block.ListBlock, blockActions: BlockActions 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 @@ -165,9 +197,11 @@ internal fun ListBlock( val actions by rememberUpdatedState(blockActions) val focus = rememberListFocus(block, blockActions) val editor = remember(block) { ListEditor(block, { actions }) { 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) } Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy( @@ -177,8 +211,8 @@ internal fun ListBlock( items.forEachIndexed { itemIndex, item -> key(item.id) { val state = rememberSyncedTextFieldState( - model = { block.items.value.firstOrNull { it.id == item.id }?.value }, - modelChanges = block.items, + model = { values.valueOf(item.id) }, + modelChanges = remember(values) { values.changes(item.id) }, onEdit = { text -> editor.setText(item.id, text) }, ) val requester = remember { FocusRequester() } @@ -188,34 +222,52 @@ internal fun ListBlock( 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) + RegisterItem(focus, item.id, requester, state, 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) { ItemField(state, highlight, marker.metrics) } + 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) + } } } ListItemRow( item = item, marker = marker.forItem(itemIndex + 1), - field = ItemField(state, highlight, marker.metrics), + field = field, 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) - } - }, + locks = locks, + 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 { diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt index fa146e39..8e320507 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/NoteLink.kt @@ -72,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 toAsciiHost(lower) + 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 { diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt index d514cae3..182082ad 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.text.TextRange 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 @@ -122,5 +123,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/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt index 0fc898a1..b9ee846d 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt @@ -43,6 +43,23 @@ class MarkdownBlockMapperTest { ) } + @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")) 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 index 54f8380f..712e3482 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockOperationsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockOperationsTest.kt @@ -124,4 +124,66 @@ class BlockOperationsTest { 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/ChecklistLockTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt index 189d0f20..3fd6fd87 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ChecklistLockTest.kt @@ -35,6 +35,24 @@ class ChecklistLockTest { 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() diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt index f621c8e1..283cf8f7 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/UndoCaretTest.kt @@ -203,4 +203,28 @@ class UndoCaretTest { 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/ui/blocks/AsciiHostTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/AsciiHostTest.kt index 0ae4b170..84cc2a5a 100644 --- 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 @@ -33,18 +33,13 @@ class AsciiHostTest { "å.se" to "xn--5ca.se", "á.com" to "xn--1ca.com", // Case folding beyond lower-casing. - "straße.de" to "strasse.de", - "fußball.de" to "fussball.de", - "ς.gr" to "xn--4xa.gr", "σασ.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", - "a‍b.com" to "ab.com", - "a‌b.com" to "ab.com", - "ab.com" to "ab.com", + "ab.com" to "ab.com", "ö​ö.de" to "xn--ndaa.de", // The other IDNA dots separate labels. "bücher。de" to "xn--bcher-kva.de", @@ -74,6 +69,24 @@ class AsciiHostTest { 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/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/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/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt index b3c0e88f..40a2a6cb 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/TextSyncTest.kt @@ -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/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 index 20ba54b2..9a9ed332 100644 --- 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 @@ -11,7 +11,8 @@ 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), Esc closes the find bar, and Print and Export as PDF start the document export. + * 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( @@ -23,7 +24,11 @@ internal fun EditorShortcutCommands( EditorCommands( find = { true.also { viewModel.openFind() } }, moveToTrash = { - val allowed = !viewModel.isReadTrashOnly && !viewModel.isBlankDraft + val allowed = canMoveToTrashByShortcut( + viewModel.isReadTrashOnly, + viewModel.isBlankDraft, + viewModel.editedElsewhere.value, + ) allowed.also { if (it) overlays.isTrashDialogVisible = true } }, dismiss = { viewModel.find.value.isOpen.also { open -> if (open) viewModel.closeFind() } }, @@ -33,3 +38,7 @@ internal fun EditorShortcutCommands( } EditorCommandsBinding(commands) } + +/** 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/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)) + } +} From e5bca0f3ab479066c2c51a011d6702064368854b Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 12:00:03 +0200 Subject: [PATCH 43/75] fix(security): a forgotten flags file never leads to a new vault over the real one Whether a vault was set up depended only on a flag in the plain flags store. If that file was lost or unreadable, Encly showed onboarding, and finishing it deleted the vault with its keys. A stored seed with a PIN or recovery slot and the encrypted database now count as a set-up vault whatever the flag says, the flag is repaired, and creating a new vault is refused. The desktop and iOS flag stores no longer read an unreadable file as empty or overwrite it. A vault file that cannot be read for a moment shows a Try again screen instead of the wipe screen, and the biometric enrolment wipes its copy of the data key on every exit. --- .../core/security/KeystoreBiometricManager.kt | 57 ++++--- .../pasich/encly/core/security/PendingDek.kt | 38 +++++ .../com/pasich/encly/core/di/AppGraphTest.kt | 6 +- .../encly/core/security/PendingDekTest.kt | 94 +++++++++++ .../strings/values-de/strings_security.xml | 4 + .../strings/values-es/strings_security.xml | 4 + .../strings/values-fr/strings_security.xml | 4 + .../strings/values-it/strings_security.xml | 4 + .../strings/values-nl/strings_security.xml | 4 + .../strings/values-pl/strings_security.xml | 4 + .../strings/values-pt/strings_security.xml | 4 + .../strings/values-uk/strings_security.xml | 4 + .../strings/values/strings_security.xml | 4 + .../encly/core/security/SecurityManager.kt | 37 +++- .../encly/core/security/SeedPhraseManager.kt | 3 + .../pasich/encly/core/security/VaultStore.kt | 36 +++- .../security/SecurityManagerLifecycleTest.kt | 158 +++++++++++++++++- .../encly/core/security/VaultStoreTest.kt | 62 +++++++ .../desktop/storage/FileKeyValueStore.kt | 48 +++--- .../com/pasich/encly/desktop/StorageTest.kt | 27 ++- .../encly/presentation/navigation/EnclyApp.kt | 5 +- .../encly/presentation/navigation/NavHost.kt | 27 +++ .../presentation/navigation/NavRoutes.kt | 1 + .../screen/StorageUnreadableScreen.kt | 76 +++++++++ .../viewmodel/StorageUnreadableViewModel.kt | 45 +++++ .../encly/ios/storage/IosKeyValueStore.kt | 47 +++--- .../com/pasich/encly/ios/IosActualsTest.kt | 18 ++ .../StorageUnreadableViewModelTest.kt | 71 ++++++++ 28 files changed, 808 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/core/security/PendingDek.kt create mode 100644 app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/StorageUnreadableScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModel.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/StorageUnreadableViewModelTest.kt 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 index 0294a997..5f32af57 100644 --- a/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt @@ -8,6 +8,7 @@ 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 @@ -65,18 +66,20 @@ class KeystoreBiometricManager( } override fun enroll(host: BiometricHost, dek: ByteArray, onResult: (Boolean) -> Unit) { - val activity = host.activityOrNull() - if (activity == null) { - onResult(false) - return + // 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 (!isStrongBiometricAvailable() || dek.size != DEK_LENGTH) { + if (activity == null) { onResult(false) return } - val dekCopy = dek.copyOf() + val pending = PendingDek(dek) try { + pending.wipeWhenStopped(activity.lifecycle) store.edit { remove(SLOT_KEY) } deleteKeyOnly() val key = generateAuthBoundKey() @@ -88,38 +91,46 @@ class KeystoreBiometricManager( 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) + val stored = try { + storeEnrolment(authenticatedCipher, pending) } catch (_: Exception) { - disable() - onResult(false) + false } finally { - SensitiveDataCleaner.clear(dekCopy) + pending.wipe() } + if (!stored) disable() + onResult(stored) }, onFailure = { - SensitiveDataCleaner.clear(dekCopy) + pending.wipe() deleteKeyOnly() onResult(false) }, ) } catch (_: Exception) { - SensitiveDataCleaner.clear(dekCopy) + 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. 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..84fde8f1 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt @@ -0,0 +1,38 @@ +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 stops or is destroyed, because androidx.biometric drops the answer + * of a prompt that is cut off and then nothing else would ever clear it. 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_STOP (or ON_DESTROY, should it get there first). */ + fun wipeWhenStopped(lifecycle: Lifecycle) { + if (dek == null) return + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP || event == Lifecycle.Event.ON_DESTROY) wipe() + } + lifecycle.addObserver(observer) + detach = { lifecycle.removeObserver(observer) } + } +} 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 index c56d02b3..4fd8cc6e 100644 --- a/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -50,6 +50,7 @@ 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 @@ -166,10 +167,10 @@ class AppGraphTest { } @Test - fun theViewModelsAreTheTwentyFiveOfTheApp() { + fun theViewModelsAreTheTwentySixOfTheApp() { assertEquals(PLAIN_VIEW_MODELS, graph.viewModelProviders.keys) assertEquals(ASSISTED_VIEW_MODELS, graph.assistedFactoryProviders.keys) - assertEquals(25, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) + assertEquals(26, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) assertTrue(graph.manualAssistedFactoryProviders.isEmpty()) } @@ -258,6 +259,7 @@ class AppGraphTest { SettingsViewModel::class, StartupViewModel::class, StatisticViewModel::class, + StorageUnreadableViewModel::class, TagListViewModel::class, ThemeViewModel::class, TrashViewModel::class, 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..8a011590 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt @@ -0,0 +1,94 @@ +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 anActivityThatStopsBeforeTheAnswerWipesTheCopy() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create().start().resume() + val pending = PendingDek(key()) + var held: ByteArray? = null + pending.use { held = it } + pending.wipeWhenStopped(controller.get().lifecycle) + + controller.pause() + assertArrayEquals("a paused activity may still get the answer", key(), pending.use { it.copyOf() }) + controller.stop() + + assertNull(pending.use { it.copyOf() }) + assertArrayEquals(ByteArray(32), held) + } + + @Test + fun anActivityDestroyedBeforeTheAnswerWipesTheCopy() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).create() + val pending = PendingDek(key()) + pending.wipeWhenStopped(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.wipeWhenStopped(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/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml index a236860b..1e974b02 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml index 8003f352..b6b506dc 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml index 2b8227e0..fd49bfed 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml index 8bae03b2..3c0820bc 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml index cc3f271a..74830ea7 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml index eba5d574..520e940c 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml index 62b47b69..71cbab85 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -18,6 +18,10 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml index 8f3ad179..10600358 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -18,6 +18,10 @@ Створена під час налаштування. Encly не зберігає слова й не може показати їх знову. Немає їх? Замініть фразу нижче. Налаштовано Після скидання можна відновити резервну копію з файлу за її 12 словами. + Зараз не вдається відкрити сховище + Encly не змогло прочитати свої дані. Їх може використовувати інший застосунок чи система, або пристрій ще заблокований. Ваші нотатки не втрачено. Спробуйте ще раз за мить. + Спробувати ще раз + Досі не вдається прочитати. Спробуйте ще раз за мить. Змінити PIN %d нотатка diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml index 5d34817b..4e2c5e0c 100644 --- a/core/resources/src/commonMain/strings/values/strings_security.xml +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -18,6 +18,10 @@ 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 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 index afb978cc..b76c0ba6 100644 --- 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 @@ -28,6 +28,12 @@ public enum class InitialStatus { 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 { @@ -124,8 +130,15 @@ public class SecurityManager( } private fun initializeSecurity(): InitialStatus { + if (vaultStore.isUnreadable()) return InitialStatus.STORAGE_UNREADABLE if (vaultStore.isCorrupt()) return InitialStatus.LOSS_CRYPTO - if (!isOnboardingShown()) return uncommittedVaultStatus() + 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)) { @@ -163,9 +176,23 @@ public class SecurityManager( InitialStatus.ONBOARDING } + /** + * A vault the non-secret flags store forgot: a stored seed, a PIN or recovery slot and the + * encrypted database 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()) && + vaultDatabase.hasEncryptedDatabase() + /** True when first-run setup may (re)create the vault without destroying user data. */ - private fun isSafeToCreateVault(): Boolean = - !isOnboardingShown() && uncommittedVaultStatus() == InitialStatus.ONBOARDING + private fun isSafeToCreateVault(): Boolean = !vaultStore.isUnreadable() && + !isOnboardingShown() && + !isCommittedWithoutFlag() && + uncommittedVaultStatus() == InitialStatus.ONBOARDING public fun authStrategy(): AuthStrategy = authenticationManager.isAuthStrategy() @@ -589,8 +616,8 @@ public class SecurityManager( * 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() && (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) + public fun isLockable(): Boolean = (isOnboardingShown() || isCommittedWithoutFlag()) && + (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) public fun isDatabaseUnlocked(): Boolean = vaultDatabase.isDatabaseUnlocked() diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt index 561da6f4..841711f0 100644 --- a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/SeedPhraseManager.kt @@ -110,6 +110,9 @@ public class SeedPhraseManager(private val store: VaultStore) { return !recoveryEnabled || hasRecoverySeed() } + /** 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() } public fun clearBootstrapKey(): Unit = synchronized(lock) { 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 index 2693e13b..31f4b919 100644 --- 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 @@ -19,6 +19,9 @@ import kotlinx.io.IOException * - 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. @@ -34,6 +37,7 @@ public class VaultStore(private val io: VaultFileIo) { // 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) { @@ -41,6 +45,16 @@ public class VaultStore(private val io: VaultFileIo) { 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. */ @@ -62,11 +76,11 @@ public class VaultStore(private val io: VaultFileIo) { /** * 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. + * 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) return false + if (corrupt || unreadable) return false val next = LinkedHashMap(current) Editor(next).block() val written = try { @@ -90,6 +104,7 @@ public class VaultStore(private val io: VaultFileIo) { cache?.values?.forEach(SensitiveDataCleaner::clear) cache = emptyMap() corrupt = false + unreadable = false io.deleteTemp() io.delete() } @@ -126,17 +141,20 @@ public class VaultStore(private val io: VaultFileIo) { cache?.let { return it } // A temp file left by a write that never reached its rename is never the state. io.deleteTemp() - val loaded = if (!io.exists()) { - emptyMap() - } else { + var loaded: Map? = emptyMap() + unreadable = false + if (io.exists()) { try { - parse(io.read()) + loaded = parse(io.read()) } catch (_: IOException) { - null + // Not damage, and not remembered: the next access reads the file again. + unreadable = true } } - corrupt = loaded == null - return (loaded ?: emptyMap()).also { cache = it } + corrupt = !unreadable && loaded == null + val result = loaded ?: emptyMap() + if (!unreadable) cache = result + return result } private fun write(values: Map) { 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 index e36ded1b..ad2bb119 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -31,6 +32,8 @@ class SecurityManagerLifecycleTest { 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 @@ -46,7 +49,7 @@ class SecurityManagerLifecycleTest { store = VaultStore(file) prefs = InMemoryKeyValueStore() seed = SeedPhraseManager(store) - auth = AuthenticationManager(store, FakePinFactor(), FakeLockoutClock()) + auth = AuthenticationManager(store, pinFactor, lockoutClock) database = mock(VaultDatabase::class.java) biometric = mock(BiometricManager::class.java) { call -> when (call.method.name) { @@ -364,6 +367,159 @@ class SecurityManagerLifecycleTest { 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" 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 index 17917491..fcf2fcc5 100644 --- 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 @@ -169,5 +169,67 @@ class VaultStoreTest { 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/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt index 0feb209d..f54e92e4 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt @@ -22,8 +22,9 @@ import java.util.concurrent.Executors * * 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 cannot be read starts as empty - * (the keys are non-secret flags). + * 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 or parsed 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. */ class FileKeyValueStore( private val file: Path, @@ -33,35 +34,38 @@ class FileKeyValueStore( ) : KeyValueStore { private val stateLock = Any() private val writeLock = Any() - private val values: MutableMap = load().toMutableMap() - override fun getString(key: String, default: String?): String? = synchronized(stateLock) { values[key] } ?: default + // 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 = - synchronized(stateLock) { values[key] }?.toBooleanStrictOrNull() ?: default + read { it[key] }?.toBooleanStrictOrNull() ?: default - override fun getLong(key: String, default: Long): Long = - synchronized(stateLock) { values[key] }?.toLongOrNull() ?: default + override fun getLong(key: String, default: Long): Long = read { it[key] }?.toLongOrNull() ?: default - override fun contains(key: String): Boolean = synchronized(stateLock) { values.containsKey(key) } + override fun contains(key: String): Boolean = read { it.containsKey(key) } ?: false override fun edit(block: KeyValueStore.Editor.() -> Unit) { - apply(block) - writer.execute { persist() } + if (read { MapEditor(it).block() } != null) writer.execute { persist() } } - override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { - apply(block) - return persist() - } + override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean = + read { MapEditor(it).block() } != null && persist() - private fun apply(block: KeyValueStore.Editor.() -> Unit) { - synchronized(stateLock) { MapEditor(values).block() } + /** + * [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() } + val snapshot = synchronized(stateLock) { values?.toMap() } ?: return false try { write(snapshot) true @@ -98,17 +102,19 @@ class FileKeyValueStore( } } - private fun load(): Map { - if (!Files.isRegularFile(file)) return emptyMap() + /** 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? { + // 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 (_: IOException) { - emptyMap() + null } catch (_: IllegalArgumentException) { // A malformed \u escape: the file is damaged. - emptyMap() + null } } diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt index 1c0814b4..1b9565db 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt @@ -12,6 +12,7 @@ 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 @@ -190,8 +191,32 @@ class FileKeyValueStoreTest { } @Test - fun `an unreadable file starts empty`() { + fun `an unreadable file fails closed and is never overwritten`() { + val damaged = byteArrayOf(0x5c, 0x75, 0x7a) + Files.write(file(), damaged) + 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") } + assertContentEquals(damaged, Files.readAllBytes(file())) + assertNull(store.getString("k", null)) + } + + @Test + fun `a file that could not be read is read again once it can be`() { Files.write(file(), byteArrayOf(0x5c, 0x75, 0x7a)) + val store = FileKeyValueStore(file(), now) + assertFalse(store.getBoolean("flag", false)) + 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") }) 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 index 4ea12485..56f988ea 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -130,7 +130,7 @@ private fun NavHostController.startNewNote(): Boolean { } /** Where the app starts for [status]; null while there is nothing to show yet. */ -private fun startRoute(status: InitialStatus): NavRoutes? = when (status) { +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. @@ -142,6 +142,8 @@ private fun startRoute(status: InitialStatus): NavRoutes? = when (status) { InitialStatus.LEGACY_VAULT -> NavRoutes.LegacyVaultRoute + InitialStatus.STORAGE_UNREADABLE -> NavRoutes.StorageUnreadableRoute + InitialStatus.NO -> null } @@ -215,6 +217,7 @@ private val PUBLIC_ROUTES = setOf( NavRoutes.OnboardingRoute.name, NavRoutes.LossDataRoute.name, NavRoutes.LegacyVaultRoute.name, + NavRoutes.StorageUnreadableRoute.name, ) private fun isProtectedRoute(route: String?): Boolean = route != null && route !in PUBLIC_ROUTES 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 index 5aafef76..cfee1380 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -29,6 +29,7 @@ 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.StorageUnreadableScreen import com.pasich.encly.presentation.screen.SupportScreen import com.pasich.encly.presentation.screen.TasksScreen import com.pasich.encly.presentation.screen.archive.ArchiveScreen @@ -45,6 +46,7 @@ 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 @@ -182,6 +184,9 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR 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) } } @@ -291,6 +296,28 @@ private fun EditNoteDestination( 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( diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt index b018affd..6752cf63 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -15,6 +15,7 @@ enum class NavRoutes { OnboardingRoute, LossDataRoute, LegacyVaultRoute, + StorageUnreadableRoute, PinCodeConfig, WipePinRoute, DuressPinRoute, 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/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/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt index 781c2d70..62cf73ec 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt @@ -22,8 +22,9 @@ import kotlinx.serialization.json.Json * * 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 cannot be read starts as empty (the keys are non-secret - * flags). + * thread; [commit] writes before it returns. A file that is absent starts as empty. A file that exists but cannot + * be read or parsed 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. */ internal class IosKeyValueStore( private val io: VaultFileIo, @@ -33,38 +34,41 @@ internal class IosKeyValueStore( private val stateLock = SynchronizedObject() private val writeLock = SynchronizedObject() - private val values: MutableMap = load().toMutableMap() - override fun getString(key: String, default: String?): String? = synchronized(stateLock) { values[key] } ?: default + // 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 = - synchronized(stateLock) { values[key] }?.toBooleanStrictOrNull() ?: default + read { it[key] }?.toBooleanStrictOrNull() ?: default - override fun getLong(key: String, default: Long): Long = - synchronized(stateLock) { values[key] }?.toLongOrNull() ?: default + override fun getLong(key: String, default: Long): Long = read { it[key] }?.toLongOrNull() ?: default - override fun contains(key: String): Boolean = synchronized(stateLock) { values.containsKey(key) } + override fun contains(key: String): Boolean = read { it.containsKey(key) } ?: false override fun edit(block: KeyValueStore.Editor.() -> Unit) { - apply(block) - writer.launch { persist() } + if (read { MapEditor(it).block() } != null) writer.launch { persist() } } - override fun commit(block: KeyValueStore.Editor.() -> Unit): Boolean { - apply(block) - return 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() } - private fun apply(block: KeyValueStore.Editor.() -> Unit) { - synchronized(stateLock) { MapEditor(values).block() } - } - /** 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() } + val snapshot = synchronized(stateLock) { values?.toMap() } ?: return false try { io.replace(Json.encodeToString(SERIALIZER, snapshot).encodeToByteArray(), null) true @@ -73,15 +77,16 @@ internal class IosKeyValueStore( } } - private fun load(): Map { + /** 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) { - emptyMap() + null } catch (_: IllegalArgumentException) { // Damaged JSON. - emptyMap() + null } } diff --git a/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt index e9202b62..1c361e29 100644 --- a/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt @@ -1,5 +1,6 @@ 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 @@ -119,6 +120,23 @@ class IosActualsTest { assertFalse(IosKeyValueStore(file).contains("a")) } + @Test + fun aDamagedFlagsFileFailsClosedAndIsNeverOverwritten() { + val file = tempPath(".json").toString() + val damaged = "{ not json".encodeToByteArray() + IosVaultFileIo(file).replace(damaged, null) + val store = IosKeyValueStore(file) + + 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/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) + } + } +} From ad7bcbfd6e8a7ee67a35dcb632469aa1024e4684 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 13:16:57 +0200 Subject: [PATCH 44/75] build: database ABI dump for the trash purge, 2.4.0 in the Flatpak metainfo --- CONTRIBUTING.md | 2 ++ core/database/api/android/database.api | 16 ++++++++++++---- core/database/api/database.klib.api | 14 ++++++++++++-- core/database/api/desktop/database.api | 16 ++++++++++++---- .../io.github.pasichdev.Encly.metainfo.xml | 1 + 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aac1b83..259883d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -230,6 +230,8 @@ upload this key instead of letting Google generate one). `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`); `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/core/database/api/android/database.api b/core/database/api/android/database.api index a7f8b646..2d462dd8 100644 --- a/core/database/api/android/database.api +++ b/core/database/api/android/database.api @@ -109,6 +109,7 @@ public final class com/pasich/encly/data/database/VaultSchema { 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 install (Landroidx/room/RoomDatabase$Builder;)Landroidx/room/RoomDatabase$Builder; } @@ -152,6 +153,7 @@ public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { public abstract interface class com/pasich/encly/data/database/dao/NotesDao { public abstract fun deleteNoteById (JLkotlin/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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; @@ -161,6 +163,7 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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; @@ -173,6 +176,7 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich public static final field Companion Lcom/pasich/encly/data/database/dao/NotesDao_Impl$Companion; public fun (Landroidx/room/RoomDatabase;)V public fun deleteNoteById (JLkotlin/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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; @@ -182,6 +186,7 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich public fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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; @@ -301,8 +306,8 @@ 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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component10 ()Ljava/lang/String; public final fun component11 ()Z @@ -310,6 +315,7 @@ public final class com/pasich/encly/data/model/Note { public final fun component13 ()Ljava/lang/String; public final fun component14 ()Ljava/lang/Integer; public final fun component15 ()Ljava/lang/Integer; + public final fun component16 ()Ljava/lang/Long; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -318,8 +324,8 @@ public final class com/pasich/encly/data/model/Note { public final fun component7 ()Ljava/lang/Long; public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;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 @@ -330,6 +336,7 @@ public final class com/pasich/encly/data/model/Note { public final fun getTagId ()Ljava/lang/Long; 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 @@ -352,6 +359,7 @@ public final class com/pasich/encly/data/model/Note { 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; diff --git a/core/database/api/database.klib.api b/core/database/api/database.klib.api index ee8a8cae..2737a883 100644 --- a/core/database/api/database.klib.api +++ b/core/database/api/database.klib.api @@ -42,8 +42,10 @@ abstract interface com.pasich.encly.data.database.dao/NotesDao { // com.pasich.e abstract fun getTrashNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getTrashNotes|getTrashNotes(){}[0] abstract fun observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao.observeNoteWithTag|observeNoteWithTag(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 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 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 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] @@ -144,8 +146,10 @@ final class com.pasich.encly.data.database.dao/NotesDao_Impl : com.pasich.encly. final fun getTrashNotes(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getTrashNotes|getTrashNotes(){}[0] final fun observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao_Impl.observeNoteWithTag|observeNoteWithTag(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 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 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 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] @@ -257,7 +261,7 @@ final class com.pasich.encly.data.database/SecureDatabaseManager : com.pasich.en } 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/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ...) // com.pasich.encly.data.model/Note.|(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[0] + constructor (kotlin/Long = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ..., kotlin/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ...) // com.pasich.encly.data.model/Note.|(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?){}[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] @@ -298,6 +302,9 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No 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] @@ -312,6 +319,7 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No final fun component13(): kotlin/String // 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/Int? // com.pasich.encly.data.model/Note.component15|component15(){}[0] + final fun component16(): kotlin/Long? // 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] @@ -320,7 +328,7 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No final fun component7(): kotlin/Long? // 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/Boolean // 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/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ...): 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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[0] + final fun copy(kotlin/Long = ..., kotlin/String = ..., kotlin/String = ..., kotlin/String = ..., kotlin/Long = ..., kotlin/Long = ..., kotlin/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ...): 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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?){}[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] @@ -488,6 +496,8 @@ final object com.pasich.encly.data.database/VaultSchema { // com.pasich.encly.da 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 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] diff --git a/core/database/api/desktop/database.api b/core/database/api/desktop/database.api index 0eb25710..6d5c7208 100644 --- a/core/database/api/desktop/database.api +++ b/core/database/api/desktop/database.api @@ -108,6 +108,7 @@ public final class com/pasich/encly/data/database/VaultSchema { 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 install (Landroidx/room/RoomDatabase$Builder;)Landroidx/room/RoomDatabase$Builder; } @@ -151,6 +152,7 @@ public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { public abstract interface class com/pasich/encly/data/database/dao/NotesDao { public abstract fun deleteNoteById (JLkotlin/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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; @@ -160,6 +162,7 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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; @@ -172,6 +175,7 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich public static final field Companion Lcom/pasich/encly/data/database/dao/NotesDao_Impl$Companion; public fun (Landroidx/room/RoomDatabase;)V public fun deleteNoteById (JLkotlin/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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; @@ -181,6 +185,7 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich public fun getTrashNotes ()Lkotlinx/coroutines/flow/Flow; public fun insertNote (Lcom/pasich/encly/data/model/Note;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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; @@ -300,8 +305,8 @@ 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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)V + public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J public final fun component10 ()Ljava/lang/String; public final fun component11 ()Z @@ -309,6 +314,7 @@ public final class com/pasich/encly/data/model/Note { public final fun component13 ()Ljava/lang/String; public final fun component14 ()Ljava/lang/Integer; public final fun component15 ()Ljava/lang/Integer; + public final fun component16 ()Ljava/lang/Long; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -317,8 +323,8 @@ public final class com/pasich/encly/data/model/Note { public final fun component7 ()Ljava/lang/Long; public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;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 @@ -329,6 +335,7 @@ public final class com/pasich/encly/data/model/Note { public final fun getTagId ()Ljava/lang/Long; 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 @@ -351,6 +358,7 @@ public final class com/pasich/encly/data/model/Note { 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; diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml index 1bee1f5b..6c242562 100644 --- a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml @@ -41,6 +41,7 @@ keyboard + From 73474f6eb8d5aee7c5465a14a7a270c97b25880f Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 13:16:57 +0200 Subject: [PATCH 45/75] fix(security): keep a biometric answer that arrives after the activity stopped Some devices may show the biometric prompt over a stopped activity and answer after the stop. Dropping that answer would leave fingerprint unlock and enrolment failing there. An answer after a stop now counts unless a newer prompt started since, and the enrolment's copy of the data key is wiped when the activity is destroyed rather than stopped. The fingerprint button is still released on a stop. Refs: #104 --- .../core/security/KeystoreBiometricManager.kt | 2 +- .../pasich/encly/core/security/PendingDek.kt | 11 ++++---- .../encly/core/security/PendingDekTest.kt | 18 ++++++------- .../viewmodel/BiometricPromptGuard.kt | 25 ++++++++++++++++--- .../viewmodel/LockViewModelTest.kt | 25 +++++++++++++++++-- 5 files changed, 58 insertions(+), 23 deletions(-) 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 index 5f32af57..0ada2846 100644 --- a/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt +++ b/app/src/main/java/com/pasich/encly/core/security/KeystoreBiometricManager.kt @@ -79,7 +79,7 @@ class KeystoreBiometricManager( val pending = PendingDek(dek) try { - pending.wipeWhenStopped(activity.lifecycle) + pending.wipeWhenDestroyed(activity.lifecycle) store.edit { remove(SLOT_KEY) } deleteKeyOnly() val key = generateAuthBoundKey() 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 index 84fde8f1..d084b64e 100644 --- a/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt +++ b/app/src/main/java/com/pasich/encly/core/security/PendingDek.kt @@ -5,8 +5,9 @@ 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 stops or is destroyed, because androidx.biometric drops the answer - * of a prompt that is cut off and then nothing else would ever clear it. A late answer finds it wiped + * 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. @@ -26,11 +27,11 @@ internal class PendingDek(source: ByteArray) { detach = null } - /** Wipes the copy when [lifecycle] reaches ON_STOP (or ON_DESTROY, should it get there first). */ - fun wipeWhenStopped(lifecycle: Lifecycle) { + /** 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_STOP || event == Lifecycle.Event.ON_DESTROY) wipe() + if (event == Lifecycle.Event.ON_DESTROY) wipe() } lifecycle.addObserver(observer) detach = { lifecycle.removeObserver(observer) } 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 index 8a011590..2b284ae9 100644 --- a/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt +++ b/app/src/test/java/com/pasich/encly/core/security/PendingDekTest.kt @@ -48,26 +48,22 @@ class PendingDekTest { } @Test - fun anActivityThatStopsBeforeTheAnswerWipesTheCopy() { + fun aStoppedActivityKeepsTheCopyForALateAnswer() { val controller = Robolectric.buildActivity(FragmentActivity::class.java).create().start().resume() val pending = PendingDek(key()) - var held: ByteArray? = null - pending.use { held = it } - pending.wipeWhenStopped(controller.get().lifecycle) + pending.wipeWhenDestroyed(controller.get().lifecycle) - controller.pause() - assertArrayEquals("a paused activity may still get the answer", key(), pending.use { it.copyOf() }) - controller.stop() + // Some devices show the prompt over a stopped activity and answer after the stop. + controller.pause().stop() - assertNull(pending.use { it.copyOf() }) - assertArrayEquals(ByteArray(32), held) + assertArrayEquals(key(), pending.use { it.copyOf() }) } @Test fun anActivityDestroyedBeforeTheAnswerWipesTheCopy() { val controller = Robolectric.buildActivity(FragmentActivity::class.java).create() val pending = PendingDek(key()) - pending.wipeWhenStopped(controller.get().lifecycle) + pending.wipeWhenDestroyed(controller.get().lifecycle) controller.destroy() @@ -80,7 +76,7 @@ class PendingDekTest { val lifecycle = controller.get().lifecycle val before = lifecycle.observerCount val pending = PendingDek(key()) - pending.wipeWhenStopped(lifecycle) + pending.wipeWhenDestroyed(lifecycle) assertEquals(before + 1, lifecycle.observerCount) pending.wipe() 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 index 2932d6ed..2d4521f9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt @@ -23,12 +23,18 @@ import androidx.lifecycle.LifecycleOwner 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 (the guard was released by - * the activity's end meanwhile, and a newer prompt may be open), and the caller then drops it. + * 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 @@ -36,17 +42,28 @@ internal class BiometricPromptGuard { 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 - if (mine) current = null + val mine = current === token || stopped === token + if (current === token) current = null + if (stopped === token) stopped = null mine } } 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 index 8f36306c..f7230ee9 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -272,19 +272,40 @@ class LockViewModelTest { } @Test - fun anAnswerFromAPromptCutOffByAStopIsDroppedAndItsKeyWiped() { + 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 } - answer[0]!!(key) + answers.first()(key) assertTrue(results.isEmpty()) assertArrayEquals(ByteArray(KEY_LENGTH), key) From 37f9b6ae177998feaa15acabc87bcc5ce28416b4 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 13:26:16 +0200 Subject: [PATCH 46/75] feat(backup): restore the user's settings with a backup A backup now carries the user's appearance, list, editor, language and privacy preferences (an allow-list of 17 settings); security state such as PIN, biometric, duress or lockout data is never part of it. They are applied on a full restore, after the notes restored, and each unknown or invalid value is skipped. The payload schema goes to 5; older backups restore exactly as before, and a test fails when a new preference key is added without deciding whether it belongs in backups. Refs: #93 --- PRIVACY.md | 5 +- SECURITY.md | 18 +- .../encly/platform/AndroidHostServices.kt | 11 +- .../pasich/encly/core/backup/BackupPayload.kt | 49 +++-- .../encly/core/backup/BackupSettings.kt | 131 ++++++++++++ .../core/backup/BackupPayloadCodecTest.kt | 79 +++++++- .../encly/core/backup/BackupSettingsTest.kt | 84 ++++++++ .../encly/core/security/CryptoGoldenTest.kt | 18 ++ .../encly/core/security/GoldenVectors.kt | 12 ++ .../presentation/viewmodel/BackupFlowsTest.kt | 3 +- .../viewmodel/BackupViewModelTest.kt | 8 +- .../viewmodel/OnboardingViewModelTest.kt | 3 +- .../com/pasich/encly/ui/screens/TestApp.kt | 3 +- .../encly/data/backup/BackupImporter.kt | 7 +- .../pasich/encly/data/backup/BackupManager.kt | 18 +- .../encly/data/backup/BackupPreferences.kt | 187 ++++++++++++++++++ .../encly/testutil/TestSettingsRepository.kt | 3 +- .../encly/data/backup/BackupManagerTest.kt | 114 ++++++++++- .../backup/PreferenceKeysClassifiedTest.kt | 153 ++++++++++++++ .../encly/data/backup/SettingsBackupTest.kt | 176 +++++++++++++++++ .../domain/usecase/RestoreFromBackupTest.kt | 3 +- .../viewmodel/AuthSetupViewModelTest.kt | 3 +- .../ImportFromMyNotesViewModelTest.kt | 3 +- .../viewmodel/OnboardingFailuresTest.kt | 4 +- 24 files changed, 1052 insertions(+), 43 deletions(-) create mode 100644 core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupSettings.kt create mode 100644 core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupSettingsTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/PreferenceKeysClassifiedTest.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt 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/SECURITY.md b/SECURITY.md index 84845ceb..ea4f34cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -311,8 +311,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 diff --git a/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt index 4f4f00e1..00173710 100644 --- a/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt +++ b/app/src/main/java/com/pasich/encly/platform/AndroidHostServices.kt @@ -6,6 +6,8 @@ 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 @@ -244,7 +246,14 @@ class AndroidSensitiveClipboard(@param:ApplicationContext private val context: C class AndroidAppLocaleController : AppLocaleController { override fun current(): AppLanguage = AppLocales.current() - override fun apply(language: AppLanguage) = AppLocales.apply(language) + /** 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. */ 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 index 5e0eebe1..6cb09f02 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -29,7 +30,12 @@ import kotlinx.serialization.json.jsonObject * - 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. */ +@OptIn(ExperimentalSerializationApi::class) @Serializable public data class BackupPayload( val schema: Int = SCHEMA_VERSION, @@ -37,9 +43,16 @@ public data class BackupPayload( 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 = 4 + public const val SCHEMA_VERSION: Int = 5 /** Tasks carried a `reminderDate`, dropped on import. */ public const val SCHEMA_V1: Int = 1 @@ -49,6 +62,9 @@ public data class BackupPayload( /** 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 } } @@ -105,6 +121,7 @@ public object BackupPayloadCodec { 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 @@ -138,11 +155,19 @@ public object BackupPayloadCodec { 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) + when (schema) { + in BackupPayload.SCHEMA_V1..BackupPayload.SCHEMA_V3 -> { + val tree = decodeStream(json, JsonElement.serializer(), plaintext) + json.decodeFromJsonElement(BackupPayload.serializer(), upgrade(tree.jsonObject, schema)) + } + + // The shape of the current schema without the section: decoded strictly in one pass (the + // commonest file, up to the size limit), not through a JSON tree. + BackupPayload.SCHEMA_V4 -> decodeStream(json, BackupPayload.serializer(), plaintext).also { + require(it.settings.isEmpty()) { "Unexpected settings in schema $schema" } + }.copy(schema = BackupPayload.SCHEMA_VERSION) + + else -> decodeStream(json, BackupPayload.serializer(), plaintext) } } validate(payload) @@ -150,11 +175,12 @@ public object BackupPayloadCodec { } /** - * 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. + * Schema 1 to 3 -> current (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. 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 -> @@ -169,6 +195,7 @@ public object BackupPayloadCodec { 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_VERSION)) + (tasks?.let { mapOf(TASKS_KEY to JsonArray(it)) }.orEmpty()), 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..1beed82f --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/backup/BackupSettings.kt @@ -0,0 +1,131 @@ +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") + 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", + ) + + 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" + + private val bool = SettingKind { it == "true" || it == "false" } + + private fun choice(names: Collection) = SettingKind { it in names } + + 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. + 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/commonTest/kotlin/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/backup/BackupPayloadCodecTest.kt index de3081b7..09333078 100644 --- 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 @@ -32,6 +32,9 @@ class BackupPayloadCodecTest { 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() { @@ -185,20 +188,65 @@ class BackupPayloadCodecTest { /** * 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-4 vector, kept byte for byte: a payload without preferences encodes + * exactly as the schema-4 app wrote it, apart from the schema number. */ @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(schema4Json(), BackupPayloadCodec.encode(withSubtasks.copy(schema = 4)).decodeToString()) + } + + /** The schema-5 vector: the same file plus the `settings` section, last, in the order given. */ + @Test + fun encodedSettingsSectionIsPinned() { + val expected = schema4Json().replaceFirst("\"schema\":4", "\"schema\":5").removeSuffix("}") + + ""","settings":{"theme_type":"DARK","font_size":"18","is_grid_note_list":"true"}}""" + + assertEquals(expected, BackupPayloadCodec.encode(withSettings).decodeToString()) + } + + @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"}}""" - assertEquals(expected, BackupPayloadCodec.encode(withSubtasks).decodeToString()) + 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 6. + 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`. */ @@ -218,6 +266,17 @@ class BackupPayloadCodecTest { """"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 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}],""" + 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/CryptoGoldenTest.kt b/core/security/src/commonTest/kotlin/com/pasich/encly/core/security/CryptoGoldenTest.kt index 443f0d97..3d285b06 100644 --- 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 @@ -5,6 +5,8 @@ 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 @@ -295,6 +297,22 @@ class CryptoGoldenTest { // --- 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( 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 index c181794f..6e2b18a5 100644 --- 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 @@ -81,6 +81,18 @@ internal object GoldenVectors { "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") diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 3a34d482..d16db08d 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -17,6 +17,7 @@ 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.Tag import com.pasich.encly.data.model.Task @@ -430,7 +431,7 @@ class BackupFlowsTest { private fun viewModelFor(store: InMemoryVaultDataStore, prefs: InMemoryKeyValueStore = InMemoryKeyValueStore()) = BackupViewModel( - backupManager = BackupManager(security, store, prefs), + backupManager = BackupManager(security, store, prefs, NoBackupPreferences), phraseSetup = BackupPhraseSetup(security), securityManager = security, sessionLockManager = SessionLockManager(security), 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 index e24741ae..d0b57239 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -11,6 +11,7 @@ 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 @@ -47,7 +48,12 @@ class BackupViewModelTest { security = mock(SecurityManager::class.java) sessionLock = SessionLockManager(security, screenshots = screenshots) viewModel = BackupViewModel( - backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()), + backupManager = BackupManager( + security, + InMemoryVaultDataStore(), + InMemoryKeyValueStore(), + NoBackupPreferences, + ), phraseSetup = BackupPhraseSetup(security), securityManager = security, sessionLockManager = sessionLock, diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt index e8137115..a55d0610 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt @@ -14,6 +14,7 @@ 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 @@ -81,7 +82,7 @@ class OnboardingViewModelTest { private fun viewModel(biometric: Boolean = false): OnboardingViewModel { `when`(security.biometricAvailable()).thenReturn(biometric) - val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) pending = PendingRestore(backupManager) return OnboardingViewModel(OnboardingUseCase(security, backupManager, pending), documents) } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index fbbb0f64..92502c67 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -23,6 +23,7 @@ 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 @@ -94,7 +95,7 @@ internal class TestApp { val documents: BackupDocuments = mock(BackupDocuments::class.java) val markdownDocuments: MarkdownDocuments = mock(MarkdownDocuments::class.java) val store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) val screenshots = ScreenshotAllowance() val sessionLock = SessionLockManager(security, screenshots = screenshots) val selectedTag = SelectedTagHolder() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt index 5e4a53a7..9614a314 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt @@ -46,7 +46,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( @@ -85,6 +89,7 @@ object BackupMapper { .map { BackupSubtask(it.uid, it.title, it.isCompleted, it.position) }, ) }, + settings = settings, ).also(BackupPayloadCodec::validate) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt index 8c00f6d2..8ea06ede 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupManager.kt @@ -11,6 +11,7 @@ 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 @@ -31,6 +32,7 @@ class BackupManager( private val securityManager: SecurityManager, private val store: VaultDataStore, private val secureStoragePrefs: KeyValueStore, + private val preferences: BackupPreferences, ) { internal var clock: () -> Long = ::currentTimeMillis @@ -38,14 +40,17 @@ class BackupManager( 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 { @@ -91,10 +96,17 @@ class BackupManager( /** * 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 } /** 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..304aca7d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt @@ -0,0 +1,187 @@ +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.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.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 and note previews (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, +) : 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, + ) + // 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()) + + // 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/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestSettingsRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestSettingsRepository.kt index c2bdffb2..e87f9d33 100644 --- a/shared/src/commonTest/kotlin/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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt index 7ee8540d..73e90118 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -1,9 +1,13 @@ 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 @@ -23,17 +27,20 @@ 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 { - val security = mock(SecurityManager::class.java) + 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).apply { clock = { 1L } } + manager = BackupManager(security, store, prefs, preferences).apply { clock = { 1L } } } @Test @@ -86,7 +93,7 @@ class BackupManagerTest { } try { - BackupManager(security, store, InMemoryKeyValueStore()).createBackup() + BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences).createBackup() fail("expected a closed-session failure") } catch (e: BackupException) { assertEquals(BackupError.IO, e.error) @@ -97,7 +104,7 @@ class BackupManagerTest { fun theDecoyVaultRefusesEveryBackupStepAsAStorageError() = runTest { val security = mock(SecurityManager::class.java) `when`(security.isDecoySession()).thenReturn(true) - val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()) + val decoy = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore(), NoBackupPreferences) val empty = BackupPayload(exportedAt = 0, tags = emptyList(), notes = emptyList(), tasks = emptyList()) val errors = listOf( @@ -109,6 +116,105 @@ class BackupManagerTest { 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, tagUid = null, 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..7e41b421 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt @@ -0,0 +1,176 @@ +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.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.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 backup = SettingsBackup(repository, autoLock, keyboard, previews, locales) + + @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(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) + } + + 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 backup = SettingsBackup(settings, autoLock, keyboard, previews, locales) + } + + 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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt index b3311f4f..f40e8f60 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt @@ -10,6 +10,7 @@ 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.Tag @@ -50,7 +51,7 @@ class RestoreFromBackupTest { true } store = InMemoryVaultDataStore() - val backupManager = BackupManager(security, store, InMemoryKeyValueStore()) + val backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences) pending = PendingRestore(backupManager) useCase = OnboardingUseCase(security, backupManager, pending) } diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt index 82c5d7ec..cc475665 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModelTest.kt @@ -5,6 +5,7 @@ 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.InMemoryKeyValueStore @@ -48,7 +49,7 @@ class AuthSetupViewModelTest { true } store = InMemoryVaultDataStore() - pending = PendingRestore(BackupManager(security, store, InMemoryKeyValueStore())) + 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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt index 6fa6ac1e..9ddad6ad 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportFromMyNotesViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle 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 @@ -75,7 +76,7 @@ class ImportFromMyNotesViewModelTest { cache = cache, securityManager = security, sessionLockManager = sessionLock, - backupManager = BackupManager(security, store, InMemoryKeyValueStore()), + backupManager = BackupManager(security, store, InMemoryKeyValueStore(), NoBackupPreferences), ioDispatcher = dispatcher, savedStateHandle = savedState, ) 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 index 3e2eea14..4de7cae7 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt @@ -10,6 +10,7 @@ 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 @@ -198,7 +199,8 @@ class OnboardingFailuresTest { private fun viewModel(biometric: Boolean = false): OnboardingViewModel { `when`(security.biometricAvailable()).thenReturn(biometric) - val backupManager = BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore()) + val backupManager = + BackupManager(security, InMemoryVaultDataStore(), InMemoryKeyValueStore(), NoBackupPreferences) return OnboardingViewModel( OnboardingUseCase(security, backupManager, PendingRestore(backupManager)), mock(BackupDocuments::class.java), From fff7bdf26b5ad9a22aefca76eefa4d630817c8fb Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 13:49:10 +0200 Subject: [PATCH 47/75] build: security ABI dump for the lock flush and the unreadable vault status --- core/security/api/android/security.api | 102 ++++++++++++++++++--- core/security/api/desktop/security.api | 102 ++++++++++++++++++--- core/security/api/security.klib.api | 119 +++++++++++++++++++++++-- 3 files changed, 295 insertions(+), 28 deletions(-) diff --git a/core/security/api/android/security.api b/core/security/api/android/security.api index 749208a8..109834a1 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -138,20 +138,23 @@ public final class com/pasich/encly/core/backup/BackupPayload { 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_VERSION I - public fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;)V - public synthetic fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 copy (IJLjava/util/List;Ljava/util/List;Ljava/util/List;)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;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupPayload; + 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 @@ -195,6 +198,44 @@ public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/ 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 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 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 @@ -440,9 +481,11 @@ public final class com/pasich/encly/core/security/AutoLock$MetroFactory$Companio 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; @@ -576,6 +619,7 @@ public final class com/pasich/encly/core/security/InitialStatus : java/lang/Enum 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; @@ -633,6 +677,7 @@ public final class com/pasich/encly/core/security/Mnemonic { 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 { @@ -720,6 +765,29 @@ public final class com/pasich/encly/core/security/RecoveryWords { 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 @@ -802,6 +870,7 @@ public final class com/pasich/encly/core/security/SeedPhraseManager { 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 @@ -832,8 +901,8 @@ public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory } 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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; @@ -847,21 +916,26 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V public final fun onUnlockAbandoned ()V public final fun onUnlocked ()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;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;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + 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/SessionLockManager; + 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;)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/SessionLockManager; + 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 { @@ -901,6 +975,11 @@ public final class com/pasich/encly/core/security/VaultKind : java/lang/Enum { 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 { @@ -913,6 +992,7 @@ public final class com/pasich/encly/core/security/VaultStore { 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 { diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index 749208a8..109834a1 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -138,20 +138,23 @@ public final class com/pasich/encly/core/backup/BackupPayload { 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_VERSION I - public fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;)V - public synthetic fun (IJLjava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 copy (IJLjava/util/List;Ljava/util/List;Ljava/util/List;)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;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupPayload; + 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 @@ -195,6 +198,44 @@ public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/ 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 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 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 @@ -440,9 +481,11 @@ public final class com/pasich/encly/core/security/AutoLock$MetroFactory$Companio 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; @@ -576,6 +619,7 @@ public final class com/pasich/encly/core/security/InitialStatus : java/lang/Enum 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; @@ -633,6 +677,7 @@ public final class com/pasich/encly/core/security/Mnemonic { 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 { @@ -720,6 +765,29 @@ public final class com/pasich/encly/core/security/RecoveryWords { 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 @@ -802,6 +870,7 @@ public final class com/pasich/encly/core/security/SeedPhraseManager { 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 @@ -832,8 +901,8 @@ public final class com/pasich/encly/core/security/SeedPhraseManager$MetroFactory } 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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; @@ -847,21 +916,26 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V public final fun onUnlockAbandoned ()V public final fun onUnlocked ()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;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;)Lcom/pasich/encly/core/security/SessionLockManager$MetroFactory; + 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/SessionLockManager; + 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;)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/SessionLockManager; + 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 { @@ -901,6 +975,11 @@ public final class com/pasich/encly/core/security/VaultKind : java/lang/Enum { 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 { @@ -913,6 +992,7 @@ public final class com/pasich/encly/core/security/VaultStore { 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 { diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index 308dfa18..99755b56 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -71,9 +71,11 @@ final enum class com.pasich.encly.core.security/AuthType : 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] @@ -129,6 +131,7 @@ final enum class com.pasich.encly.core.security/InitialStatus : kotlin/Enum(): kotlin.enums/EnumEntries // com.pasich.encly.core.security/InitialStatus.entries.|#static(){}[0] @@ -327,6 +330,8 @@ abstract interface com.pasich.encly.core.security/VaultFileIo { // com.pasich.en 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] @@ -461,7 +466,7 @@ final class com.pasich.encly.core.backup/BackupNote { // com.pasich.encly.core.b } 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) // com.pasich.encly.core.backup/BackupPayload.|(kotlin.Int;kotlin.Long;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[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] @@ -469,6 +474,8 @@ final class com.pasich.encly.core.backup/BackupPayload { // com.pasich.encly.cor 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] @@ -479,7 +486,8 @@ final class com.pasich.encly.core.backup/BackupPayload { // com.pasich.encly.cor 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 copy(kotlin/Int = ..., kotlin/Long = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): 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){}[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] @@ -500,6 +508,8 @@ final class com.pasich.encly.core.backup/BackupPayload { // com.pasich.encly.cor 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_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] @@ -786,6 +796,30 @@ final class com.pasich.encly.core.security/PinFactorException : kotlin/Exception 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] @@ -863,6 +897,7 @@ final class com.pasich.encly.core.security/SeedPhraseManager { // com.pasich.enc 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] @@ -888,7 +923,7 @@ final class com.pasich.encly.core.security/SeedPhraseManager { // com.pasich.enc } 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/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){}[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] @@ -903,15 +938,16 @@ final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecyc 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/Boolean // com.pasich.encly.core.security/SessionLockManager.onUnlocked|onUnlocked(){}[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/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){}[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): 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){}[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/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){}[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] } } } @@ -927,6 +963,7 @@ final class com.pasich.encly.core.security/VaultStore { // com.pasich.encly.core 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] @@ -1012,6 +1049,74 @@ final object com.pasich.encly.core.backup/BackupPayloadCodec { // com.pasich.enc 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 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 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] @@ -1035,5 +1140,7 @@ final object com.pasich.encly.core.security/RecoveryWords { // com.pasich.encly. 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/PIN_LENGTH // com.pasich.encly.core.security/PIN_LENGTH|{}PIN_LENGTH[0] final fun (): kotlin/Int // com.pasich.encly.core.security/PIN_LENGTH.|(){}[0] From 0550b663c8c1c9f6fc9b8b9d0ccdc45f1cf8ed3e Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 14:10:49 +0200 Subject: [PATCH 48/75] feat: Arabic and right-to-left layouts The app follows the layout direction of its language on every platform: navigation, transitions, menus and icons mirror, while the PIN keypad and the recovery phrase stay left to right. Each paragraph and list item of a note takes its direction from its first strong character, so Arabic and Latin text can be mixed in one note. Arabic is added as the tenth language, with its six plural forms, the store listing and the language picker. Refs: #105 --- CONTRIBUTING.md | 7 +- README.md | 2 +- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + app/src/main/res/xml/locales_config.xml | 1 + .../encly/core/common/UiTextAndStringsTest.kt | 1 + .../encly/i18n/TranslationCompletenessTest.kt | 1 + .../pasich/encly/release/StoreMetadataTest.kt | 2 +- core/resources/README.md | 2 +- core/resources/build.gradle.kts | 2 +- .../commonMain/strings/values-ar/strings.xml | 693 ++++++++++++++++++ .../strings/values-ar/strings_handoff.xml | 20 + .../strings/values-ar/strings_import.xml | 82 +++ .../strings/values-ar/strings_security.xml | 154 ++++ .../src/commonMain/strings/values/strings.xml | 1 + .../encly/core/resources/AndroidStrings.kt | 1 + .../resources/AndroidStringsConversionTest.kt | 36 + .../encly/core/backup/BackupSettings.kt | 1 + .../pasich/encly/editor/ui/RtlBlocksUiTest.kt | 181 +++++ .../encly/editor/ui/EditorBlocksHost.kt | 38 +- .../encly/editor/ui/blocks/ListBlock.kt | 21 +- .../editor/ui/blocks/ReadingDirection.kt | 69 ++ .../encly/editor/ui/blocks/SlashMenuPopup.kt | 7 +- .../editor/ui/components/EditorContextMenu.kt | 13 +- .../editor/ui/blocks/ReadingDirectionTest.kt | 66 ++ .../editor/ui/components/MenuOffsetTest.kt | 18 + .../metadata/android/ar/changelogs/20400.txt | 9 + .../metadata/android/ar/full_description.txt | 35 + .../metadata/android/ar/short_description.txt | 1 + fastlane/metadata/android/ar/title.txt | 1 + .../android/de-DE/full_description.txt | 2 +- .../android/en-US/full_description.txt | 2 +- .../android/es-ES/full_description.txt | 2 +- .../android/fr-FR/full_description.txt | 2 +- .../android/it-IT/full_description.txt | 2 +- .../android/nl-NL/full_description.txt | 2 +- .../android/pl-PL/full_description.txt | 2 +- .../android/pt-PT/full_description.txt | 2 +- .../metadata/android/uk/full_description.txt | 2 +- .../encly/ui/screens/AdaptiveLayoutTest.kt | 28 + .../ui/screens/AppLayoutDirectionTest.kt | 44 ++ .../encly/ui/screens/LocalizedScreensTest.kt | 2 +- .../pasich/encly/ui/screens/RtlScreensTest.kt | 201 +++++ .../pasich/encly/utils/LocalizedDateTest.kt | 8 + .../pasich/encly/core/locale/AppLanguage.kt | 6 +- .../presentation/components/tiles/TaskItem.kt | 5 +- .../presentation/designsystem/ContextMenu.kt | 11 +- .../presentation/designsystem/EnclyIcons.kt | 8 +- .../encly/presentation/designsystem/Layout.kt | 13 + .../encly/presentation/designsystem/Pin.kt | 138 ++-- .../designsystem/RecoveryPhraseInput.kt | 3 + .../presentation/designsystem/WordGrid.kt | 14 + .../encly/presentation/effects/Animation.kt | 27 +- .../presentation/effects/AnimationScreens.kt | 23 +- .../presentation/effects/UnlockReveal.kt | 6 +- .../navigation/AppLayoutDirection.kt | 22 + .../encly/presentation/navigation/EnclyApp.kt | 19 +- .../screen/PinCodeConfigScreen.kt | 7 +- .../screen/onboarding/OnboardingScreen.kt | 12 +- .../screen/onboarding/PhraseCheckFields.kt | 3 +- .../com/pasich/encly/whatsnew/Changelogs.kt | 1 + .../encly/core/locale/AppLanguageTest.kt | 10 +- .../pasich/encly/utils/LocalizedDateJvm.kt | 12 +- .../com/pasich/encly/whatsnew/WhatsNewTest.kt | 9 +- 64 files changed, 1955 insertions(+), 165 deletions(-) create mode 100644 core/resources/src/commonMain/strings/values-ar/strings.xml create mode 100644 core/resources/src/commonMain/strings/values-ar/strings_handoff.xml create mode 100644 core/resources/src/commonMain/strings/values-ar/strings_import.xml create mode 100644 core/resources/src/commonMain/strings/values-ar/strings_security.xml create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/RtlBlocksUiTest.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirection.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ReadingDirectionTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/components/MenuOffsetTest.kt create mode 100644 fastlane/metadata/android/ar/changelogs/20400.txt create mode 100644 fastlane/metadata/android/ar/full_description.txt create mode 100644 fastlane/metadata/android/ar/short_description.txt create mode 100644 fastlane/metadata/android/ar/title.txt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AppLayoutDirectionTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/RtlScreensTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/AppLayoutDirection.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 259883d7..f4caa80a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,7 +172,10 @@ the build converts them (`ConvertAndroidStrings` in `build-logic`). Compose reso 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`. + `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); @@ -228,7 +231,7 @@ 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. diff --git a/README.md b/README.md index 18bd570a..5f50e926 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78f5b282..c2d76fbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,12 +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 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 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"> + 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 77245b2b..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 @@ -89,6 +89,7 @@ class UiTextAndStringsTest(private val language: AppLanguage, private val expect 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/i18n/TranslationCompletenessTest.kt b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt index 408b449a..79abcacb 100644 --- a/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt +++ b/app/src/test/java/com/pasich/encly/i18n/TranslationCompletenessTest.kt @@ -233,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/release/StoreMetadataTest.kt b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt index e61d650d..17f24976 100644 --- a/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt +++ b/app/src/test/java/com/pasich/encly/release/StoreMetadataTest.kt @@ -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/core/resources/README.md b/core/resources/README.md index f0d4424e..2ed92eeb 100644 --- a/core/resources/README.md +++ b/core/resources/README.md @@ -1,6 +1,6 @@ # :core:resources -Every string and plural the UI shows, in English and eight translations (de es fr it nl pl pt uk), +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`, diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts index 010ef631..631fffb4 100644 --- a/core/resources/build.gradle.kts +++ b/core/resources/build.gradle.kts @@ -1,4 +1,4 @@ -// Every string and plural the UI shows, in all nine languages, as Compose Multiplatform resources. The +// 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 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..e93417de --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -0,0 +1,693 @@ + + + حول + مساحة محلية مشفرة للملاحظات والأفكار والمهام، بلا مزامنة سحابية. + حماية البيانات + تُخزَّن بيانات الخزنة محليًا في قاعدة بيانات SQLCipher مشفرة + الأسئلة الشائعة + اقتراحات وبلاغات + أبلغ عن خلل أو اقترح تحسينًا على GitHub + لماذا هذا مهم وكيف تساهم عبر Ko-fi + لم يُعثر على متصفح. افتح الرابط يدويًا: %1$s + لم يُعثر على تطبيق بريد إلكتروني + سياسة الخصوصية + كيف نخزّن بياناتك ونحميها + قيّم التطبيق + اترك تقييمًا في متجر Google Play + الدعم والتواصل + تواصل مع المطوّر + إن كانت لديك أسئلة أو اقتراحات فيمكنك مراسلتي عبر البريد الإلكتروني. + + متابعة + + إضافة + + الكل + + مفتاح الدخول المطلوب. اضغط لتغيير رمز PIN. + رمز PIN من 6 أرقام + المصادقة + + رجوع + + لم يتم تأكيد تغيير الحماية بالقياسات الحيوية + تعذّر إنشاء مفتاح القياسات الحيوية + أكّد هويتك + المصادقة + أكّد قياساتك الحيوية + الوصول إلى المفتاح + أكّد قياساتك الحيوية لتغيير الحماية + تأكيد الأمان + أكّد قياساتك الحيوية للوصول إلى المفتاح المشفر + فتح 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 لقطات الشاشة وتسجيلها والمعاينات غير المحمية في قائمة التطبيقات الأخيرة. وعندما تحتاج إلى لقطة، يرفع الحظر خيار «السماح بلقطات الشاشة حتى يُقفل التطبيق» في الإعدادات ← الأمان بعد إدخال رمز PIN؛ وتبقى شاشات القفل ورمز PIN وعبارة الاسترداد محمية، ويتوقف السماح من تلقاء نفسه عندما يُقفل Encly. + هل يمكنني التقاط لقطات شاشة لـ Encly؟ + الأساسيات + الأمان والاسترداد + الإعدادات + استكشاف الأخطاء وإصلاحها + تُخزَّن الملاحظات والوسوم والمهام فقط في قاعدة بيانات SQLCipher المحلية الخاصة بـ Encly. لا توجد مزامنة سحابية ولا نسخ احتياطي تلقائي: يمكنك بنفسك تصدير نسخة احتياطية مشفرة من الإعدادات ← النسخ الاحتياطي، أو نسخة Markdown مقروءة غير مشفرة. + أين تُخزَّن بياناتي؟ + تجمع الوسوم ملاحظاتك. يمكنك إنشاؤها وإعادة تسميتها وإخفاؤها وحذفها، وسحبها إلى أي ترتيب، وتصفية الشاشة الرئيسية حسب الوسم. + كيف تعمل الوسوم؟ + للمهام وصف وأولوية. يمكنك تصفيتها حسب الأولوية ووضع علامة «تم» عليها. لا يضع Encly نص المهام في إشعارات النظام أبدًا. + ماذا يمكن أن تفعل المهام؟ + في الإعدادات ← المظهر اختر إحدى السمات الخمس، والوضع الفاتح أو الداكن أو وضع النظام، وأحد أنماط الخطوط الثلاثة، وحجم النص. وعلى Android 12 فما فوق يمكنك أيضًا أخذ لون التمييز من خلفية الشاشة. + كيف أغيّر السمة؟ + يتعمد Encly ألا ينشئ قاعدة بيانات فارغة فوق خزنة مفقودة أو غير قابلة للقراءة. لا تؤكد المسح الكامل إن كنت لا تزال بحاجة إلى بياناتك المحلية. + يقول Encly إن الخزنة تالفة + + حجم النص + تباعد الأسطر + افتراضي + مضغوط + مريح + تحريري + عصري + تقني + + اختر اللغة + افتراضي النظام + + إضافة رابط + + عنصر القائمة + + العودة إلى رمز PIN + استعادة الوصول + أدخل عبارة الاسترداد المكونة من 12 كلمة + الاسترداد + أدخل رمز PIN المكوّن من 6 أرقام للمتابعة + افتح قفل ملاحظاتك + جارٍ فتح القفل… + استعد الوصول بعبارة الاسترداد + رمز PIN خاطئ + عبارة الاسترداد خاطئة + + اضغط مطولًا لمسح كل شيء + يبدو أن البيانات المشفرة أو مفاتيح التشفير قد تغيّرت أو تلفت، لذا لا يمكن الاسترداد.\n\nلمواصلة استخدام التطبيق عليك إعادة تعيينه ومسح البيانات التالفة. سيؤدي ذلك إلى حذف كل الملاحظات والإعدادات المحفوظة نهائيًا. + عذرًا، بياناتك تالفة + هذه البيانات من إصدار أقدم + عثر Encly على ملاحظات حُفظت بإصدار سابق للإصدار 2.0. يستخدم Encly 2.0 تنسيقًا مشفرًا جديدًا ولا يستطيع فتحها، ولا توجد طريقة لتحويلها.\n\nلاستخدام التطبيق ابدأ من جديد. سيؤدي ذلك إلى حذف الملاحظات والإعدادات القديمة على هذا الهاتف نهائيًا. + اضغط مطولًا للبدء من جديد + + الأسئلة الشائعة + الإعدادات + ادعم المطوّر + الوسوم + المهام + سلة المحذوفات + + خيارات إضافية + + بلا وسم + + ملاحظة جديدة + تعذّر حذف الملاحظة + الوصف… + تجاهل كل التغييرات + تعذّر تجاهل التغييرات + تعذّر إنشاء نسخة + تعذّرت قراءة محتوى الملاحظة. عُطّل التحرير حتى لا تتضرر البيانات. + %1$s (نسخة) + تعذّرت استعادة الملاحظة + تعذّر حفظ التغييرات + تعذّر نقل الملاحظة إلى سلة المحذوفات + + تعذّرت تهيئة التطبيق + تعذّر إنشاء عبارة الاسترداد + تعذّر إنشاء الخزنة المشفرة + + فتح القائمة + + %1$d%% + + أدخل رمز PIN مرة أخرى للتأكيد + أكّد رمز PIN المكوّن من 6 أرقام + أكّد رمز PIN الحالي قبل تغيير مفتاح الدخول + أدخل رمز PIN الحالي + أدخل رمز PIN جديدًا لحماية ملاحظاتك + تم تغيير رمز PIN + أنشئ رمز PIN من 6 أرقام + رمز 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 + فتح القفل ببصمة الإصبع + الخطوة %1$d من %2$d + دفتر ملاحظات لا يفتحه غيرك. + ملاحظات ومهام مشفرة على هذا الهاتف. يستغرق الإعداد دقيقة تقريبًا. + بلا حساب + بلا تسجيل ولا بريد إلكتروني ولا إعادة تعيين كلمة مرور. + يعمل دون اتصال + لا يتصل Encly بالإنترنت أبدًا. + مشفر على الجهاز + خزنتك مقفلة بمفتاح لا يملكه سواك. + ابدأ + لديّ نسخة احتياطية + اختر رمز PIN من 6 أرقام + يفتح 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 يومًا في سلة المحذوفات. + 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..21f01df7 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings_import.xml @@ -0,0 +1,82 @@ + + + + الاستيراد من تطبيقات أخرى + استيراد الملاحظات + من 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 + جارٍ الاستيراد… + تم الاستيراد + أُضيف %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..19ab3d03 --- /dev/null +++ b/core/resources/src/commonMain/strings/values-ar/strings_security.xml @@ -0,0 +1,154 @@ + + + اكتب كلماتك الـ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 جديدًا. + + محاولات كثيرة جدًا. أعد المحاولة بعد %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 أرقام، مختلفة عن رمز PIN + أكّد رمز المسح + أدخل رمز المسح مرة أخرى + يجب أن يختلف رمز المسح عن رمز PIN + تعذّر حفظ رمز المسح. حاول مرة أخرى. + تم تعيين رمز المسح + تم إيقاف رمز المسح + أُعيد تعيين مفتاح رمز PIN، فأُوقف أي رمز مسح كان لديك. عيّنه من جديد إن أردت واحدًا. + هذا الرمز مستخدم بالفعل. اختر رمزًا مختلفًا. + رمز الإكراه + رمز PIN ثانٍ يفتح خزنة منفصلة بملاحظاتها الخاصة + أُوقف عند إعادة تعيين مفتاح رمز PIN. عيّنه من جديد إن أردت واحدًا. + إن أجبرك أحد على فتح Encly، فاكتب رمز الإكراه بدل رمز PIN. يفتح Encly خزنة منفصلة فارغة يمكنك استخدامها كالمعتاد: الملاحظات التي تضيفها هناك تبقى فيها. + تبقى خزنتك الحقيقية مقفلة ولا تُمسّ. وعلى خلاف رمز المسح، لا يمسح رمز الإكراه شيئًا. وتفتح بصمتك وعبارة الاسترداد الخزنة الحقيقية دائمًا. + يحميك عندما يطلب منك أحد فتح التطبيق وإظهار ما فيه، لا من فحص جنائي للهاتف. وأثناء الحظر المؤقت لا يمكن استخدام رمز الإكراه أيضًا. + رمز الإكراه معيّن. + لا يوجد رمز إكراه معيّن. + تعيين رمز الإكراه + تغيير رمز الإكراه + إيقاف رمز الإكراه + أكّد رمز PIN لإعداد رمز الإكراه + أنشئ رمز إكراه + 6 أرقام، مختلفة عن رمز PIN + أكّد رمز الإكراه + أدخل رمز الإكراه مرة أخرى + يجب أن يختلف رمز الإكراه عن رمز PIN + تعذّر حفظ رمز الإكراه. حاول مرة أخرى. + تم تعيين رمز الإكراه. + تم تغيير رمز الإكراه. تحتفظ الخزنة الوهمية بملاحظاتها. + تم إيقاف رمز الإكراه + أُعيد تعيين مفتاح رمز PIN، فتوقف رمز الإكراه عن العمل. عيّنه من جديد؛ فالخزنة الوهمية تحتفظ بملاحظاتها. + إيقاف رمز الإكراه؟ + تُحذف الخزنة الوهمية والملاحظات التي فيها. أما خزنتك الحقيقية فلا تتأثر. + إيقاف + إظهار معاينات الملاحظات + تعرض قائمة الملاحظات أول أسطر كل ملاحظة. أوقفه لعرض العنوان والوسم والتاريخ فقط؛ ويظل البحث يجد النص الكامل. + diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index 421c320b..bd5ac8af 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -15,6 +15,7 @@ Polski Português Nederlands + العربية About A local encrypted space for notes, ideas and tasks, with no cloud sync. 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 index 7ba22e51..2c2f9f02 100644 --- 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 @@ -25,6 +25,7 @@ internal object AndroidStrings { 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") 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 index 9b069a48..73c5feca 100644 --- 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 @@ -139,6 +139,41 @@ class AndroidStringsConversionTest { 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 { @@ -204,6 +239,7 @@ class AndroidStringsConversionTest { "pt" to mapOf("one" to 1, "many" to 1_000_000, "other" to 2), "uk" to mapOf("one" to 1, "few" to 2, "many" to 5), "pl" to mapOf("one" to 1, "few" to 2, "many" to 5), + "ar" to mapOf("zero" to 0, "one" to 1, "two" to 2, "few" to 3, "many" to 11, "other" to 100), ) } } 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 index 1beed82f..49d42ee0 100644 --- 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 @@ -48,6 +48,7 @@ public object BackupSettings { "POLISH", "PORTUGUESE", "DUTCH", + "ARABIC", ) public const val IS_DYNAMIC_THEME: String = "is_dynamic_theme" 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/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt index 298e3a6c..306eaf61 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -46,6 +46,7 @@ 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.TextBlock import com.pasich.encly.editor.ui.components.ContextEntry @@ -302,23 +303,30 @@ private fun BlockContent( val fieldModifier = modifier.focusRequester(focusRequester) val isLocked = state.isLocked when (block) { - is Block.TextBlock -> TextBlock( - block, - blockActions, - modifier = fieldModifier, - isLocked = isLocked, - showPlaceholder = state.showPlaceholder, - ) + // 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 -> QuoteBlock( - block, - blockActions, - focusRequester, - fieldModifier = modifier, - isLocked = isLocked, - ) + is Block.QuoteBlock -> ReadingDirection(block.text) { + QuoteBlock( + block, + blockActions, + focusRequester, + fieldModifier = modifier, + isLocked = isLocked, + ) + } - is Block.HBlock -> HBlock(block, blockActions, modifier = fieldModifier, 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) 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 index 16c46154..7d7cc9d8 100644 --- 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 @@ -234,14 +234,17 @@ internal fun ListBlock( } } } - ListItemRow( - item = item, - marker = marker.forItem(itemIndex + 1), - field = field, - editor = editor, - locks = locks, - fieldModifier = itemModifier, - ) + // 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 = marker.forItem(itemIndex + 1), + field = field, + editor = editor, + locks = locks, + fieldModifier = itemModifier, + ) + } } } } @@ -485,7 +488,7 @@ private fun RowScope.RowCheckMark(state: CheckState, marker: ListMarker) { 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) } + layout(marker.width.roundToPx(), top + box.height) { box.placeRelative(0, top) } } } 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/SlashMenuPopup.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt index 7c8b03c0..099786a0 100644 --- 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 @@ -138,7 +138,12 @@ private class UnderOrOverField(private val bottomLimit: (windowHeight: Int) -> I layoutDirection: LayoutDirection, popupContentSize: IntSize, ): IntOffset { - val x = anchorBounds.left.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0)) + 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) 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 index c429f57a..3a23cbd1 100644 --- 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 @@ -28,8 +28,10 @@ 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 @@ -52,19 +54,28 @@ internal fun EditorContextMenu( // 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(position.x.toDp(), (position.y - size.height).toDp()) }, + 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 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/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/fastlane/metadata/android/ar/changelogs/20400.txt b/fastlane/metadata/android/ar/changelogs/20400.txt new file mode 100644 index 00000000..effe8721 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/20400.txt @@ -0,0 +1,9 @@ +Encly 2.4.0 +• عاد فتح القفل ببصمة الإصبع إلى العمل بعد أن يقفل Encly في الخلفية. +• تحتفظ الملاحظات الجديدة بتاريخ إنشائها. +• جديد: إخفاء نص الملاحظات في القوائم (الإعدادات ← الأمان). +• جديد: السماح بلقطات الشاشة حتى يُقفل التطبيق. +• قفل تلقائي بعد 5 أو 10 دقائق. +• تفرغ سلة المحذوفات تلقائيًا بعد 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..1284399f --- /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 أرقام مرتبط بالعتاد الآمن في هاتفك، مع فتح اختياري ببصمة الإصبع أو الوجه (القياسات الحيوية القوية فقط). +• يقفل نفسه: بعد مغادرتك التطبيق (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/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 6fc05652..4619f16f 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index c627986e..6e33d3fe 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 0100b5d7..be1a2fee 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 5ec424fc..57b27c8f 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 16bcac9c..e907ee44 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt index e67b4455..88eb46ab 100644 --- a/fastlane/metadata/android/nl-NL/full_description.txt +++ b/fastlane/metadata/android/nl-NL/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt index bc39775a..43865e32 100644 --- a/fastlane/metadata/android/pl-PL/full_description.txt +++ b/fastlane/metadata/android/pl-PL/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt index 3108a05b..d4a21f89 100644 --- a/fastlane/metadata/android/pt-PT/full_description.txt +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -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/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 7c13e4b1..068bb130 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -28,7 +28,7 @@ Encly — це захищені нотатки й завдання, зашифр ВІДКРИТИЙ КОД • Encly має відкритий код (Apache-2.0): кожен може перевірити, що він робить. • Працює без Google Play Services. Android 8.0 або новіший. -• 9 мов: українська, англійська, німецька, французька, іспанська, італійська, польська, португальська й нідерландська. Мову для Encly можна вибрати окремо в Налаштування → Мова. +• 10 мов: українська, англійська, німецька, французька, іспанська, італійська, польська, португальська, нідерландська й арабська. Мову для Encly можна вибрати окремо в Налаштування → Мова. Encly: зашифровані нотатки, які залишаються вашими. 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 index f9f043b3..9f3f715c 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveLayoutTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/AdaptiveLayoutTest.kt @@ -6,6 +6,7 @@ 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 @@ -20,6 +21,7 @@ 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 @@ -29,6 +31,7 @@ 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 @@ -123,10 +126,14 @@ class AdaptiveLayoutTest : ComposeScreenTest() { 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()) @@ -161,6 +168,27 @@ class AdaptiveLayoutTest : ComposeScreenTest() { 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")) 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/LocalizedScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt index 81c8b63f..29e61087 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt @@ -169,6 +169,6 @@ class LocalizedScreensTest(private val language: String) : ComposeScreenTest() { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") fun languages(): List> = - listOf("en", "uk", "de", "es", "fr", "it", "nl", "pl", "pt").map { arrayOf(it) } + 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/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/utils/LocalizedDateTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt index 7395a1c2..3764926a 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/utils/LocalizedDateTest.kt @@ -73,6 +73,14 @@ class LocalizedDateTest { 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() 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 index b669e9a9..5abd0933 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/core/locale/AppLanguage.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/locale/AppLanguage.kt @@ -1,6 +1,7 @@ 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 @@ -18,9 +19,11 @@ import org.jetbrains.compose.resources.StringResource * 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) { +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), @@ -31,6 +34,7 @@ enum class AppLanguage(val tag: String?, val nativeName: StringResource) { 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 { 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 index 998eccbb..5cacd23d 100644 --- 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 @@ -15,10 +15,12 @@ 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 @@ -107,12 +109,13 @@ fun TaskItem( 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 = (1f - state.animationProgress) * TASK_TRANSLATION_X + translationX = towardsEnd * (1f - state.animationProgress) * TASK_TRANSLATION_X }, ) { TaskRow( 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 index ed4071ac..9cff3b75 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ContextMenu.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/ContextMenu.kt @@ -28,8 +28,10 @@ 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 @@ -53,6 +55,7 @@ fun EnclyContextMenu( 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 } @@ -62,8 +65,12 @@ fun EnclyContextMenu( anchor?.let { position -> ContextMenuPopup( entries = remember(position) { entries() }, - // A menu is placed from the bottom start of its anchor. - offset = with(density) { DpOffset(position.x.toDp(), (position.y - size.height).toDp()) }, + // 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 }, ) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt index d4b809d1..22a1b050 100644 --- a/shared/src/commonMain/kotlin/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,6 +694,11 @@ 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", +) private val Ink = SolidColor(Color.Black) internal fun enclyIcon( @@ -707,6 +712,7 @@ internal 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/Layout.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt index cb557833..4b621f9d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Layout.kt @@ -1,8 +1,21 @@ 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 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 index e855f839..a298e7ce 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt @@ -75,32 +75,34 @@ fun PinDots( 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)) {} + 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)) {} + } } } } @@ -128,57 +130,59 @@ fun PinKeypad( 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)) + 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) { + 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 = onBiometric, + onClick = onBackspace, size = size, enabled = enabled, - container = MaterialTheme.colorScheme.primaryContainer, + 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.Fingerprint, - contentDescription = stringResource(Res.string.pin_unlock_with_fingerprint), - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(28.dp), + imageVector = EnclyIcons.Backspace, + contentDescription = stringResource(Res.string.pin_delete_digit), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(26.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(Res.string.pin_delete_digit), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(26.dp), - ) } } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt index 8c6706b9..3aea7382 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/RecoveryPhraseInput.kt @@ -46,6 +46,7 @@ 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.core.resources.Res import com.pasich.encly.core.resources.recovery_phrase_checksum @@ -316,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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/WordGrid.kt index 8377f8f6..1ba5edd6 100644 --- a/shared/src/commonMain/kotlin/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 @@ -54,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/effects/Animation.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt index 2da56b9f..c0154a81 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt @@ -1,5 +1,7 @@ package com.pasich.encly.presentation.effects +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.CubicBezierEasing @@ -8,8 +10,6 @@ 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 @@ -23,18 +23,21 @@ private const val AXIS_SHIFT_FRACTION = 8 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. + * 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. */ -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 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 }, +) + 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)) +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 }, +) + fadeOut(tween(AXIS_FADE_OUT)) /** * Duration of the card growing into the editor. A little longer than a screen slide: the card travels 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 index 89b9db63..183002f9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt @@ -1,5 +1,6 @@ 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 @@ -12,18 +13,20 @@ 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) -} +fun AnimatedContentTransitionScope<*>.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) -} +fun AnimatedContentTransitionScope<*>.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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt index c38c35a0..2ce43717 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt @@ -24,6 +24,7 @@ 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.unit.LayoutDirection import androidx.compose.ui.util.lerp import com.pasich.encly.shared.resources.Res import com.pasich.encly.shared.resources.ic_launcher_foreground @@ -35,7 +36,7 @@ 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. */ +/** ...then the notes list slides in from the end side (the right, left to right) and pushes that colour out. */ private const val UNLOCK_PUSH_MS = 520 /** How long the list rests under the cover first, so its data and first layout land before it moves. */ @@ -198,7 +199,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/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/EnclyApp.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt index 56f988ea..b15049b5 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -18,6 +18,8 @@ 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 @@ -273,11 +275,13 @@ fun App( shielded: Boolean = false, locked: Boolean = false, ) { - 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) + 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) + } } } } @@ -311,11 +315,12 @@ private fun AppContent( LocalVaultLocked provides (locked || shielded), ) { // The whole app layer moves with the unlock reveal: it slides in from the - // right and pushes the logo colour out (see UnlockRevealOverlay). + // 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 = unlockReveal.contentOffset * size.width } + .graphicsLayer { translationX = towardsEnd * unlockReveal.contentOffset * size.width } .background(MaterialTheme.colorScheme.background), ) { CompositionLocalProvider(LocalEraseEpoch provides eraseEpoch) { 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 index 0d65b89c..4106086d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -1,14 +1,13 @@ 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.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 @@ -222,8 +221,8 @@ fun PinCodeConfigScreen( AnimatedContent( targetState = pinState.step, transitionSpec = { - (slideInHorizontally { width -> width } + fadeIn()) - .togetherWith(slideOutHorizontally { width -> -width } + fadeOut()) + (slideIntoContainer(SlideDirection.Start) + fadeIn()) + .togetherWith(slideOutOfContainer(SlideDirection.Start) + fadeOut()) }, label = "StepAnimation", ) { currentStep -> 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 index 673caa37..ae950abd 100644 --- 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 @@ -1,12 +1,11 @@ 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.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 @@ -235,11 +234,12 @@ private fun StepContent( modifier = modifier.fillMaxWidth(), transitionSpec = { val forward = targetState.ordinal > initialState.ordinal - slideInHorizontally( - initialOffsetX = { if (forward) it else -it }, + val direction = if (forward) SlideDirection.Start else SlideDirection.End + slideIntoContainer( + towards = direction, animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), - ) + fadeIn(tween(FADE_IN_MS, FADE_IN_DELAY_MS)) togetherWith slideOutHorizontally( - targetOffsetX = { if (forward) -it else it }, + ) + fadeIn(tween(FADE_IN_MS, FADE_IN_DELAY_MS)) togetherWith slideOutOfContainer( + towards = direction, animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), ) + fadeOut(tween(FADE_OUT_MS)) }, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt index 1d5d665c..4c7cc20d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/PhraseCheckFields.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +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 @@ -55,7 +56,7 @@ internal fun PhraseCheckFields( 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/whatsnew/Changelogs.kt b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt index 0e2e5bfd..6ad57a7f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/whatsnew/Changelogs.kt @@ -27,6 +27,7 @@ object Changelogs { AppLanguage.POLISH -> "pl-PL" AppLanguage.PORTUGUESE -> "pt-PT" AppLanguage.DUTCH -> "nl-NL" + AppLanguage.ARABIC -> "ar" AppLanguage.ENGLISH, AppLanguage.SYSTEM -> FALLBACK } 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 index d0519894..f0f1ba6f 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/core/locale/AppLanguageTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/core/locale/AppLanguageTest.kt @@ -18,6 +18,7 @@ class AppLanguageTest { 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 @@ -33,8 +34,13 @@ class AppLanguageTest { } @Test - fun systemDefaultIsListedFirstThenNineLanguages() { + fun systemDefaultIsListedFirstThenTenLanguages() { assertEquals(AppLanguage.SYSTEM, AppLanguage.entries.first()) - assertEquals(9, AppLanguage.entries.count { it.tag != null }) + assertEquals(10, AppLanguage.entries.count { it.tag != null }) + } + + @Test + fun onlyArabicIsLaidOutRightToLeft() { + assertEquals(listOf(AppLanguage.ARABIC), AppLanguage.entries.filter { it.rightToLeft }) } } diff --git a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt index cf624fc8..09e72cc6 100644 --- a/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt +++ b/shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/utils/LocalizedDateJvm.kt @@ -23,7 +23,7 @@ internal fun formatLocalizedJvm( bestPattern: (Locale, String) -> String, ): String { val format = formats.getOrPut(style to languageTag) { - newFormat(style, Locale.forLanguageTag(languageTag), bestPattern) + 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) { @@ -42,3 +42,13 @@ private fun newFormat(style: LocalizedDate, locale: Locale, bestPattern: (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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt index ccd45fdd..e51c1cd8 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/whatsnew/WhatsNewTest.kt @@ -115,6 +115,7 @@ class WhatsNewTest { "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")) @@ -123,8 +124,8 @@ class WhatsNewTest { @Test fun anUnsupportedOrMissingLanguageReadsEnglish() { - assertEquals("en-US", Changelogs.folderFor("ar")) assertEquals("en-US", Changelogs.folderFor("ja-JP")) + assertEquals("en-US", Changelogs.folderFor("he")) assertEquals("en-US", Changelogs.folderFor(null)) assertEquals("en-US", Changelogs.folderFor("")) } @@ -137,9 +138,9 @@ class WhatsNewTest { ) assertEquals("• Deutsche Änderung", Changelogs.notes("de", bundled)?.body) - // French has no file for this version, Arabic is not an app language: both read English. + // 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("ar", bundled)?.body) + assertEquals("• English change", Changelogs.notes("ja", bundled)?.body) assertEquals("Encly 2.4.0", Changelogs.notes("de", bundled)?.heading) } @@ -164,7 +165,7 @@ class WhatsNewTest { .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(9, BundledChangelogs.notes.size) + assertEquals(10, BundledChangelogs.notes.size) assertNotNull(Changelogs.current("uk")) assertTrue(BundledChangelogs.notes.getValue("uk").startsWith(Changelogs.current("uk")!!.heading)) } From ee82cdba4cd045ff3964728eb5bcf0a30043319f Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 15:10:47 +0200 Subject: [PATCH 49/75] fix(desktop): windows, locking, clipboard and storage ready for a first release Autosave writes only the columns it changed, so a note trashed in another window stays trashed, and a read-only window follows the other window's saves. A lock that comes during an unlock is no longer lost, sleep locks the vault on macOS and Windows, and a lock cancels a print in progress. Copies stay out of the Windows clipboard history and cloud and are concealed on macOS. Windows keeps its data in local app data, macOS excludes it from Time Machine, and the Linux Secret Service session is encrypted. Desktop-only libraries are listed with their licences. Refs: #106 --- SECURITY.md | 9 +- config/detekt/detekt.yml | 4 + core/database/api/android/database.api | 4 + core/database/api/database.klib.api | 4 + core/database/api/desktop/database.api | 4 + .../encly/data/database/NotesDaoTest.kt | 85 +- .../encly/data/database/dao/NotesDao.kt | 43 + .../encly/core/keystore/DesktopPinFactor.kt | 2 +- .../pasich/encly/core/keystore/DpapiErrors.kt | 45 + .../core/keystore/LinuxSecretServiceStore.kt | 89 +- .../core/keystore/SecretServiceSession.kt | 139 +++ .../core/keystore/WindowsDpapiSecretStore.kt | 19 +- .../encly/core/keystore/DpapiErrorsTest.kt | 49 + .../core/keystore/SecretServiceSessionTest.kt | 150 +++ core/security/api/android/security.api | 4 +- core/security/api/desktop/security.api | 4 +- core/security/api/security.klib.api | 3 +- .../encly/core/security/SecurityManager.kt | 23 +- .../encly/core/security/SessionLockManager.kt | 42 +- .../core/security/SessionLockManagerTest.kt | 46 + desktopApp/README.md | 18 +- desktopApp/build.gradle.kts | 4 + .../common/licenses/THIRD-PARTY-NOTICES.txt | 39 + .../licenses/commons-logging-LICENSE.txt | 202 ++++ .../licenses/commons-logging-NOTICE.txt | 5 + .../common/licenses/dbus-java-LICENSE.txt | 22 + .../resources/common/licenses/jna-AL2.0.txt | 177 +++ .../resources/common/licenses/jna-LGPL2.1.txt | 458 +++++++ .../resources/common/licenses/jna-LICENSE.txt | 26 + .../common/licenses/pdfbox-LICENSE.txt | 1061 +++++++++++++++++ .../common/licenses/pdfbox-NOTICE.txt | 46 + .../common/licenses/slf4j-LICENSE.txt | 24 + .../com/pasich/encly/desktop/DesktopApp.kt | 30 +- .../kotlin/com/pasich/encly/desktop/Main.kt | 8 + .../encly/desktop/di/DesktopAppGraph.kt | 2 + .../pasich/encly/desktop/di/DesktopModules.kt | 2 +- .../desktop/documents/DesktopNoteDocuments.kt | 12 +- .../encly/desktop/nativeos/AwtPrinting.kt | 53 +- .../desktop/nativeos/NativeBackupExclusion.kt | 44 + .../encly/desktop/nativeos/NativeClipboard.kt | 268 +++++ .../desktop/nativeos/NativeScreenLock.kt | 83 +- .../desktop/nativeos/PortalFileDialogs.kt | 49 +- .../desktop/platform/DesktopClipboard.kt | 35 +- .../desktop/platform/DesktopEnvironment.kt | 3 + .../desktop/platform/DesktopFileDialogs.kt | 42 +- .../encly/desktop/platform/DesktopLicenses.kt | 32 + .../desktop/security/DesktopSessionLock.kt | 69 +- .../encly/desktop/storage/AppDirectories.kt | 10 +- .../encly/desktop/storage/BackupExclusion.kt | 49 + .../desktop/storage/FileKeyValueStore.kt | 31 +- .../encly/desktop/window/DesktopWindows.kt | 50 +- .../encly/desktop/window/IdleLockWhileOpen.kt | 46 + .../pasich/encly/desktop/window/WindowHost.kt | 75 +- .../encly/desktop/window/WindowStateStore.kt | 31 +- .../encly/desktop/BackupExclusionTest.kt | 60 + .../com/pasich/encly/desktop/ClipboardTest.kt | 113 ++ .../com/pasich/encly/desktop/LicensesTest.kt | 47 + .../encly/desktop/NavigationCarryTest.kt | 103 ++ .../encly/desktop/NoteDocumentsPdfTest.kt | 23 +- .../encly/desktop/PlatformServicesTest.kt | 51 + .../com/pasich/encly/desktop/SessionTest.kt | 151 +++ .../com/pasich/encly/desktop/StorageTest.kt | 42 +- .../encly/desktop/WindowAndCommandsTest.kt | 17 + .../com/pasich/encly/desktop/WindowsTest.kt | 63 + .../viewmodel/MarkdownExportViewModelTest.kt | 2 +- .../encly/core/di/AppViewModelFactory.kt | 17 +- .../data/repository/NotesRepositoryImpl.kt | 24 +- .../domain/repository/NotesRepository.kt | 12 +- .../note/UpdateNoteTrashStatusUseCase.kt | 7 +- .../com/pasich/encly/platform/HostServices.kt | 12 + .../pasich/encly/platform/NoteHostServices.kt | 26 +- .../editor/persistence/NotePersistence.kt | 119 +- .../encly/presentation/navigation/EnclyApp.kt | 15 +- .../presentation/screen/LicensesScreen.kt | 4 +- .../encly/presentation/screen/LockScreen.kt | 12 +- .../screen/PinCodeConfigScreen.kt | 26 + .../presentation/screen/editnote/NoteClaim.kt | 30 +- .../screen/settings/SecuritySections.kt | 24 +- .../viewmodel/AuthSetupViewModel.kt | 3 +- .../viewmodel/EditNoteViewModel.kt | 45 + .../presentation/viewmodel/LockViewModel.kt | 14 +- .../viewmodel/MarkdownExportViewModel.kt | 6 +- .../viewmodel/SelectedTagHolder.kt | 65 +- .../encly/platform/NoteHostServicesTest.kt | 23 + .../editor/persistence/NotePersistenceTest.kt | 122 +- .../viewmodel/ArchiveViewModelTest.kt | 5 +- .../EditNoteViewModelLifecycleTest.kt | 55 + .../viewmodel/WindowSelectionsTest.kt | 52 + .../encly/testutil/TestNotesRepository.kt | 17 +- .../data/repository/RepositoryImplTest.kt | 51 +- .../domain/usecase/note/NoteUseCasesTest.kt | 6 +- .../viewmodel/LockViewModelTest.kt | 24 + 92 files changed, 5106 insertions(+), 256 deletions(-) create mode 100644 core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/DpapiErrors.kt create mode 100644 core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceSession.kt create mode 100644 core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/DpapiErrorsTest.kt create mode 100644 core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt create mode 100644 desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt create mode 100644 desktopApp/packaging/resources/common/licenses/commons-logging-LICENSE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/commons-logging-NOTICE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/dbus-java-LICENSE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/jna-AL2.0.txt create mode 100644 desktopApp/packaging/resources/common/licenses/jna-LGPL2.1.txt create mode 100644 desktopApp/packaging/resources/common/licenses/jna-LICENSE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/pdfbox-LICENSE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/pdfbox-NOTICE.txt create mode 100644 desktopApp/packaging/resources/common/licenses/slf4j-LICENSE.txt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeBackupExclusion.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeClipboard.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/BackupExclusion.kt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/IdleLockWhileOpen.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/BackupExclusionTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt create mode 100644 desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NavigationCarryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/WindowSelectionsTest.kt diff --git a/SECURITY.md b/SECURITY.md index ea4f34cd..21b3d862 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,12 +66,17 @@ 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 `%APPDATA%\Encly\`; only the same Windows user can decrypt it. | +| 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`. Without a running Secret Service (a headless session) the PIN cannot be used; there is no fallback to a file. | +| 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. diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index ea0fe5bb..8a1fa3ff 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -108,6 +108,10 @@ Compose: - 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 diff --git a/core/database/api/android/database.api b/core/database/api/android/database.api index 2d462dd8..9324b233 100644 --- a/core/database/api/android/database.api +++ b/core/database/api/android/database.api @@ -167,9 +167,11 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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_Impl : com/pasich/encly/data/database/dao/NotesDao { @@ -190,9 +192,11 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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 { diff --git a/core/database/api/database.klib.api b/core/database/api/database.klib.api index 2737a883..9d40d866 100644 --- a/core/database/api/database.klib.api +++ b/core/database/api/database.klib.api @@ -49,9 +49,11 @@ abstract interface com.pasich.encly.data.database.dao/NotesDao { // com.pasich.e 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/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.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 updateTagId(kotlin/Long, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateTagId|updateTagId(kotlin.Long;kotlin.Long?){}[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] } abstract interface com.pasich.encly.data.database.dao/TagsDao { // com.pasich.encly.data.database.dao/TagsDao|null[0] @@ -153,9 +155,11 @@ final class com.pasich.encly.data.database.dao/NotesDao_Impl : com.pasich.encly. 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/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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?){}[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 updateTagId(kotlin/Long, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateTagId|updateTagId(kotlin.Long;kotlin.Long?){}[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] diff --git a/core/database/api/desktop/database.api b/core/database/api/desktop/database.api index 6d5c7208..70a4702e 100644 --- a/core/database/api/desktop/database.api +++ b/core/database/api/desktop/database.api @@ -166,9 +166,11 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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_Impl : com/pasich/encly/data/database/dao/NotesDao { @@ -189,9 +191,11 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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 { 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 index 3232ff7a..eee60aa0 100644 --- 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 @@ -95,16 +95,96 @@ class NotesDaoTest { fun aTrashedArchivedNoteIsInTheTrashAndComesBackToTheArchive() = runBlocking { val id = insert("both") { isArchived = true } - dao.updateNote(checkNotNull(dao.getNoteById(id)).copy(isTrash = true)) + dao.updateTrashed(id, true, TRASHED_AT) assertEquals(listOf("both"), dao.getTrashNotes().first().map { it.title }) assertTrue(titles(dao.getArchivedNotesWithTags()).isEmpty()) - dao.updateNote(checkNotNull(dao.getNoteById(id)).copy(isTrash = false)) + 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, null, 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, null, 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, false, null, 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, null, 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, null, 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, null, false, false, false, "none", false, null, false, null, + ) + + assertEquals(uid, checkNotNull(dao.getNoteById(id)).uid) + } + @Test fun theBackupSnapshotKeepsPinArchiveAndColour() = runBlocking { insert("styled") { @@ -241,6 +321,7 @@ class NotesDaoTest { 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/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 index cdc51b1b..00b866e5 100644 --- 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 @@ -74,6 +74,49 @@ public interface NotesDao { ) 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. + * [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, + tagId = CASE WHEN :setTag = 1 THEN :tagId ELSE tagId 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, + setTag: Boolean, + tagId: Long?, + setPinned: Boolean, + pinned: Boolean, + setColor: Boolean, + color: String, + setTextSize: Boolean, + textSize: Int?, + setLineSpacing: Boolean, + lineSpacing: Int?, + ): Int + /** 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 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 index 5159a504..b9d9f7d0 100644 --- 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 @@ -107,7 +107,7 @@ public class DesktopPinFactor internal constructor(private val store: SecretStor public fun create(dataDirectory: Path): DesktopPinFactor = DesktopPinFactor( selectSecretStore( osName = System.getProperty("os.name").orEmpty(), - windows = { WindowsDpapiSecretStore() }, + 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 index d099cede..63697473 100644 --- 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 @@ -6,6 +6,7 @@ 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 /** @@ -19,8 +20,10 @@ import java.security.SecureRandom * * 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 `plain` transfer algorithm: the secret crosses the session bus, a socket that only the same user - * can open, unencrypted; the encrypted `dh-ietf1024-sha256-aes128-cbc-pkcs7` algorithm is not implemented. + * 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 @@ -33,19 +36,30 @@ internal class LinuxSecretServiceStore( private val application: String = APPLICATION_ID, private val random: SecureRandom = SecureRandom(), ) : SecretStore { - override fun read(slot: PinKeySlot): ByteArray? = session { connection, service, sessionPath -> - val items = findItems(connection, service, slot) + 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(sessionPath) - SensitiveDataCleaner.clear(secret.parameters) - secret.value + 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, sessionPath -> + 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") @@ -61,7 +75,8 @@ internal class LinuxSecretServiceStore( ITEM_LABEL to Variant(LABEL), ITEM_ATTRIBUTES to Variant(attributesOf(slot), "a{ss}"), ) - val struct = SecretStruct(sessionPath, ByteArray(0), sent, CONTENT_TYPE) + 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 @@ -92,8 +107,27 @@ internal class LinuxSecretServiceStore( } } - /** Opens a bus connection and a `plain` Secret Service session, runs [block], and closes both. */ - private fun session(block: (DBusConnection, SecretServiceApi, DBusPath) -> T): T { + /** + * 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( @@ -102,12 +136,10 @@ internal class LinuxSecretServiceStore( SecretServiceApi::class.java, true, ) - val opened = service.OpenSession(PLAIN_ALGORITHM, Variant("")) - try { - return block(connection, service, opened.session) - } finally { - closeQuietly(connection, opened.session) - } + 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 @@ -116,12 +148,21 @@ internal class LinuxSecretServiceStore( } } - private fun closeQuietly(connection: DBusConnection, session: DBusPath) { - try { - connection.getRemoteObject(SECRETS_BUS_NAME, session.path, SecretSessionApi::class.java, true).Close() - } catch (_: Exception) { - // The session ends with the connection anyway. - } + /** + * 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) { @@ -141,7 +182,7 @@ internal class LinuxSecretServiceStore( private companion object { const val DEFAULT_ALIAS = "default" - const val PLAIN_ALGORITHM = "plain" + 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" 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..d4d5d187 --- /dev/null +++ b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/SecretServiceSession.kt @@ -0,0 +1,139 @@ +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 = when (val value = output.value) { + is ByteArray -> value + is Array<*> -> value.filterIsInstance().toByteArray().takeIf { it.size == value.size } + else -> null +} ?: throw GeneralSecurityException("The Secret Service did not answer the key exchange") + +/** + * 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/WindowsDpapiSecretStore.kt b/core/keystore/src/desktopMain/kotlin/com/pasich/encly/core/keystore/WindowsDpapiSecretStore.kt index 79bf0e6b..385e0a2c 100644 --- 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 @@ -18,15 +18,16 @@ import java.security.SecureRandom /** * Windows: the secret is encrypted with DPAPI (`CryptProtectData`, user scope, no UI) and the blob is kept in - * `%APPDATA%\Encly\`. Only the same Windows user (on the same machine, or a roaming profile with the same - * credentials) can unprotect it, so the file is useless to anyone who copies it away. + * `%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 - * LOST_ERRORS) is reported as lost; any other DPAPI failure and every I/O failure is not. + * [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( @@ -47,7 +48,7 @@ internal class WindowsDpapiSecretStore( // 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 ${e.errorCode})", + message = "The DPAPI blob does not unprotect (error ${DpapiErrors.describe(e.errorCode)})", cause = e, ) } @@ -83,7 +84,8 @@ internal class WindowsDpapiSecretStore( } } - private fun isFinal(e: Win32Exception): Boolean = e.errorCode in LOST_ERRORS || e.hr.toInt() in LOST_ERRORS + 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) @@ -117,18 +119,15 @@ internal class WindowsDpapiSecretStore( const val TEMP_EXTENSION = ".tmp" const val DESCRIPTION = "Encly PIN factor" - /** ERROR_INVALID_DATA, NTE_BAD_DATA, NTE_BAD_KEY, NTE_BAD_KEY_STATE. */ - val LOST_ERRORS = setOf(13, 0x80090005.toInt(), 0x80090003.toInt(), 0x8009000B.toInt()) - /** 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("APPDATA") + val appData = System.getenv("LOCALAPPDATA") val base = if (!appData.isNullOrBlank()) { Paths.get(appData) } else { - Paths.get(System.getProperty("user.home"), "AppData", "Roaming") + 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/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/SecretServiceSessionTest.kt b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt new file mode 100644 index 00000000..2c341d02 --- /dev/null +++ b/core/keystore/src/desktopTest/kotlin/com/pasich/encly/core/keystore/SecretServiceSessionTest.kt @@ -0,0 +1,150 @@ +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))) + 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/security/api/android/security.api b/core/security/api/android/security.api index 109834a1..20a0fcf0 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -908,6 +908,7 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; public final fun getSessionGeneration ()J 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 @@ -915,7 +916,8 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V public final fun onUnlockAbandoned ()V - public final fun onUnlocked ()Z + 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 } diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index 109834a1..20a0fcf0 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -908,6 +908,7 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun getLocked ()Lkotlinx/coroutines/flow/StateFlow; public final fun getSessionGeneration ()J 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 @@ -915,7 +916,8 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ public fun onStart (Landroidx/lifecycle/LifecycleOwner;)V public fun onStop (Landroidx/lifecycle/LifecycleOwner;)V public final fun onUnlockAbandoned ()V - public final fun onUnlocked ()Z + 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 } diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index 99755b56..df6d6326 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -934,10 +934,11 @@ final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecyc 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 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/Boolean // com.pasich.encly.core.security/SessionLockManager.onUnlocked|onUnlocked(){}[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] 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 index b76c0ba6..f0261e35 100644 --- 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 @@ -178,15 +178,17 @@ public class SecurityManager( /** * A vault the non-secret flags store forgot: a stored seed, a PIN or recovery slot and the - * encrypted database 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 + * 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()) && - vaultDatabase.hasEncryptedDatabase() + // 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() && @@ -604,10 +606,17 @@ public class SecurityManager( 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() { - vaultDatabase.reset() - clearSessionKey() - securityStatus = InitialStatus.AUTH + synchronized(databaseLock) { + vaultDatabase.reset() + clearSessionKey() + securityStatus = InitialStatus.AUTH + } } /** 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 index 39733cdf..31ce0860 100644 --- 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 @@ -19,6 +19,9 @@ 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 @@ -62,6 +65,7 @@ public object NeverLocked : VaultLockEvents { * 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 @@ -82,8 +86,14 @@ public class SessionLockManager( private val preLockFlushes = MutableStateFlow>(emptyList()) /** True from the moment a lock starts waiting for the editors until the vault is closed. */ - @Volatile - private var flushing = false + 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() @@ -158,6 +168,7 @@ public class SessionLockManager( */ @MainThread public fun requireUnlock() { + lockRequestCount.addAndFetch(1L) screenshots.revoke() _locked.value = true } @@ -218,19 +229,26 @@ public class SessionLockManager( 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() + /** * 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. + * 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. 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. + * 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(): Boolean { - if (!foreground) { + public fun onUnlocked(lockRequestsAtStart: Long = lockRequests()): Boolean { + if (!foreground || lockRequestCount.load() != lockRequestsAtStart || deviceLock.isDeviceLocked()) { relock() return false } @@ -254,6 +272,8 @@ public class SessionLockManager( */ @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() } @@ -266,6 +286,7 @@ public class SessionLockManager( } private fun relock() { + lockRequestCount.addAndFetch(1L) // The screenshot allowance ends with every lock, before anything else can show. screenshots.revoke() val flushes = preLockFlushes.value @@ -274,8 +295,7 @@ public class SessionLockManager( return } // A lock already waiting for the editors covers this one. - if (flushing) return - flushing = true + 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 @@ -286,7 +306,7 @@ public class SessionLockManager( withTimeoutOrNull(LOCK_FLUSH_TIMEOUT_MS) { saves.joinAll() } } finally { closeVault() - flushing = false + flushing.store(false) } } } 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 index 2471b1f5..64d39720 100644 --- 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 @@ -504,6 +504,52 @@ class SessionLockManagerTest { 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() + } + private class FakeDeviceLock : DeviceLockWatcher { var locked = false var watching = false diff --git a/desktopApp/README.md b/desktopApp/README.md index 9458728a..7b4cdd38 100644 --- a/desktopApp/README.md +++ b/desktopApp/README.md @@ -22,7 +22,7 @@ One private directory per OS user. It is created `rwx------` where POSIX permiss | OS | Directory | |---|---| -| Windows | `%APPDATA%\Encly` | +| Windows | `%LOCALAPPDATA%\Encly` (local, never the roaming profile) | | macOS | `~/Library/Application Support/Encly` | | Linux | `$XDG_DATA_HOME/encly`, else `~/.local/share/encly` | @@ -40,9 +40,10 @@ 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 `%APPDATA%\Encly` too. Windows has no POSIX modes; the folder keeps the ACL it inherits from -`%APPDATA%` (the user, SYSTEM and Administrators), which is the same boundary DPAPI gives the secret. Encly does not -rewrite ACLs. +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 @@ -50,9 +51,10 @@ rewrite ACLs. |---|---|---| | `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) | | -| (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 | | -| 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 | AWT has no sensitive flag: a clipboard history (Windows Win+V, clipboard managers) may keep a copy | +| 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 | @@ -61,7 +63,7 @@ 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; +- 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 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index a91cd2e6..146048f8 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -102,6 +102,10 @@ compose.desktop { 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( 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..b77fdc93 --- /dev/null +++ b/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,39 @@ +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 + +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/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 index c9d39a5a..671d9844 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/DesktopApp.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/DesktopApp.kt @@ -20,8 +20,10 @@ 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 @@ -32,6 +34,8 @@ 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 @@ -41,6 +45,7 @@ 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 @@ -78,15 +83,23 @@ fun DesktopApp( // 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, @@ -96,12 +109,17 @@ fun DesktopApp( ) } AppTheme { - ToastHost(graph.toaster, Modifier.align(Alignment.BottomCenter).padding(16.dp)) + 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( @@ -116,13 +134,19 @@ fun DesktopHostServices( } } 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 graph.metroViewModelFactory, + LocalMetroViewModelFactory provides viewModelFactory, LocalAppWindow provides appWindow, LocalExternalLinks provides graph.externalLinks, - LocalToaster provides graph.toaster, + LocalExtraLicenses provides DesktopLicenses.notices, + LocalKeyboardPrivacyOffered provides false, + LocalToaster provides services.toaster, LocalSensitiveClipboard provides graph.clipboard, LocalAppLocales provides graph.locales, LocalAppInfo provides graph.appInfo, diff --git a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt index b9f4ec46..e2d3fce9 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/Main.kt @@ -5,6 +5,7 @@ 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 @@ -36,6 +37,8 @@ fun main(args: Array) { } 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) @@ -70,6 +73,11 @@ private fun startServices(graph: DesktopAppGraph) { 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() } 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 index 7fa784fe..8a122f07 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopAppGraph.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopAppGraph.kt @@ -1,5 +1,6 @@ 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 @@ -48,6 +49,7 @@ interface DesktopAppGraph : ViewModelGraph { val lifecycle: DesktopAppLifecycle val deviceLockWatcher: DesktopDeviceLockWatcher val toaster: DesktopToaster + val viewModelFactory: AppViewModelFactory val clipboardWiper: ClipboardWiper val locales: DesktopAppLocaleController val noteDocuments: NoteDocumentRenderer 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 index c62328f3..e4244f08 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopModules.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/di/DesktopModules.kt @@ -168,7 +168,7 @@ object DesktopServicesModule { @Provides @SingleIn(AppScope::class) fun provideClipboardWiper(environment: DesktopEnvironment): ClipboardWiper = - ClipboardWiper(environment.systemClipboard, environment.timer) + ClipboardWiper(environment.systemClipboard, environment.timer, writer = environment.clipboardWriter) @Provides @SingleIn(AppScope::class) 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 index 2f494e19..01be5a83 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt @@ -18,8 +18,11 @@ 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. Throws on failure. */ - suspend fun print(pages: Pageable): PrintResult + /** + * 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 } /** @@ -38,9 +41,10 @@ class DesktopNoteDocuments( build(document).use { pdf -> pdf.save(out.asOutputStream()) } } - override suspend fun print(document: NoteDocument): PrintResult { + 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. - return build(document).use { pdf -> printer.print(PDFPageable(pdf)) } + // 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. */ 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 index 9b29cfb3..e5436415 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/AwtPrinting.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/AwtPrinting.kt @@ -6,7 +6,10 @@ 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 @@ -16,32 +19,37 @@ import java.io.IOException * (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): PrintResult { + 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(pages) + job.setPageable(GuardedPages(pages, job, mayProceed)) job.jobName = JOB_NAME val confirmed = withContext(Dispatchers.Main.immediate) { job.printDialog() } - if (!confirmed) return PrintResult.CANCELLED + if (!confirmed || !mayProceed()) return PrintResult.CANCELLED // Spooling a long job is not for the UI thread. - withContext(Dispatchers.IO) { printJob(job) } - return PrintResult.PRINTED + 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() } } - private fun printJob(job: PrinterJob) { - try { - job.print() - } catch (e: PrinterException) { - throw IOException("Printing failed", e) - } + /** 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 { @@ -49,3 +57,26 @@ class AwtPrinting(private val lifecycle: DesktopAppLifecycle) : PrintLauncher { 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/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 index 271054aa..0f537427 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt @@ -4,10 +4,12 @@ package com.pasich.encly.desktop.nativeos 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.connections.impl.DBusConnection @@ -22,8 +24,18 @@ import org.freedesktop.dbus.messages.DBusSignal /** 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) - DesktopOs.WINDOWS -> PollingLockSignals(POLL_MILLIS, WindowsScreenLockProbe()::isLocked) + 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() } @@ -89,6 +101,73 @@ private class MacScreenLockProbe { } } +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? 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 index 7685cb70..5a56ac0e 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/PortalFileDialogs.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/PortalFileDialogs.kt @@ -6,6 +6,7 @@ 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 @@ -17,6 +18,9 @@ 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 @@ -24,6 +28,12 @@ import javax.swing.SwingUtilities // 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 @@ -37,6 +47,14 @@ internal interface PortalFileChooser : DBusInterface { 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( @@ -92,6 +110,7 @@ class PortalFileDialogs(private val fallback: FileDialogs) : FileDialogs { 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)) }, @@ -106,11 +125,39 @@ class PortalFileDialogs(private val fallback: FileDialogs) : FileDialogs { try { method(chooser) } catch (e: Exception) { + waiting -= answer throw Unavailable().initCause(e) } - answer.get() + 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() 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 index d4795acf..9e1fae0b 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopClipboard.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopClipboard.kt @@ -13,6 +13,14 @@ 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 @@ -20,17 +28,31 @@ import java.awt.datatransfer.Clipboard as AwtClipboard * 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. * - * Limit: AWT has no "sensitive" flag, so a clipboard history (Windows Win+V, a clipboard manager) may keep a - * copy that this wipe cannot reach. + * 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() @@ -90,7 +112,10 @@ class DesktopSensitiveClipboard(private val clipboard: () -> AwtClipboard?, priv // 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 { + 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) @@ -115,8 +140,10 @@ private class SensitiveComposeClipboard(private val delegate: Clipboard, private override suspend fun getClipEntry(): ClipEntry? = delegate.getClipEntry() override suspend fun setClipEntry(clipEntry: ClipEntry?) { - delegate.setClipEntry(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) { 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 index 13708d88..d8d0fe16 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopEnvironment.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopEnvironment.kt @@ -49,6 +49,8 @@ class DesktopEnvironment( 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(), ) { @@ -78,6 +80,7 @@ class DesktopEnvironment( }, 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 index eb0e0aa4..51902e91 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopFileDialogs.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopFileDialogs.kt @@ -5,6 +5,7 @@ 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 @@ -21,6 +22,12 @@ interface FileDialogs { /** "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. */ @@ -110,27 +117,44 @@ class AwtFileDialogs(private val os: DesktopOs, private val parent: () -> Frame? * 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) { - lifecycle.beginOwnDialog() + override fun save(suggestedName: String, onResult: (Path?) -> Unit) = gated { end -> delegate.save(suggestedName) { path -> - lifecycle.endOwnDialog() + end() onResult(path) } } - override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) { - lifecycle.beginOwnDialog() + override fun open(multiple: Boolean, extensions: Set, onResult: (List) -> Unit) = gated { end -> delegate.open(multiple, extensions) { paths -> - lifecycle.endOwnDialog() + end() onResult(paths) } } - override fun folder(onResult: (Path?) -> Unit) { - lifecycle.beginOwnDialog() + override fun folder(onResult: (Path?) -> Unit) = gated { end -> delegate.folder { path -> - lifecycle.endOwnDialog() + 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/DesktopLicenses.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt new file mode 100644 index 00000000..0ce0ba8f --- /dev/null +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt @@ -0,0 +1,32 @@ +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( + "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/DesktopSessionLock.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt index 5c69560c..e9636782 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt @@ -25,14 +25,47 @@ fun interface OsLockSignals { } } +/** + * 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 + } +} + /** * [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 } @@ -43,10 +76,15 @@ class PollingLockSignals( 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) @@ -189,6 +227,33 @@ class DesktopAppLifecycle : LifecycleOwner { } } +/** + * 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 @@ -203,6 +268,8 @@ class IdleLock( 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() @@ -233,7 +300,7 @@ class IdleLock( private fun check() { val limit = limitMillis() - val idle = TimeUnit.NANOSECONDS.toMillis(clock() - lastInputNanos) + val idle = TimeUnit.NANOSECONDS.toMillis(checkClock() - lastInputNanos) if (idle >= limit) { lockNow() schedule(limit) 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 index d4833506..a6036187 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/AppDirectories.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/AppDirectories.kt @@ -39,15 +39,17 @@ object AppDirectories { /** * The data directory for [os]. Pure: [env] answers environment variables and [home] is the user's home. - * - Windows: `%APPDATA%\Encly` (the same folder the DPAPI blob of the PIN factor lives in); + * - 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("APPDATA")?.takeIf { it.isNotBlank() } - (appData?.let(Paths::get) ?: Paths.get(home, "AppData", "Roaming")).resolve(WINDOWS_NAME) + 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) @@ -68,7 +70,7 @@ object AppDirectories { * 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 `%APPDATA%` is readable by the user, SYSTEM and Administrators only, which + * 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). * 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 index f54e92e4..94641057 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/storage/FileKeyValueStore.kt @@ -5,6 +5,7 @@ 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 @@ -23,8 +24,11 @@ import java.util.concurrent.Executors * 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 or parsed 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. + * 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, @@ -102,7 +106,10 @@ class FileKeyValueStore( } } - /** The file's content; empty when there is no file, null when there is one that cannot be read or parsed. */ + /** + * 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() @@ -110,14 +117,30 @@ class FileKeyValueStore( 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. - null + 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 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 index 78b07f8f..7aeaf5ef 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopWindows.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/DesktopWindows.kt @@ -1,8 +1,10 @@ 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 @@ -10,15 +12,19 @@ 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), and the file dialogs those screens open. The vault, the lock, - * the settings and the other services of the graph are the process's, one for all windows. + * 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, @@ -28,7 +34,25 @@ class WindowServices( 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( @@ -45,8 +69,24 @@ class WindowServices( } } +/** + * 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) { +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 @@ -84,7 +124,9 @@ class DesktopWindows( // 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 session = WindowSession(id, create(id, this), noteId) + 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 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 index 7edca93a..f974153c 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowHost.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowHost.kt @@ -12,6 +12,7 @@ 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 @@ -59,7 +60,6 @@ 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.security.IdleLock import com.pasich.encly.desktop.storage.DesktopOs import com.pasich.encly.domain.model.ThemeType import com.pasich.encly.platform.NoteWindows @@ -70,14 +70,12 @@ 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.AWTEvent import java.awt.Desktop import java.awt.Dimension import java.awt.GraphicsEnvironment -import java.awt.Toolkit import java.awt.datatransfer.DataFlavor -import java.awt.event.AWTEventListener import java.awt.event.WindowAdapter import java.awt.event.WindowEvent import java.io.File @@ -128,20 +126,18 @@ fun ApplicationScope.EnclyWindows(graph: DesktopAppGraph, launchFiles: List windows.focused?.services?.dropRouter?.drop(files) == true } + 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 -> @@ -159,7 +155,7 @@ fun ApplicationScope.EnclyWindows(graph: DesktopAppGraph, launchFiles: List) -> Boolean): Modifier = dragAndDropTarget( shouldStartDragAndDrop = { event -> event.awtTransferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) }, @@ -326,7 +317,7 @@ private fun Modifier.dropFiles(onDrop: (List) -> Boolean): Modifier = drag * 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) -> Boolean) { +private fun installOsHandlers(windows: DesktopWindows, onFiles: (List) -> Unit) { if (!Desktop.isDesktopSupported()) return val desktop = Desktop.getDesktop() runCatching { 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 index 58cda648..9a960fa8 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowStateStore.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/window/WindowStateStore.kt @@ -22,42 +22,51 @@ data class SavedWindow(val width: Int, val height: Int, val x: Int?, val y: Int? * 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) || !flags.contains(KEY_HEIGHT)) return null + 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, 0L).toInt().coerceAtLeast(minWidth).let { w -> + 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, 0L).toInt().coerceAtLeast(minHeight).let { h -> + 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, Long.MIN_VALUE).takeIf { flags.contains(KEY_X) }?.toInt() - val y = flags.getLong(KEY_Y, Long.MIN_VALUE).takeIf { flags.contains(KEY_Y) }?.toInt() + 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, false), + 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, maximised) + putBoolean(KEY_MAXIMISED + suffix, maximised) if (bounds != null && !maximised) { - putLong(KEY_WIDTH, bounds.width.toLong()) - putLong(KEY_HEIGHT, bounds.height.toLong()) - putLong(KEY_X, bounds.x.toLong()) - putLong(KEY_Y, bounds.y.toLong()) + 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()) } } } 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 index 1f64fe63..a7b21d17 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/ClipboardTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/ClipboardTest.kt @@ -151,6 +151,95 @@ class ClipboardWiperTest { } } +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 { @@ -194,6 +283,30 @@ class ComposeClipboardBoundaryTest { 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") 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..226f3d39 --- /dev/null +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt @@ -0,0 +1,47 @@ +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", "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", + "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/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 index 1c9b27ae..2168d722 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt @@ -26,7 +26,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class NoteDocumentsPdfTest { - private val offline = PrintLauncher { error("nothing prints here") } + private val offline = PrintLauncher { _, _ -> error("nothing prints here") } private val documents = DesktopNoteDocuments(offline) private fun pdfOf(note: NoteDocument, renderer: DesktopNoteDocuments = documents): ByteArray { @@ -209,7 +209,7 @@ class NoteDocumentsPdfTest { val cacheBefore = cache.exists() to cache.lastModified() pdfOf(note(DocumentBlock.Paragraph("Привіт"), DocumentBlock.Heading(1, "Zażółć"))) - val printing = DesktopNoteDocuments(PrintLauncher { PrintResult.PRINTED }) + val printing = DesktopNoteDocuments(PrintLauncher { _, _ -> PrintResult.PRINTED }) runBlocking { printing.print(note(DocumentBlock.Paragraph("x"))) } val created = temp.list().orEmpty().toSet() - tempBefore @@ -220,7 +220,7 @@ class NoteDocumentsPdfTest { @Test fun printingHandsTheRenderedPagesToThePrinter() = runBlocking { var pages = 0 - val printer = PrintLauncher { pageable: Pageable -> + val printer = PrintLauncher { pageable: Pageable, _ -> pages = pageable.numberOfPages PrintResult.PRINTED } @@ -231,9 +231,22 @@ class NoteDocumentsPdfTest { 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 closed = DesktopNoteDocuments(PrintLauncher { _, _ -> PrintResult.CANCELLED }) val result = closed.print(note(DocumentBlock.Paragraph("Hello"))) assertEquals(PrintResult.CANCELLED, result) @@ -241,7 +254,7 @@ class NoteDocumentsPdfTest { @Test fun aPrinterThatFailsFailsThePrint() { - val printer = PrintLauncher { throw IOException("jam") } + val printer = PrintLauncher { _, _ -> throw IOException("jam") } assertFailsWith { runBlocking { DesktopNoteDocuments(printer).print(note(DocumentBlock.Paragraph("Hello"))) } diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt index c8a3a9e5..bbec0f7b 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/PlatformServicesTest.kt @@ -36,6 +36,7 @@ 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 @@ -357,6 +358,56 @@ class PickersTest { 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) { diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt index 0790d34d..ea4953ec 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,7 @@ 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 @@ -281,6 +283,47 @@ class DesktopDeviceLockWatcherTest { 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() @@ -290,6 +333,114 @@ class DesktopDeviceLockWatcherTest { } } +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`() { diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt index 1b9565db..18ec7140 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/StorageTest.kt @@ -25,18 +25,22 @@ class AppDirectoriesTest { private fun env(vararg pairs: Pair): (String) -> String? = mapOf(*pairs)::get @Test - fun `windows uses APPDATA and falls back to the roaming profile`() { + fun `windows uses LOCALAPPDATA and never the roaming profile`() { assertEquals( - Paths.get("C:\\Users\\a\\AppData\\Roaming", "Encly"), + Paths.get("C:\\Users\\a\\AppData\\Local", "Encly"), AppDirectories.dataDirectory( DesktopOs.WINDOWS, - env("APPDATA" to "C:\\Users\\a\\AppData\\Roaming"), + env("LOCALAPPDATA" to "C:\\Users\\a\\AppData\\Local", "APPDATA" to "C:\\roaming"), "C:\\Users\\a", ), ) assertEquals( - Paths.get("home", "AppData", "Roaming", "Encly"), - AppDirectories.dataDirectory(DesktopOs.WINDOWS, env("APPDATA" to " "), "home"), + 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"), ) } @@ -191,23 +195,45 @@ class FileKeyValueStoreTest { } @Test - fun `an unreadable file fails closed and is never overwritten`() { + 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") } - assertContentEquals(damaged, Files.readAllBytes(file())) + 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.write(file(), byteArrayOf(0x5c, 0x75, 0x7a)) + 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") }) diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt index ba3d14a4..d66a68a5 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowAndCommandsTest.kt @@ -34,6 +34,23 @@ class WindowStateStoreTest { 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) diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt index 6b8dc166..2985ca53 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/WindowsTest.kt @@ -58,6 +58,69 @@ class DesktopWindowsTest { 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 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 index 3e58509b..a2d0d43a 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt @@ -424,7 +424,7 @@ class MarkdownExportViewModelTest { out.write("%PDF-fake".encodeToByteArray()) } - override suspend fun print(document: NoteDocument): PrintResult { + override suspend fun print(document: NoteDocument, mayProceed: () -> Boolean): PrintResult { if (printFails) throw java.io.IOException("no printer") printed += document return printResult 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 index 04b088f8..cf7ad228 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/core/di/AppViewModelFactory.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/core/di/AppViewModelFactory.kt @@ -1,6 +1,8 @@ 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 @@ -22,4 +24,17 @@ class AppViewModelFactory( override val assistedFactoryProviders: Map, () -> ViewModelAssistedFactory>, override val manualAssistedFactoryProviders: Map, () -> ManualViewModelAssistedFactory>, -) : MetroViewModelFactory() +) : 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/data/repository/NotesRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt index e83394e1..35736b53 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -57,10 +57,30 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note dao().insertNote(note) } - override suspend fun updateNote(note: Note): Result = storageWrite(TAG, "updateNote") { - dao().updateNote(note).requireRows() + override suspend fun updateEdited(note: Note, base: Note?): Result = storageWrite(TAG, "updateEdited") { + dao().updateEdited( + noteId = note.id, + title = note.title, + value = note.value, + stamp = base == null || note.date != base.date || note.description != base.description, + description = note.description, + date = note.date, + setTag = base == null || note.tagId != base.tagId, + tagId = note.tagId, + setPinned = base == null || note.isPinned != base.isPinned, + pinned = note.isPinned, + setColor = base == null || note.color != base.color, + color = note.color, + setTextSize = base == null || note.textSize != base.textSize, + textSize = note.textSize, + setLineSpacing = base == null || note.lineSpacing != base.lineSpacing, + lineSpacing = note.lineSpacing, + ).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() } 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 index d7189cfc..468ec9d1 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -35,7 +35,17 @@ interface NotesRepository { /** The new note's row id. */ suspend fun insertNote(note: Note): Result - suspend fun updateNote(note: Note): Result + + /** + * Saves what an editor owns of [note]: its title and content, and each of tag, 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. 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. */ 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 index adcbefbf..8362cae4 100644 --- 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 @@ -5,9 +5,12 @@ 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. */ +/** + * 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.updateNote(targetNote.copy(isTrash = isTrash, trashedAt = now.takeIf { isTrash })) + repository.setTrashed(targetNote.id, isTrash, now.takeIf { isTrash }) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt index f716cd11..5dbe20a6 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt @@ -155,6 +155,18 @@ val LocalBiometricHost = staticCompositionLocalOf { null } */ val LocalScreenshotAllowance = staticCompositionLocalOf { null } +/** + * Whether the host can restrain the system keyboard's learning (Android: strict keyboard privacy). The desktop has + * no such keyboard and says no, so 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 } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt index 00a7cd37..fdc60d3d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/NoteHostServices.kt @@ -4,6 +4,10 @@ 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. */ @@ -24,8 +28,12 @@ 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. */ - suspend fun print(document: NoteDocument): PrintResult + /** + * 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). */ @@ -49,6 +57,10 @@ val LocalNoteWindows = staticCompositionLocalOf { null } 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) @@ -78,7 +90,10 @@ class NoteEditRegistry { synchronized(lock) { val holder = holders[noteId] ?: return if (holder.window != window) return - if (--holder.count <= 0) holders.remove(noteId) + if (--holder.count <= 0) { + holders.remove(noteId) + _releases.update { it + 1 } + } } } @@ -92,6 +107,11 @@ class NoteEditClaims internal constructor(private val registry: NoteEditRegistry 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. */ 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 index 1b8c1e9e..8827aa75 100644 --- 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 @@ -17,9 +17,11 @@ 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 @@ -98,6 +100,10 @@ class NotePersistence( // 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 @@ -148,6 +154,7 @@ class NotePersistence( 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 @@ -180,30 +187,40 @@ class NotePersistence( * 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) { + 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 } - return@withLock true - } - val currentNote = _state.value.note - if (currentNote.id == -1L && currentNote.title.isBlank() && blocks().none { it.hasContent() }) { - return@withLock true + 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@withLock true + if (onlyIfChanged && snapshot == persistedSnapshot) return true _status.value = SaveStatusNote.SAVING val saved = suspendRunCatching { if (currentNote.id != -1L) { val stored = withContext(ioDispatcher) { - notesRepository.updateStored(currentNote, _state.value.backupNote, blocksJson) + 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) } } + stored?.let { note -> + updateNote { it.copy(date = note.date, description = note.description) } + persistedNote = note + } stored != null } else { val draft = newDraft(currentNote, blocksJson) @@ -216,6 +233,7 @@ class NotePersistence( updateNote { it.copy(id = id, value = blocksJson, date = draft.date, dateCreate = draft.dateCreate) } + persistedNote = draft.copy(id = id) } insertedId != null } @@ -223,9 +241,64 @@ class NotePersistence( 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. @@ -236,8 +309,13 @@ class NotePersistence( val backupNote = _state.value.backupNote val discarded = when { - backupNote.id != -1L -> withContext(ioDispatcher) { notesRepository.updateNote(backupNote) } + // 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 @@ -251,7 +329,8 @@ class NotePersistence( val currentNote = _state.value.note if (!loaded || suspended || currentNote.id == -1L) return@withLock false - val restored = currentNote.copy(value = contentJson(), isTrash = 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) @@ -269,7 +348,9 @@ class NotePersistence( return@withLock true } - val trashed = currentNote.copy(value = contentJson(), isTrash = 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. @@ -366,21 +447,27 @@ private data class NoteMeta( 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 && + tagId == other.tagId && isPinned == other.isPinned && color == other.color && + textSize == other.textSize && lineSpacing == other.lineSpacing && date == other.date + 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 + * 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, backup: Note, blocksJson: String): Note? { +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 { updateNote(it).isSuccess } + return stored.takeIf { updateEdited(it, base).isSuccess } } /** A copy of the blocks holding their current values, to serialize away from the editor. */ 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 index b15049b5..e01021de 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -9,6 +9,7 @@ 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 @@ -26,6 +27,7 @@ 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 @@ -62,7 +64,9 @@ val LocalVaultLocked = staticCompositionLocalOf { false } * 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. + * 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( @@ -72,9 +76,18 @@ fun EnclyApp( 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 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 index 82105f2e..79402f63 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LicensesScreen.kt @@ -20,6 +20,7 @@ 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 @@ -72,8 +73,9 @@ private val bundledLibraries = listOf( 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), + LicenseSection(Res.string.licenses_libraries, bundledLibraries + extra), LicenseSection(Res.string.licenses_fonts, bundledFonts), ) Scaffold( 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 index 2d9887b0..8a83a56d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -89,7 +89,8 @@ fun LockScreen(navController: NavHostController, modifier: 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. A short grace lets this window's own unlock start its reveal first, which this must not cut off. + * 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( @@ -103,7 +104,14 @@ private fun FollowUnlockFromOtherWindows( delay(FOLLOW_UNLOCK_GRACE_MS) val ownUnlock = viewModel.busy.value || reveal?.running == true if (!ownUnlock && !viewModel.isSessionLocked()) { - navController.leaveLockScreen(null, viewModel::isSessionLocked, viewModel::canReopenNote) + if (viewModel.mustResetPin()) { + // The other window unlocked with the recovery phrase: this one needs the new PIN too. + navController.navigate(NavRoutes.PinCodeConfig.name) { + popUpTo(NavRoutes.LockRoute.name) { inclusive = true } + } + } else { + navController.leaveLockScreen(null, viewModel::isSessionLocked, viewModel::canReopenNote) + } } } } 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 index 4106086d..8f13ba67 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -26,6 +26,7 @@ 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 @@ -51,6 +52,7 @@ 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 @@ -189,6 +191,9 @@ fun PinCodeConfigScreen( 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 } @@ -246,6 +251,27 @@ fun PinCodeConfigScreen( } } +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) { 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 index 7cccac8b..63b8a342 100644 --- 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 @@ -2,19 +2,27 @@ 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 and says so, instead of saving over the other window's work. The note's id is only known once a new - * note was saved, so the claim follows it. Without claims (Android) nothing happens. + * 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()) { @@ -22,9 +30,21 @@ internal fun ClaimNoteForEditing(viewModel: EditNoteViewModel = metroViewModel() val state = viewModel.state.collectAsState() val noteId by remember(state) { derivedStateOf { state.value.note.id } } if (viewModel.isReadTrashOnly || noteId <= 0L) return - DisposableEffect(claims, viewModel, noteId) { + 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) - viewModel.setEditedElsewhere(!granted) - onDispose { if (granted) claims.release(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/settings/SecuritySections.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySections.kt index f4f8c855..a640720a 100644 --- 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 @@ -53,6 +53,7 @@ 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 @@ -269,21 +270,26 @@ internal fun PrivacySection( 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 { - 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 (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) { - EnclyGroupDivider() + if (keyboardOffered) EnclyGroupDivider() ScreenshotsRow(screenshots) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt index 56f0e819..b40155a8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/AuthSetupViewModel.kt @@ -61,6 +61,7 @@ class AuthSetupViewModel( fun finishSetup(onResult: (FinishResult) -> Unit) { _restoreFailed.value = false _finishFailed.value = false + val lockRequests = sessionLockManager.lockRequests() viewModelScope.launch { val owner = coroutineContext.job _busy.value = true @@ -77,7 +78,7 @@ class AuthSetupViewModel( false } - else -> sessionLockManager.onUnlocked() + else -> sessionLockManager.onUnlocked(lockRequests) } outcome to published } 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 index 43caecb2..a801a7f0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -70,6 +70,7 @@ 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 @@ -184,6 +185,49 @@ class EditNoteViewModel( 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 @@ -263,6 +307,7 @@ class EditNoteViewModel( // 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()) 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 index e997594d..17bb7771 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -68,6 +68,12 @@ class LockViewModel( /** 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.canResetPinWithoutCurrent() + /** Whether the session is closed (again); an unlock reveal then must not open Home. */ fun isSessionLocked(): Boolean = sessionLockManager.locked.value @@ -151,10 +157,12 @@ class LockViewModel( * ([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).unlock() } + val result = withContext(NonCancellable) { UnlockScope(owner, lockRequests).unlock() } // A cleared screen gets no result: the unlock was already closed again. ensureActive() _busy.value = false @@ -164,7 +172,7 @@ class LockViewModel( } /** What an unlock body can see: whether its ViewModel is still alive. */ - private inner class UnlockScope(private val owner: Job) { + 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]. @@ -177,7 +185,7 @@ class LockViewModel( backgrounded } - sessionLockManager.onUnlocked() -> result + sessionLockManager.onUnlocked(lockRequests) -> result else -> backgrounded } 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 index ebbc2bd3..1a53b93c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -257,7 +257,11 @@ class MarkdownExportViewModel( val document = documentOf(snapshot, what.noteId, labels()) // A re-lock that landed after the read must not be followed by a print. bound.assertCurrentSession() - when (what.renderer.print(document)) { + // 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 } 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 index d75975b0..c211dae6 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SelectedTagHolder.kt @@ -4,30 +4,36 @@ 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. 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. + * 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. */ -@SingleIn(AppScope::class) -@Inject 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 { - // Unconfined: the reset runs inside the lock itself, before any later unlock can publish. - CoroutineScope(SupervisorJob() + Dispatchers.Unconfined).launch { + scope.launch { lockEvents.locked.collect { locked -> if (locked) _selectedTagFlow.tryEmit(Tag()) } } } @@ -35,4 +41,49 @@ class SelectedTagHolder(lockEvents: VaultLockEvents = NeverLocked) { 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/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt index 14e54f64..9e1ef05e 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/platform/NoteHostServicesTest.kt @@ -55,4 +55,27 @@ class NoteHostServicesTest { 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/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 index 30dac6ee..cc26aedb 100644 --- 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 @@ -286,22 +286,134 @@ class NotePersistenceTest { 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(persistence.state.value.note.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(NoteWithTag(Note(id = NOTE_ID, title = "title", value = other), null)) + 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 restoringTakesTheNoteOutOfTheTrashWithTheEditorContent() = runTest(dispatcher) { + 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()) - val restored = repository.updatedNotes.single() - assertFalse(restored.isTrash) - assertEquals(BlockConverter.blocksToJson(blocks), restored.value) + assertFalse(repository.updatedNotes.single().isTrash) + assertNull(repository.updatedNotes.single().trashedAt) } @Test diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt index e406d857..fc1d890b 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt @@ -91,7 +91,10 @@ class ArchiveViewModelTest { val trashed = repository.updatedNotes.single() assertNotNull(trashed.trashedAt, "the time it went to the trash is remembered") - assertEquals(note.copy(isTrash = true, trashedAt = trashed.trashedAt), trashed) + 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 index 6363d388..963217d8 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt @@ -164,6 +164,61 @@ class EditNoteViewModelLifecycleTest { 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(NoteWithTag(note = saved, tag = null)) + 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) 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/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt index b878ea2c..0f3a7759 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -47,12 +47,25 @@ internal class TestNotesRepository : NotesRepository { return if (insertResult > 0L) Result.success(insertResult) else Result.failure(IllegalStateException()) } - /** Every note passed to [updateNote], in call order. */ + /** Every note passed to [updateEdited], and every trash write as the note it was made on, in call order. */ val updatedNotes = mutableListOf() - override suspend fun updateNote(note: Note): Result { + /** 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()) } 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 index 538e41d6..951eef1d 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt @@ -45,18 +45,49 @@ class RepositoryImplTest { @Test fun updatingZeroRowsIsAFailure() = runBlocking { - val note = Note(id = NOTE_ID) - `when`(notesDao.updateNote(note)).thenReturn(0) + `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenReturn(0) - assertTrue(NotesRepositoryImpl(provider).updateNote(note).isFailure) + assertTrue(NotesRepositoryImpl(provider).setTrashed(NOTE_ID, true, TRASHED_AT).isFailure) } @Test fun updatingARowSucceeds() = runBlocking { - val note = Note(id = NOTE_ID) - `when`(notesDao.updateNote(note)).thenReturn(1) + `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenReturn(1) - assertTrue(NotesRepositoryImpl(provider).updateNote(note).isSuccess) + 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) + val edited = base.copy(value = "w", tagId = 3L) + // The title and content go; the tag changed; the pin, colour and sizes are left to whatever the row holds. + `when`( + notesDao.updateEdited( + NOTE_ID, "t", "w", false, "", 1L, true, 3L, false, true, false, "teal", false, null, false, null, + ), + ).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) + `when`( + notesDao.updateEdited( + NOTE_ID, "t", "v", true, "", 1L, true, null, true, false, true, "none", true, null, true, null, + ), + ).thenReturn(1) + + assertTrue(NotesRepositoryImpl(provider).updateEdited(note, null).isSuccess) } @Test @@ -89,11 +120,12 @@ class RepositoryImplTest { @Test fun cancellationIsRethrownNotReportedAsAFailure() { - val note = Note(id = NOTE_ID) val cancellation = CancellationException("cancelled") - runBlocking { `when`(notesDao.updateNote(note)).thenAnswer { throw cancellation } } + runBlocking { `when`(notesDao.updateTrashed(NOTE_ID, true, TRASHED_AT)).thenAnswer { throw cancellation } } - val thrown = runCatching { runBlocking { NotesRepositoryImpl(provider).updateNote(note) } }.exceptionOrNull() + val thrown = runCatching { + runBlocking { NotesRepositoryImpl(provider).setTrashed(NOTE_ID, true, TRASHED_AT) } + }.exceptionOrNull() assertSame(cancellation, thrown) } @@ -138,6 +170,7 @@ class RepositoryImplTest { 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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt index 40998346..1a0c664f 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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.NoteWithTag import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.testutil.TestNotesRepository @@ -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(NoteWithTag(archived, null)) useCase(archived, isTrash = true) useCase(archived.copy(isTrash = true), isTrash = false) 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 index f7230ee9..b10b4cee 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -96,6 +96,30 @@ class LockViewModelTest { 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 theRecoveryPhraseResetIsAskedOfTheSecurityLayer() { + `when`(security.canResetPinWithoutCurrent()).thenReturn(true) + assertTrue(viewModel.mustResetPin()) + + `when`(security.canResetPinWithoutCurrent()).thenReturn(false) + assertFalse(viewModel.mustResetPin()) + } + @Test fun aWrongPinAndABrokenVaultAreToldApart() = runTest { `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) From 9d93b899f545966d638cc3fff157a462e588a089 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 15:50:25 +0200 Subject: [PATCH 50/75] fix(ios): a PIN guess needs the Secure Enclave, local-only clipboard, no keyboard learning Each PIN guess is hashed to a point of P-256 and goes through one key agreement with a key that never leaves the Secure Enclave, so a copy of the Keychain or of the app's memory is not enough to search PINs offline; the simulator uses a software key in its own source set. The clipboard writes only the local, expiring item, text fields ask the keyboard not to learn or correct, the background lock closes the vault before the task ends, and a damaged flags file is moved aside. Refs: #107 --- SECURITY.md | 51 ++++- core/crypto/api/crypto.klib.api | 9 + .../pasich/encly/core/crypto/P256Points.kt | 40 ++++ .../src/iosMain/swift/EnclyAesGcm.swift | 27 ++- .../encly/core/crypto/P256PointsTest.kt | 48 ++++ .../src/nativeInterop/cinterop/enclyaesgcm.h | 4 + .../pasich/encly/core/keystore/SecretStore.kt | 5 +- .../core/keystore/SecureEnclavePinEnclave.kt | 35 +++ .../encly/core/keystore/IosPinFactor.kt | 138 ++++++------ .../core/keystore/KeychainSecretStore.kt | 156 ------------- .../pasich/encly/core/keystore/PinEnclave.kt | 206 +++++++++++++++++ .../pasich/encly/core/keystore/PinPoint.kt | 52 +++++ .../encly/core/keystore/PlatformPinEnclave.kt | 11 + .../core/keystore/SecureEnclaveWrapper.kt | 170 -------------- .../encly/core/keystore/SoftwarePinEnclave.kt | 22 ++ .../core/keystore/SoftwarePinEnclaveTest.kt | 72 ++++++ .../encly/core/keystore/IosPinFactorTest.kt | 210 +++++++++++------- .../core/keystore/KeychainSecretStoreTest.kt | 133 ----------- .../core/keystore/PinFactorGoldenTest.kt | 155 +++++++++++++ iosApp/README.md | 7 + .../com/pasich/encly/platform/HostServices.kt | 5 +- .../kotlin/com/pasich/encly/ios/IosApp.kt | 2 + .../pasich/encly/ios/MainViewController.kt | 3 + .../com/pasich/encly/ios/di/IosModules.kt | 2 +- .../pasich/encly/ios/platform/IosClipboard.kt | 25 +-- .../pasich/encly/ios/platform/IosDocuments.kt | 25 ++- .../encly/ios/security/BackgroundLockGuard.kt | 48 +++- .../encly/ios/security/IosBiometricManager.kt | 13 +- .../encly/ios/security/IosKeyboardPrivacy.kt | 63 ++++++ .../encly/ios/storage/IosKeyValueStore.kt | 26 ++- .../com/pasich/encly/ios/IosActualsTest.kt | 19 +- .../encly/ios/platform/PickedCopiesTest.kt | 60 +++++ .../platform/SensitiveComposeClipboardTest.kt | 58 +++++ .../ios/security/BackgroundLockGuardTest.kt | 100 +++++++++ .../ios/security/IosKeyboardPrivacyTest.kt | 41 ++++ 35 files changed, 1382 insertions(+), 659 deletions(-) create mode 100644 core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/P256Points.kt create mode 100644 core/crypto/src/iosTest/kotlin/com/pasich/encly/core/crypto/P256PointsTest.kt create mode 100644 core/keystore/src/iosArm64Main/kotlin/com/pasich/encly/core/keystore/SecureEnclavePinEnclave.kt delete mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinEnclave.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PinPoint.kt create mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/PlatformPinEnclave.kt delete mode 100644 core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt create mode 100644 core/keystore/src/iosSimulatorArm64Main/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclave.kt create mode 100644 core/keystore/src/iosSimulatorArm64Test/kotlin/com/pasich/encly/core/keystore/SoftwarePinEnclaveTest.kt delete mode 100644 core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt create mode 100644 core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/PinFactorGoldenTest.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacy.kt create mode 100644 shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/PickedCopiesTest.kt create mode 100644 shared/src/iosTest/kotlin/com/pasich/encly/ios/platform/SensitiveComposeClipboardTest.kt create mode 100644 shared/src/iosTest/kotlin/com/pasich/encly/ios/security/BackgroundLockGuardTest.kt create mode 100644 shared/src/iosTest/kotlin/com/pasich/encly/ios/security/IosKeyboardPrivacyTest.kt diff --git a/SECURITY.md b/SECURITY.md index 21b3d862..4702a505 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,9 +37,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 @@ -90,6 +92,41 @@ session password), can obtain it and then guess PINs off the machine against PBK 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 An optional second PIN (Settings → Security → Wipe PIN; set, replace or turn off, each after @@ -587,6 +624,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 diff --git a/core/crypto/api/crypto.klib.api b/core/crypto/api/crypto.klib.api index 1663b139..aaae5b7a 100644 --- a/core/crypto/api/crypto.klib.api +++ b/core/crypto/api/crypto.klib.api @@ -39,6 +39,15 @@ final object com.pasich.encly.core.crypto/Crypto { // com.pasich.encly.core.cryp 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] diff --git a/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/P256Points.kt b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/P256Points.kt new file mode 100644 index 00000000..bde6d31f --- /dev/null +++ b/core/crypto/src/iosMain/kotlin/com/pasich/encly/core/crypto/P256Points.kt @@ -0,0 +1,40 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.crypto + +import com.pasich.encly.core.crypto.swift.encly_p256_point_from_x +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned + +/** + * Decoding of NIST P-256 curve points, for the one caller that needs a point nobody knows the discrete log of + * (the iOS PIN factor). CryptoKit does the work through EnclyAesGcm.swift: the square root and the on-curve + * check are Apple's, not ours. + */ +public object P256Points { + public const val X_BYTES: Int = 32 + public const val POINT_BYTES: Int = 65 + + /** + * The point with abscissa [x] (32 bytes, big endian) as ANSI X9.63 `04 ‖ X ‖ Y`, or null when no point of the + * curve has that x (about half of all values) or [x] is not below the field prime. Of the two points with + * this x, CryptoKit picks one; either gives the same ECDH x coordinate. + */ + public fun fromX(x: ByteArray): ByteArray? { + require(x.size == X_BYTES) { "x must be $X_BYTES bytes" } + val point = ByteArray(POINT_BYTES) + val status = x.usePinned { xPinned -> + 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/swift/EnclyAesGcm.swift b/core/crypto/src/iosMain/swift/EnclyAesGcm.swift index 92926c4c..c718021f 100644 --- a/core/crypto/src/iosMain/swift/EnclyAesGcm.swift +++ b/core/crypto/src/iosMain/swift/EnclyAesGcm.swift @@ -1,9 +1,9 @@ import CryptoKit import Foundation -// AES-256-GCM for Kotlin/Native. CommonCrypto has no public GCM and CryptoKit is Swift-only, so this file -// is the one Swift seam of the module: two C-callable functions (declared in enclyaesgcm.h) over -// CryptoKit's AES.GCM. Everything else is Kotlin. Keys, nonces and data arrive as raw pointers into the +// 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. @@ -75,3 +75,24 @@ public func enclyAesGcmOpen( return -1 } } + +/// Decodes the x coordinate `x` (32 bytes, big endian) into a point of the P-256 curve and writes it as +/// ANSI X9.63 (`04 || X || Y`, 65 bytes) into `out`. Returns 0, or -1 when no curve point has that x (about +/// half of all values) or `x` is not below the field prime. CryptoKit does the square root and the +/// on-curve check; nothing is computed here. +@_cdecl("encly_p256_point_from_x") +public func enclyP256PointFromX( + _ x: UnsafePointer?, _ 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/nativeInterop/cinterop/enclyaesgcm.h b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h index 9a413881..8bd5d7a8 100644 --- a/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h +++ b/core/crypto/src/nativeInterop/cinterop/enclyaesgcm.h @@ -20,4 +20,8 @@ int32_t encly_aes_gcm_open(const uint8_t *key, ptrdiff_t key_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/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt b/core/keystore/src/commonMain/kotlin/com/pasich/encly/core/keystore/SecretStore.kt index d5de1b0e..c92b05fb 100644 --- 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 @@ -3,8 +3,9 @@ package com.pasich.encly.core.keystore import com.pasich.encly.core.security.PinKeySlot /** - * The operating system's secret store, as far as the PIN factors need it ([DesktopPinFactor] on the desktop, the iOS - * factor on iOS): one 32-byte secret per [PinKeySlot], which only the signed-in OS user can read back. + * 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. */ 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/IosPinFactor.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/IosPinFactor.kt index 7ab7c072..90fcc9ed 100644 --- 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 @@ -1,65 +1,63 @@ -@file:OptIn(ExperimentalForeignApi::class) - 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 -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.reinterpret -import kotlinx.cinterop.usePinned -import platform.CoreCrypto.CCHmac -import platform.CoreCrypto.CC_SHA256_DIGEST_LENGTH -import platform.CoreCrypto.kCCHmacAlgSHA256 /** - * [PinHardwareFactor] for iOS. The contract is the one of the Android Keystore and the desktop factors: - * `hw = HMAC-SHA256(deviceSecret, data)`, where the caller passes `data = salt ‖ pin`, so a vault made on one - * platform derives the same PIN key from the same secret. + * [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, the vault file or a dump of this process's memory taken at another time. 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. + * - Not the same bytes as the desktop or Android factor for the same data (the vault is bound to one device anyway). * - * The `deviceSecret` is 32 random bytes from the platform CSPRNG, created once per slot and kept only in the - * Keychain of this device, available while it is unlocked, never synced or restored onto another device. Where - * the Secure Enclave accepts it, the Keychain holds the secret only encrypted to an Enclave key (see - * [KeychainSecretStore]); otherwise (the Simulator, an old device) it holds the secret itself, which is as safe - * as the Keychain and the passcode. The HMAC is CommonCrypto's, in this process: the Enclave only supports EC - * keys, so it protects the secret at rest rather than computing the MAC. + * 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 the desktop: - * - [ensureKey] creates the secret when there is none and returns true then. A secret that is there but cannot - * be read for good counts as existing (like an invalidated Android key): [mac] reports it as lost and [reset] - * replaces it. - * - [mac] with no secret, or one of the wrong size, fails with `PinFactorException(lost = true)`; any other - * failure of the store (locked device, I/O) is `lost = false` and may pass. + * 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. * - * Every copy of the secret this class controls is wiped after use. + * 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 CFData the system returns for the agreement is immutable and is not. */ -public class IosPinFactor internal constructor(private val store: SecretStore) : PinHardwareFactor { - @Suppress("ReturnCount") // Exists, unreadable (also exists), or created. +public class IosPinFactor internal constructor(private val enclave: PinEnclave) : PinHardwareFactor { @Throws(PinFactorException::class) override fun ensureKey(slot: PinKeySlot): Boolean { - val existing = try { - store.read(slot) + try { + if (enclave.exists(slot)) return false + enclave.create(slot) + return true } catch (e: SecretStoreException) { - 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)) + enclave.create(slot) } catch (e: SecretStoreException) { throw PinFactorException(lost = false, cause = e) } @@ -67,53 +65,47 @@ public class IosPinFactor internal constructor(private val store: SecretStore) : @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) + val point = try { + PinPoint.derive(slot, data) + } catch (e: IllegalStateException) { + throw PinFactorException(lost = false, cause = e) + } try { - if (secret.size != SecretStore.SECRET_SIZE) throw PinFactorException(lost = true) - return hmacSha256(secret, data) + 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(secret) + SensitiveDataCleaner.clear(point) } } override fun delete(slot: PinKeySlot) { try { - store.delete(slot) + enclave.delete(slot) } catch (_: SecretStoreException) { // Documented: delete never throws. } } - public companion object { - /** The factor on this device's Keychain. */ - public fun create(): IosPinFactor = IosPinFactor(KeychainSecretStore()) + 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) + } } -} -internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray { - val mac = ByteArray(CC_SHA256_DIGEST_LENGTH) - // An empty array has no first element to point at; CommonCrypto takes a null pointer with a length of 0. - key.usePinnedOrNull { keyPointer -> - data.usePinnedOrNull { dataPointer -> - mac.usePinned { - CCHmac( - kCCHmacAlgSHA256, - keyPointer, - key.size.convert(), - dataPointer, - data.size.convert(), - it.addressOf(0), - ) - } - } + 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 } - return mac } - -private inline fun ByteArray.usePinnedOrNull( - block: (kotlinx.cinterop.CPointer?) -> R, -): R = if (isEmpty()) block(null) else usePinned { block(it.addressOf(0).reinterpret()) } diff --git a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt deleted file mode 100644 index 43f8addc..00000000 --- a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/KeychainSecretStore.kt +++ /dev/null @@ -1,156 +0,0 @@ -@file:OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) - -package com.pasich.encly.core.keystore - -import com.pasich.encly.core.crypto.Crypto -import com.pasich.encly.core.security.PinKeySlot -import com.pasich.encly.core.security.SensitiveDataCleaner -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.CFRelease -import platform.CoreFoundation.CFTypeRefVar -import platform.Security.SecItemAdd -import platform.Security.SecItemCopyMatching -import platform.Security.SecItemDelete -import platform.Security.errSecItemNotFound -import platform.Security.errSecSuccess -import platform.Security.kSecAttrAccessible -import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly -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.kSecReturnData -import platform.Security.kSecValueData - -/** - * [SecretStore] on iOS: one generic-password item per [PinKeySlot] in the app's Keychain (service - * `com.pasich.encly`, account `pin-factor-v1` or `pin-factor-v1-b`), readable only while the device is unlocked - * (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`), so it is not in iCloud Keychain, not in a backup that is - * restored to another device, and not readable in the background of a locked phone. - * - * What the item holds is a format byte and a payload: - * - [FORMAT_KEYCHAIN]: the 32-byte secret itself. This is the only path on the Simulator and on a device - * without a usable Secure Enclave, and the path every test in this repository exercises. - * - [FORMAT_ENCLAVE]: the secret encrypted to a Secure Enclave key by [SecureEnclaveWrapper]; decrypting needs - * the Enclave of this device. Chosen whenever the Enclave accepts the key, and recorded in the item, so a - * read always follows the path that wrote it and a secret never silently moves between them. - * - * Pass `enclave = null` to keep the secret in the Keychain only. - */ -internal class KeychainSecretStore( - private val service: String = APPLICATION_ID, - private val enclave: SecureEnclaveWrapper? = SecureEnclaveWrapper(service), -) : SecretStore { - @Throws(SecretStoreException::class) - override fun read(slot: PinKeySlot): ByteArray? { - val record = copyRecord(slot) ?: return null - try { - if (record.isEmpty()) throw SecretStoreException(lost = true, message = "empty Keychain item") - val payload = record.copyOfRange(1, record.size) - try { - return when (record[0]) { - FORMAT_KEYCHAIN -> payload.copyOf() - - FORMAT_ENCLAVE -> enclave?.unwrap(slot, payload) - ?: throw SecretStoreException(lost = true, message = "no Secure Enclave for a wrapped secret") - - else -> throw SecretStoreException(lost = true, message = "unknown Keychain item format") - } - } finally { - SensitiveDataCleaner.clear(payload) - } - } finally { - SensitiveDataCleaner.clear(record) - } - } - - @Throws(SecretStoreException::class) - override fun create(slot: PinKeySlot): ByteArray { - val secret = Crypto.randomBytes(SecretStore.SECRET_SIZE) - try { - val wrapped = enclave?.wrap(slot, secret) - val record = if (wrapped != null) { - byteArrayOf(FORMAT_ENCLAVE) + wrapped - } else { - byteArrayOf(FORMAT_KEYCHAIN) + secret - } - try { - writeRecord(slot, record) - } finally { - SensitiveDataCleaner.clear(record) - } - return secret - } catch (e: SecretStoreException) { - SensitiveDataCleaner.clear(secret) - throw e - } - } - - @Throws(SecretStoreException::class) - override fun delete(slot: PinKeySlot) { - enclave?.deleteKey(slot) - deleteItem(slot) - } - - private fun deleteItem(slot: PinKeySlot) { - val status = withCfDictionary { query -> SecItemDelete(itemQuery(query, slot).ref) } - if (status != errSecSuccess && status != errSecItemNotFound) { - throw SecretStoreException(lost = false, message = "Keychain delete: $status") - } - } - - private fun copyRecord(slot: PinKeySlot): ByteArray? = withCfDictionary { query -> - itemQuery(query, slot).putBoolean(kSecReturnData, true).put(kSecMatchLimit, kSecMatchLimitOne) - memScoped { - val result = alloc() - when (val status = SecItemCopyMatching(query.ref, result.ptr)) { - errSecSuccess -> { - val data: CFDataRef = checkNotNull(result.value).reinterpret() - cfDataToBytes(data).also { CFRelease(data) } - } - - errSecItemNotFound -> null - - else -> throw SecretStoreException(lost = false, message = "Keychain read: $status") - } - } - } - - private fun writeRecord(slot: PinKeySlot, record: ByteArray) { - // Replace, not update: an item left by an earlier secret must not survive a failed write. - deleteItem(slot) - val status = withCfDictionary { item -> - itemQuery(item, slot) - .put(kSecAttrAccessible, kSecAttrAccessibleWhenUnlockedThisDeviceOnly) - .putBytes(kSecValueData, record) - SecItemAdd(item.ref, null) - } - if (status != errSecSuccess) throw SecretStoreException(lost = false, message = "Keychain write: $status") - } - - private fun itemQuery(query: CfDictionary, slot: PinKeySlot): CfDictionary = query - .put(kSecClass, kSecClassGenericPassword) - .putString(kSecAttrService, service) - .putString(kSecAttrAccount, slot.entryName()) - .putBoolean(kSecAttrSynchronizable, false) - - /** Which path wrote the item of [slot] (a `FORMAT_` value), or null when there is none; for tests. */ - fun recordFormat(slot: PinKeySlot): Byte? = copyRecord(slot)?.let { record -> - record.firstOrNull().also { SensitiveDataCleaner.clear(record) } - } - - internal companion object { - const val FORMAT_KEYCHAIN: Byte = 1 - const val FORMAT_ENCLAVE: Byte = 2 - } -} 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/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt b/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt deleted file mode 100644 index 33589b5c..00000000 --- a/core/keystore/src/iosMain/kotlin/com/pasich/encly/core/keystore/SecureEnclaveWrapper.kt +++ /dev/null @@ -1,170 +0,0 @@ -@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.SecAccessControlCreateWithFlags -import platform.Security.SecItemCopyMatching -import platform.Security.SecItemDelete -import platform.Security.SecKeyCopyPublicKey -import platform.Security.SecKeyCreateDecryptedData -import platform.Security.SecKeyCreateEncryptedData -import platform.Security.SecKeyCreateRandomKey -import platform.Security.SecKeyRef -import platform.Security.errSecItemNotFound -import platform.Security.errSecSuccess -import platform.Security.kSecAccessControlPrivateKeyUsage -import platform.Security.kSecAttrAccessControl -import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly -import platform.Security.kSecAttrApplicationTag -import platform.Security.kSecAttrIsPermanent -import platform.Security.kSecAttrKeySizeInBits -import platform.Security.kSecAttrKeyType -import platform.Security.kSecAttrKeyTypeECSECPrimeRandom -import platform.Security.kSecAttrTokenID -import platform.Security.kSecAttrTokenIDSecureEnclave -import platform.Security.kSecClass -import platform.Security.kSecClassKey -import platform.Security.kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM -import platform.Security.kSecMatchLimit -import platform.Security.kSecMatchLimitOne -import platform.Security.kSecPrivateKeyAttrs -import platform.Security.kSecReturnRef - -/** - * Wraps the device secret of a [PinKeySlot] with a key that never leaves the Secure Enclave: an EC P-256 private - * key created inside it (`kSecAttrTokenIDSecureEnclave`, usable only while the device is unlocked, never - * synchronized or backed up), and ECIES (`eciesEncryptionCofactorX963SHA256AESGCM`) for the secret itself. The - * public half encrypts without the hardware; only decrypting asks the Enclave. The wrapped blob is what - * [KeychainSecretStore] keeps in the Keychain, so a copy of the Keychain alone no longer holds the secret. - * - * The iOS Simulator has no Secure Enclave and some devices (and a locked one) cannot use it: [wrap] then returns - * null and the store keeps the secret in the Keychain only. Nothing here runs on the Simulator past that first - * check, so the Enclave path itself is exercised only on a device. - */ -internal class SecureEnclaveWrapper(private val service: String) { - /** - * [secret] encrypted to a new Enclave key for [slot] (replacing the key it had), or null when the Secure - * Enclave cannot be used. The blob is not secret. - */ - fun wrap(slot: PinKeySlot, secret: ByteArray): ByteArray? { - deleteKey(slot) - val privateKey = createKey(slot) ?: return null - val blob = try { - encrypt(privateKey, secret) - } finally { - CFRelease(privateKey) - } - if (blob == null) deleteKey(slot) - return blob - } - - /** ECIES with the public half of [privateKey], which needs no Enclave call. */ - private fun encrypt(privateKey: SecKeyRef, secret: ByteArray): ByteArray? { - val publicKey = SecKeyCopyPublicKey(privateKey) ?: return null - val plain = newSecretData(secret) - val sealed = SecKeyCreateEncryptedData(publicKey, ALGORITHM, plain, null) - zeroizeAndRelease(plain) - CFRelease(publicKey) - return sealed?.let { cfDataToBytes(it).also { _ -> CFRelease(it) } } - } - - /** - * The secret [wrap] sealed. A missing Enclave key, or a blob it refuses, means the secret is gone for good - * (`lost`); anything else (a locked device, a cancelled prompt) may pass. - */ - @Throws(SecretStoreException::class) - fun unwrap(slot: PinKeySlot, blob: ByteArray): ByteArray { - val privateKey = - findKey(slot) ?: throw SecretStoreException(lost = true, message = "Secure Enclave key is gone") - try { - val cipher = newSecretData(blob) - try { - memScoped { - val error = alloc() - val plain = SecKeyCreateDecryptedData(privateKey, ALGORITHM, cipher, error.ptr) - if (plain == null) { - val code = error.value?.let { CFErrorGetCode(it).toInt().also { _ -> CFRelease(error.value) } } - throw SecretStoreException(lost = code in LOST_CODES, message = "Secure Enclave decrypt: $code") - } - return cfDataToBytes(plain).also { CFRelease(plain) } - } - } finally { - zeroizeAndRelease(cipher) - } - } finally { - CFRelease(privateKey) - } - } - - /** Removes the Enclave key of [slot]; none is not an error. */ - fun deleteKey(slot: PinKeySlot) { - withCfDictionary { query -> - SecItemDelete( - query.put(kSecClass, kSecClassKey).putBytes(kSecAttrApplicationTag, tag(slot)).ref, - ) - } - } - - private fun createKey(slot: PinKeySlot): SecKeyRef? { - val accessControl = SecAccessControlCreateWithFlags( - null, - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - kSecAccessControlPrivateKeyUsage, - null, - ) ?: return null - val privateAttributes = CfDictionary() - val attributes = CfDictionary() - try { - privateAttributes.putBoolean(kSecAttrIsPermanent, true) - .putBytes(kSecAttrApplicationTag, tag(slot)) - .put(kSecAttrAccessControl, accessControl) - attributes.put(kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom) - .putInt(kSecAttrKeySizeInBits, KEY_BITS) - .put(kSecAttrTokenID, kSecAttrTokenIDSecureEnclave) - .put(kSecPrivateKeyAttrs, privateAttributes.ref) - return SecKeyCreateRandomKey(attributes.ref, null) - } finally { - attributes.release() - privateAttributes.release() - CFRelease(accessControl) - } - } - - 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() - val status = SecItemCopyMatching(query.ref, result.ptr) - when (status) { - errSecSuccess -> result.value as SecKeyRef? - errSecItemNotFound -> null - else -> throw SecretStoreException(lost = false, message = "Secure Enclave key lookup: $status") - } - } - } - - private fun tag(slot: PinKeySlot): ByteArray = "$service.enclave.${slot.entryName()}".encodeToByteArray() - - private companion object { - val ALGORITHM = kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM - const val KEY_BITS = 256 - - // errSecDecode (-26275) and errSecParam (-50): the blob does not belong to the key. - val LOST_CODES = setOf(-26275, -50) - } -} 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 index 90d7bb08..7fd7d4b6 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -8,93 +9,94 @@ 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 iOS factor over an in-memory store, so the states are tested without a Keychain. The HMAC golden values are - * the ones DesktopPinFactorTest pins, computed with the JCA: the same secret and data give the same bytes on iOS. + * 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 store = InMemorySecretStore() - private val factor = IosPinFactor(store) + 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 macIsHmacSha256OfTheDataUnderTheStoredSecretGolden() { - store.put(PinKeySlot.A, ByteArray(32) { it.toByte() }) + fun macIsDeterministicAndDependsOnTheData() { + factor.ensureKey(PinKeySlot.A) - val hw = factor.mac(PinKeySlot.A, data) + val first = factor.mac(PinKeySlot.A, data) - assertEquals(32, hw.size) - assertEquals("25b11968d03777c4e2519917fd8757ebaed13eeb6a9378e58e9fab63c4962f36", hw.toHex()) + 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 aSecondGoldenVectorWithAnotherSecret() { - store.put(PinKeySlot.B, ByteArray(32) { 0xFF.toByte() }) + fun everyGuessIsAnAgreementOfTheEnclaveOnAPointOfItsOwn() { + factor.ensureKey(PinKeySlot.A) - assertEquals( - "412acd40567e42ba035be3fd49c094c6cc015c5a9921e633e52bf0235e0f0755", - factor.mac(PinKeySlot.B, data).toHex(), - ) - } + factor.mac(PinKeySlot.A, data) + factor.mac(PinKeySlot.A, data) + factor.mac(PinKeySlot.A, salt + "000000".encodeToByteArray()) - @Test - fun hmacHandlesEmptyKeyAndData() { - // RFC 4231 has no empty vector; HMAC-SHA256 of nothing under an empty key is a known constant. - assertEquals( - "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad", - hmacSha256(ByteArray(0), ByteArray(0)).toHex(), - ) + 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 macIsDeterministicAndDependsOnTheData() { + fun theSameDataOnTheOtherSlotIsAnotherPointAndAnotherFactor() { factor.ensureKey(PinKeySlot.A) + factor.ensureKey(PinKeySlot.B) - val first = factor.mac(PinKeySlot.A, data) - assertContentEquals(first, factor.mac(PinKeySlot.A, data)) - assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, salt + "482914".encodeToByteArray()))) + 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 firstUseCreatesA32ByteSecretAndReportsIt() { + fun firstUseCreatesTheKeyAndReportsIt() { assertTrue(factor.ensureKey(PinKeySlot.A)) - assertEquals(32, store.peek(PinKeySlot.A)!!.size) + assertTrue(enclave.has(PinKeySlot.A)) assertFalse(factor.ensureKey(PinKeySlot.A)) - assertNull(store.peek(PinKeySlot.B)) + assertFalse(enclave.has(PinKeySlot.B)) + assertEquals(1, enclave.created) } @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() { + 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 macWithoutASecretIsLost() { + 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 macAfterDeleteIsLostAndEnsureKeyMakesANewSecret() { + fun macAfterDeleteIsLostAndEnsureKeyMakesANewKey() { factor.ensureKey(PinKeySlot.A) val before = factor.mac(PinKeySlot.A, data) @@ -106,88 +108,80 @@ class IosPinFactorTest { } @Test - fun aSecretOfTheWrongSizeIsLostForMacAndKeptByEnsureKey() { - store.put(PinKeySlot.A, ByteArray(7)) + fun aKeyTheEnclaveCallsGoneIsLost() { + factor.ensureKey(PinKeySlot.A) + enclave.agreeFailure = SecretStoreException(lost = true, message = "gone") 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() { + fun anEnclaveThatIsBusyOrLockedIsNotLost() { factor.ensureKey(PinKeySlot.A) - val before = store.peek(PinKeySlot.A)!!.copyOf() - store.readFailure = SecretStoreException(lost = true, message = "does not decrypt") + val locked = SecretStoreException(lost = false, message = "locked") + enclave.agreeFailure = locked - assertTrue(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) - assertFalse(factor.ensureKey(PinKeySlot.A)) - assertContentEquals(before, store.peek(PinKeySlot.A)) + val failure = assertFailsWith { factor.mac(PinKeySlot.A, data) } - factor.reset(PinKeySlot.A) - store.readFailure = null - assertFalse(before.contentEquals(store.peek(PinKeySlot.A))) + assertFalse(failure.lost) + assertEquals(locked, failure.cause) + enclave.agreeFailure = null factor.mac(PinKeySlot.A, data) } @Test - fun anUnavailableStoreIsNotLost() { + fun anAgreementOfTheWrongSizeIsNotLostAndIsWiped() { factor.ensureKey(PinKeySlot.A) - val unavailable = SecretStoreException(lost = false, message = "locked") - store.readFailure = unavailable + enclave.agreementSize = 31 assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, data) }.lost) - assertFalse(assertFailsWith { factor.ensureKey(PinKeySlot.A) }.lost) - assertEquals(unavailable, assertFailsWith { factor.ensureKey(PinKeySlot.A) }.cause) + + assertTrue(enclave.handedOut.all { copy -> copy.all { it == 0.toByte() } }) } @Test - fun resetFailureIsNotLost() { - store.createFailure = SecretStoreException(lost = false, message = "full") + 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() { - store.deleteFailure = SecretStoreException(lost = false, message = "locked") + enclave.deleteFailure = SecretStoreException(lost = false, message = "locked") factor.delete(PinKeySlot.A) } @Test - fun macWipesItsCopyOfTheSecretAndLeavesTheDataAlone() { - store.put(PinKeySlot.A, ByteArray(32) { it.toByte() }) + fun macWipesThePointAndTheAgreementAndLeavesTheDataAlone() { + factor.ensureKey(PinKeySlot.A) val input = data.copyOf() factor.mac(PinKeySlot.A, input) - assertTrue(store.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + 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 ensureKeyAndResetWipeTheSecretsTheyHandled() { - factor.ensureKey(PinKeySlot.A) + fun theFailurePathsWipeToo() { 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 }) + enclave.agreeFailure = SecretStoreException(lost = false, message = "locked") assertFailsWith { factor.mac(PinKeySlot.A, data) } - assertTrue(store.handedOut.all { copy -> copy.all { it == 0.toByte() } }) + assertTrue(enclave.peerArrays.all { it.all { b -> b == 0.toByte() } }) } @Test @@ -197,9 +191,57 @@ class IosPinFactorTest { } @Test - fun createBuildsTheFactorOnTheKeychainWithoutTouchingIt() { + 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/KeychainSecretStoreTest.kt b/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt deleted file mode 100644 index 0edac458..00000000 --- a/core/keystore/src/iosTest/kotlin/com/pasich/encly/core/keystore/KeychainSecretStoreTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.pasich.encly.core.keystore - -import com.pasich.encly.core.security.PinFactorException -import com.pasich.encly.core.security.PinKeySlot -import platform.Foundation.NSProcessInfo -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.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * The real Keychain of the iOS Simulator (an item under a service name of its own, removed afterwards). The - * Simulator has no Secure Enclave, so these tests run the Keychain-only path of [KeychainSecretStore]; the - * Enclave-wrapped path (`FORMAT_ENCLAVE`) is compiled but can only be exercised on a device, and is not. - */ -class KeychainSecretStoreTest { - private val service = "com.pasich.encly.test." + NSUUID().UUIDString - private val keychainOnly = KeychainSecretStore(service, enclave = null) - private val defaultStore = KeychainSecretStore(service) - - /** - * Whether the Keychain answers at all. A plain test executable has no application identifier, and the Simulator - * then refuses every SecItem call with errSecNotAvailable (-25291); such a run cannot touch the Keychain, so the - * tests that need one 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. - */ - private val keychainAvailable: Boolean = try { - keychainOnly.read(PinKeySlot.A) - true - } catch (_: SecretStoreException) { - false - } - - @AfterTest - fun cleanUp() { - if (keychainAvailable) PinKeySlot.entries.forEach { keychainOnly.delete(it) } - } - - @Test - fun withoutAKeychainEveryCallIsAStoreFailureThatIsNotLost() { - if (keychainAvailable) return - - assertFalse(assertFailsWith { keychainOnly.read(PinKeySlot.A) }.lost) - assertFalse(assertFailsWith { keychainOnly.create(PinKeySlot.A) }.lost) - assertFalse(assertFailsWith { keychainOnly.delete(PinKeySlot.A) }.lost) - val factor = IosPinFactor(keychainOnly) - assertFalse(assertFailsWith { factor.mac(PinKeySlot.A, ByteArray(4)) }.lost) - } - - @Test - fun aMissingItemReadsAsNull() { - if (!keychainAvailable) return - - assertNull(keychainOnly.read(PinKeySlot.A)) - } - - @Test - fun createStoresASecretThatReadReturns() { - if (!keychainAvailable) return - - val created = keychainOnly.create(PinKeySlot.A) - - assertEquals(SecretStore.SECRET_SIZE, created.size) - assertContentEquals(created, keychainOnly.read(PinKeySlot.A)) - assertNull(keychainOnly.read(PinKeySlot.B)) - } - - @Test - fun createReplacesAnEarlierSecretAndDeleteRemovesIt() { - if (!keychainAvailable) return - - val first = keychainOnly.create(PinKeySlot.A) - val second = keychainOnly.create(PinKeySlot.A) - - assertFalse(first.contentEquals(second)) - assertContentEquals(second, keychainOnly.read(PinKeySlot.A)) - - keychainOnly.delete(PinKeySlot.A) - assertNull(keychainOnly.read(PinKeySlot.A)) - keychainOnly.delete(PinKeySlot.A) - } - - @Test - fun theSlotsHoldSeparateSecrets() { - if (!keychainAvailable) return - - val a = keychainOnly.create(PinKeySlot.A) - val b = keychainOnly.create(PinKeySlot.B) - - assertFalse(a.contentEquals(b)) - assertContentEquals(a, keychainOnly.read(PinKeySlot.A)) - assertContentEquals(b, keychainOnly.read(PinKeySlot.B)) - } - - @Test - fun theDefaultStoreFallsBackToTheKeychainWhereTheEnclaveIsMissing() { - if (!keychainAvailable) return - - val created = defaultStore.create(PinKeySlot.A) - - val format = assertNotNull(defaultStore.recordFormat(PinKeySlot.A)) - val simulator = NSProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != null - // The Simulator has no Secure Enclave, so the secret is in the Keychain as it is. - if (simulator) assertEquals(KeychainSecretStore.FORMAT_KEYCHAIN, format) - assertContentEquals(created, defaultStore.read(PinKeySlot.A)) - } - - @Test - fun theFactorOnTheRealKeychainFollowsTheDesktopContract() { - if (!keychainAvailable) return - - val factor = IosPinFactor(keychainOnly) - val data = ByteArray(22) { it.toByte() } - - 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/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/iosApp/README.md b/iosApp/README.md index c105c056..49a6744d 100644 --- a/iosApp/README.md +++ b/iosApp/README.md @@ -32,6 +32,13 @@ An unsigned Simulator build has no application identifier, so the Keychain refus 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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt index 5dbe20a6..f8d05391 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/platform/HostServices.kt @@ -156,8 +156,9 @@ val LocalBiometricHost = staticCompositionLocalOf { null } val LocalScreenshotAllowance = staticCompositionLocalOf { null } /** - * Whether the host can restrain the system keyboard's learning (Android: strict keyboard privacy). The desktop has - * no such keyboard and says no, so Settings does not show a switch that would do nothing. + * 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 } diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt index 4a9a9c0c..4ee00535 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt @@ -33,6 +33,7 @@ import com.pasich.encly.platform.LocalBiometricHost import com.pasich.encly.platform.LocalDocumentPickers import com.pasich.encly.platform.LocalExternalLinks import com.pasich.encly.platform.LocalSensitiveClipboard +import com.pasich.encly.platform.LocalKeyboardPrivacyOffered import com.pasich.encly.platform.LocalToaster import com.pasich.encly.platform.ScreenBiometricHost import com.pasich.encly.presentation.navigation.EnclyApp @@ -107,6 +108,7 @@ internal fun IosHostServices(graph: IosAppGraph, appWindow: AppWindow, content: 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 index a9847a0e..737f66c1 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/MainViewController.kt @@ -9,6 +9,7 @@ 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 @@ -63,6 +64,8 @@ internal object IosHost { 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. 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 index 54f62413..0286bb23 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/di/IosModules.kt @@ -77,7 +77,7 @@ internal object DataFiles { * * | Android (`:app`) | iOS | * |-------------------------------------------|-------------------------------------------------------| - * | KeystorePinFactor | `environment.pinFactor` (`IosPinFactor`, the Keychain)| + * | KeystorePinFactor | `environment.pinFactor` (`IosPinFactor`, Enclave) | * | KeystoreBiometricManager | [IosBiometricManager] (Keychain + biometryCurrentSet) | * | HandlerMainThreadTimer | [IosMainThreadTimer] | * | SystemLockoutClock | [IosLockoutClock] (continuous clock, boot session) | 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 index b8abbd5b..6f034231 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosClipboard.kt @@ -5,6 +5,7 @@ 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 @@ -48,15 +49,6 @@ internal class IosClipboardPolicy(private val timer: MainThreadTimer) { arm(board) } - /** - * Something copied by the system's own text editing (a field's copy or cut) is on the pasteboard now: it gets - * the same policy. Items that are not text are left to the system. - */ - fun adoptCurrent() { - val text = UIPasteboard.generalPasteboard.string ?: return - copy(text) - } - /** Empties the pasteboard now, whatever it holds. */ fun clearNow() { cancel?.invoke() @@ -104,17 +96,24 @@ internal class IosSensitiveClipboard(private val policy: IosClipboardPolicy) : S /** * 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 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. */ -private class SensitiveComposeClipboard(private val delegate: Clipboard, private val policy: IosClipboardPolicy) : +@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?) { - delegate.setClipEntry(clipEntry) - if (clipEntry == null) policy.clearNow() else policy.adoptCurrent() + if (clipEntry == null) { + policy.clearNow() + } else if (clipEntry.hasPlainText()) { + clipEntry.getPlainText()?.let(policy::copy) + } } } 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 index fd2b9bcf..58d386f2 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/platform/IosDocuments.kt @@ -123,13 +123,30 @@ internal class PickedCopies(private val directory: Path) { return folder } - /** Moves [source] (a file the system copied for the app) into [folder]; the new path. */ + /** + * 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 name = source.lastPathComponent ?: return null + 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() - val moved = NSFileManager.defaultManager.moveItemAtPath(source.path ?: return null, target, null) - return if (moved) target else source.path + 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() { 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 index 2833ff52..d685e50a 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/BackgroundLockGuard.kt @@ -18,22 +18,41 @@ import platform.UIKit.UIBackgroundTaskInvalid * 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) : - DefaultLifecycleObserver { +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. */ + /** + * 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 - task = UIApplication.sharedApplication.beginBackgroundTaskWithName(TASK_NAME) { - // Out of time: close the vault now, while the app still runs, then give the task back. + val granted = tasks.begin(TASK_NAME, ::onExpired) + if (granted == UIBackgroundTaskInvalid) { lockNow() - end() + 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. */ @@ -41,10 +60,25 @@ internal class BackgroundLockGuard(private val isVaultOpen: () -> Boolean, priva val running = task if (running == UIBackgroundTaskInvalid) return task = UIBackgroundTaskInvalid - UIApplication.sharedApplication.endBackgroundTask(running) + 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/IosBiometricManager.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt index 98a8ff9c..cf7adcec 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/security/IosBiometricManager.kt @@ -137,10 +137,15 @@ internal class IosBiometricManager( } } + /** 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. - if (keychain.exists()) keychain.delete() + // 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() @@ -152,6 +157,8 @@ internal class IosBiometricManager( 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) @@ -160,6 +167,7 @@ internal class IosBiometricManager( if (!kept) { SensitiveDataCleaner.clear(dekCopy) disable() + enrolling = false onResult(false) return } @@ -169,6 +177,7 @@ internal class IosBiometricManager( wrapKey?.let(SensitiveDataCleaner::clear) SensitiveDataCleaner.clear(dekCopy) if (!stored) disable() + enrolling = false onResult(stored) } } 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/storage/IosKeyValueStore.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt index 62cf73ec..3002cde3 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/storage/IosKeyValueStore.kt @@ -12,6 +12,8 @@ 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 @@ -23,14 +25,18 @@ import kotlinx.serialization.json.Json * 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 or parsed 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. + * 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)) + constructor(path: String) : this(IosVaultFileIo(path), { moveToDamaged(path) }) private val stateLock = SynchronizedObject() private val writeLock = SynchronizedObject() @@ -85,8 +91,9 @@ internal class IosKeyValueStore( } catch (_: IOException) { null } catch (_: IllegalArgumentException) { - // Damaged JSON. - null + // 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 } } @@ -113,6 +120,15 @@ internal class IosKeyValueStore( } 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/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt index 1c361e29..9df7b82e 100644 --- a/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt +++ b/shared/src/iosTest/kotlin/com/pasich/encly/ios/IosActualsTest.kt @@ -121,12 +121,29 @@ class IosActualsTest { } @Test - fun aDamagedFlagsFileFailsClosedAndIsNeverOverwritten() { + 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") } 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") + } +} From bbbfe4aeb263e6c0be3c1af484cf45bbbae8625a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 15:50:25 +0200 Subject: [PATCH 51/75] fix(keystore): read the Secret Service key exchange answer as dbus-java gives it The service's public key arrives as a list of bytes inside the variant, which the encrypted session refused, so no secret could be stored with GNOME Keyring. The error now also names the type it got. --- .../core/keystore/SecretServiceSession.kt | 18 +++++++++++++----- .../core/keystore/SecretServiceSessionTest.kt | 1 + 2 files changed, 14 insertions(+), 5 deletions(-) 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 index d4d5d187..9358f742 100644 --- 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 @@ -109,11 +109,19 @@ internal class SecretServiceCipher(private val key: ByteArray) : AutoCloseable { * 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 = when (val value = output.value) { - is ByteArray -> value - is Array<*> -> value.filterIsInstance().toByteArray().takeIf { it.size == value.size } - else -> null -} ?: throw GeneralSecurityException("The Secret Service did not answer the key exchange") +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) 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 index 2c341d02..646488e4 100644 --- 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 @@ -112,6 +112,7 @@ class SecretServiceSessionTest { 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")) } } From 9a971591b97815fa0b33a3603a45536e54c7d0f0 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 16:31:13 +0200 Subject: [PATCH 52/75] feat(editor): select text across blocks, block selection, checklist lock button, indented lists A selection can now run from one block into others, like in a document: drag with the mouse or the handles, Shift with a click or the arrow keys, or Select all twice. Typing, pasting or deleting over it joins what is left of the first and last block, as one undo step, and copy goes through the protected clipboard. Whole blocks can also be picked from their gutter to copy, cut or delete. A checklist shows a lock button while you work in it, and lists are indented under their heading. Refs: #83 --- .../commonMain/strings/values-ar/strings.xml | 8 + .../commonMain/strings/values-de/strings.xml | 8 + .../commonMain/strings/values-es/strings.xml | 8 + .../commonMain/strings/values-fr/strings.xml | 8 + .../commonMain/strings/values-it/strings.xml | 8 + .../commonMain/strings/values-nl/strings.xml | 8 + .../commonMain/strings/values-pl/strings.xml | 8 + .../commonMain/strings/values-pt/strings.xml | 8 + .../commonMain/strings/values-uk/strings.xml | 8 + .../src/commonMain/strings/values/strings.xml | 8 + editor/api/android/editor.api | 130 +++- editor/api/desktop/editor.api | 130 +++- editor/api/editor.klib.api | 151 ++++- .../pasich/encly/editor/EditorServicesTest.kt | 5 +- .../encly/editor/ui/BlockSelectionUiTest.kt | 432 +++++++++++++ .../pasich/encly/editor/ui/TextRangeUiTest.kt | 591 ++++++++++++++++++ .../ui/blocks/SelectAllConnectionTest.kt | 131 ++++ .../editor/ui/blocks/SelectAllIme.android.kt | 51 ++ .../com/pasich/encly/editor/EditorStrings.kt | 15 + .../encly/editor/state/BlockEditorState.kt | 18 + .../encly/editor/state/BlockSelection.kt | 4 + .../encly/editor/state/SelectedBlocks.kt | 108 ++++ .../encly/editor/state/SelectedBlocksEdits.kt | 62 ++ .../encly/editor/state/TextRangeSelection.kt | 429 +++++++++++++ .../pasich/encly/editor/theme/EditorIcons.kt | 34 + .../pasich/encly/editor/theme/NoteSpacing.kt | 14 +- .../pasich/encly/editor/ui/BlockActions.kt | 22 + .../encly/editor/ui/BlockActionsImpl.kt | 13 + .../com/pasich/encly/editor/ui/BlockChrome.kt | 180 ++++++ .../encly/editor/ui/EditorBlocksHost.kt | 26 +- .../encly/editor/ui/EditorController.kt | 24 + .../pasich/encly/editor/ui/FindHighlights.kt | 42 ++ .../encly/editor/ui/SelectedBlocksActions.kt | 19 + .../encly/editor/ui/SelectedBlocksBar.kt | 161 +++++ .../pasich/encly/editor/ui/SelectionKey.kt | 50 ++ .../encly/editor/ui/TextRangeOverlay.kt | 141 +++++ .../encly/editor/ui/blocks/BlockInput.kt | 38 +- .../encly/editor/ui/blocks/FieldHighlight.kt | 126 +++- .../pasich/encly/editor/ui/blocks/HBlock.kt | 3 +- .../encly/editor/ui/blocks/ListBlock.kt | 54 +- .../encly/editor/ui/blocks/QuoteBlock.kt | 3 +- .../encly/editor/ui/blocks/RangeInput.kt | 131 ++++ .../encly/editor/ui/blocks/SelectAllIme.kt | 18 + .../encly/editor/ui/blocks/SelectAllKey.kt | 32 + .../encly/editor/ui/blocks/TextBlock.kt | 3 +- .../encly/editor/ui/blocks/TextBlockKeys.kt | 31 +- .../pasich/encly/editor/ui/blocks/TextSync.kt | 1 + .../editor/state/SelectedBlocksEditsTest.kt | 200 ++++++ .../encly/editor/state/SelectedBlocksTest.kt | 212 +++++++ .../encly/editor/state/TextRangeListsTest.kt | 421 +++++++++++++ .../editor/state/TextRangeSelectionTest.kt | 356 +++++++++++ .../encly/editor/theme/NoteSpacingTest.kt | 20 + .../encly/editor/ui/BlockActionsImplTest.kt | 24 + .../pasich/encly/editor/ui/RangeKeysTest.kt | 37 ++ .../encly/editor/ui/SelectionKeysTest.kt | 111 ++++ .../pasich/encly/editor/ui/StateController.kt | 51 +- .../editor/ui/blocks/SelectAllIme.desktop.kt | 6 + .../ui/blocks/SelectAllImeDesktopTest.kt | 21 + .../editor/ui/blocks/SelectAllIme.ios.kt | 6 + .../screen/editnote/EditorBindingsTest.kt | 8 + .../encly/ui/screens/EditNoteScreenTest.kt | 10 +- .../dialogs/EditNoteBottomSheet.kt | 30 +- .../screen/editnote/EditNoteScreen.kt | 21 +- .../editnote/EditNoteViewModelController.kt | 36 +- .../screen/editnote/EditorBindings.kt | 23 +- .../screen/editnote/EditorSelection.kt | 134 ++++ .../screen/editnote/EditorShortcutCommands.kt | 12 +- .../viewmodel/EditNoteViewModel.kt | 39 ++ .../EditNoteViewModelSelectionTest.kt | 288 +++++++++ 69 files changed, 5484 insertions(+), 85 deletions(-) create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/BlockSelectionUiTest.kt create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TextRangeUiTest.kt create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllConnectionTest.kt create mode 100644 editor/src/androidMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.android.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocks.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksActions.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectedBlocksBar.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/SelectionKey.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/TextRangeOverlay.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/RangeInput.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllKey.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksEditsTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SelectedBlocksTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeListsTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeSelectionTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/RangeKeysTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/SelectionKeysTest.kt create mode 100644 editor/src/desktopMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.desktop.kt create mode 100644 editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllImeDesktopTest.kt create mode 100644 editor/src/iosMain/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllIme.ios.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSelection.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index e93417de..92d80924 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -690,4 +690,12 @@ التغييرات في هذا الإصدار فهمت تُحذف الملاحظات نهائيًا بعد 28 يومًا في سلة المحذوفات. + تحديد الكتلة + تحديد الكل + إلغاء التحديد + تم النسخ + نسخ + الكتل المحددة: %1$d + قص + حذف الكتل المحددة diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index bf495ca3..a7444264 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -52,6 +52,14 @@ Liste Checkliste sperren Checkliste entsperren + Block auswählen + 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index 0010ced3..53837bd4 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -52,6 +52,14 @@ Lista Bloquear lista de tareas Desbloquear lista de tareas + Seleccionar bloque + 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index c96feac8..c3ae9ea0 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -52,6 +52,14 @@ Liste Verrouiller la liste de tâches Déverrouiller la liste de tâches + Sélectionner le bloc + 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 02805b03..dba676c4 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -52,6 +52,14 @@ Elenco Blocca la lista di controllo Sblocca la lista di controllo + Seleziona blocco + Blocchi selezionati: %1$d + Annulla selezione + Copia + Taglia + Elimina i blocchi selezionati + Seleziona tutto + Copiato Sposta blocco giù Sposta blocco su Elenco numerato diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index fc54fa32..b2b520fb 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -52,6 +52,14 @@ Lijst Checklist vergrendelen Checklist ontgrendelen + Blok selecteren + Geselecteerde blokken: %1$d + Selectie wissen + Kopiëren + Knippen + Geselecteerde blokken verwijderen + Alles selecteren + Gekopieerd Blok omlaag verplaatsen Blok omhoog verplaatsen Genummerde lijst diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index 270a8926..a6d84fe9 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -52,6 +52,14 @@ Lista Zablokuj listę kontrolną Odblokuj listę kontrolną + Zaznacz blok + 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index dc7dd115..be186891 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -52,6 +52,14 @@ Lista Bloquear lista de tarefas Desbloquear lista de tarefas + Selecionar bloco + 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index 5c4dfbc7..3053a115 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -52,6 +52,14 @@ Список Заблокувати чекліст Розблокувати чекліст + Виділити блок + Виділено блоків: %1$d + Зняти виділення + Копіювати + Вирізати + Видалити виділені блоки + Виділити все + Скопійовано Перемістити блок вниз Перемістити блок вгору Нумерований список diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index bd5ac8af..98b2d51a 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -69,6 +69,14 @@ List Lock checklist Unlock checklist + Select block + Blocks selected: %1$d + Clear selection + Copy + Cut + Delete selected blocks + Select all + Copied Move block down Move block up Numbered list diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index 697a84b5..d9613e7d 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -33,6 +33,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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 CHECKLIST_LOCK Lcom/pasich/encly/editor/EditorString; public static final field CHECKLIST_UNLOCK Lcom/pasich/encly/editor/EditorString; @@ -47,6 +48,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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 TAP_AGAIN_TO_DELETE Lcom/pasich/encly/editor/EditorString; public static final field TEMPLATE_ACTION_ITEMS Lcom/pasich/encly/editor/EditorString; @@ -73,6 +80,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; } @@ -337,8 +345,10 @@ public final class com/pasich/encly/editor/state/BlockEditorState { 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 removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;Z)V @@ -458,6 +468,22 @@ public final class com/pasich/encly/editor/state/FocusRequest { 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 @@ -512,6 +538,26 @@ public final class com/pasich/encly/editor/state/NoteTemplatesKt { public static final fun isBlankNote (Lcom/pasich/encly/editor/state/BlockEditorState;)Z } +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 @@ -642,6 +688,60 @@ public final class com/pasich/encly/editor/state/TextEditsKt { 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 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; @@ -712,8 +812,8 @@ public final class com/pasich/encly/editor/theme/EditorFontSizes { 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/pasich/encly/editor/theme/EditorLineSpacing : java/lang/Enum { @@ -830,10 +930,15 @@ 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 @@ -855,6 +960,8 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { public abstract fun onSlashTyped (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;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 } @@ -864,11 +971,30 @@ public final class com/pasich/encly/editor/ui/FindHighlights { 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 } diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index 697a84b5..d9613e7d 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -33,6 +33,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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 CHECKLIST_LOCK Lcom/pasich/encly/editor/EditorString; public static final field CHECKLIST_UNLOCK Lcom/pasich/encly/editor/EditorString; @@ -47,6 +48,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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 TAP_AGAIN_TO_DELETE Lcom/pasich/encly/editor/EditorString; public static final field TEMPLATE_ACTION_ITEMS Lcom/pasich/encly/editor/EditorString; @@ -73,6 +80,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; } @@ -337,8 +345,10 @@ public final class com/pasich/encly/editor/state/BlockEditorState { 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 removeBlock (Lcom/pasich/encly/editor/model/Block;Lcom/pasich/encly/editor/state/BlockRemoveAction;Z)V @@ -458,6 +468,22 @@ public final class com/pasich/encly/editor/state/FocusRequest { 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 @@ -512,6 +538,26 @@ public final class com/pasich/encly/editor/state/NoteTemplatesKt { public static final fun isBlankNote (Lcom/pasich/encly/editor/state/BlockEditorState;)Z } +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 @@ -642,6 +688,60 @@ public final class com/pasich/encly/editor/state/TextEditsKt { 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 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; @@ -712,8 +812,8 @@ public final class com/pasich/encly/editor/theme/EditorFontSizes { 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } public final class com/pasich/encly/editor/theme/EditorLineSpacing : java/lang/Enum { @@ -830,10 +930,15 @@ 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 @@ -855,6 +960,8 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { public abstract fun onSlashTyped (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;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 } @@ -864,11 +971,30 @@ public final class com/pasich/encly/editor/ui/FindHighlights { 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 } diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index dfd0358a..e928a305 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -38,6 +38,25 @@ final enum class com.pasich.encly.editor.state/BlockRemoveAction : kotlin/Enum // 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] @@ -123,6 +142,7 @@ final enum class com.pasich.encly.editor/EditorString : kotlin/Enum(): 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] @@ -229,11 +263,14 @@ abstract interface com.pasich.encly.editor.ui/EditorController { // com.pasich.e 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 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] } @@ -342,10 +379,14 @@ final class com.pasich.encly.editor.state/BlockEditorState { // com.pasich.encly 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] @@ -492,6 +533,24 @@ final class com.pasich.encly.editor.state/NoteFind { // com.pasich.encly.editor. 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] @@ -574,6 +633,62 @@ final class com.pasich.encly.editor.state/TextDiff { // com.pasich.encly.editor. 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] @@ -641,7 +756,7 @@ final class com.pasich.encly.editor.theme/EditorFontSizes { // com.pasich.encly. } 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 = ...) // 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){}[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 = ...) // 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){}[0] } final class com.pasich.encly.editor.theme/EditorSpacing { // com.pasich.encly.editor.theme/EditorSpacing|null[0] @@ -755,6 +870,21 @@ final class com.pasich.encly.editor.ui/FindHighlights { // com.pasich.encly.edit 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] @@ -920,6 +1050,7 @@ final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$ 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] @@ -927,6 +1058,9 @@ final val com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePa 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] @@ -953,6 +1087,7 @@ final val com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_Ac 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] @@ -970,6 +1105,7 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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/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/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] @@ -982,10 +1118,15 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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/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/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 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] @@ -1012,6 +1153,7 @@ final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_FindState$ 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] @@ -1019,6 +1161,9 @@ final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TemplatePa 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/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] @@ -1042,8 +1187,12 @@ final fun com.pasich.encly.editor.ui.sheets/com_pasich_encly_editor_ui_sheets_Ac 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/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt index a7a0f2e4..0eb5acde 100644 --- a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/EditorServicesTest.kt @@ -30,6 +30,7 @@ class EditorServicesTest { 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 @@ -68,6 +69,8 @@ class EditorServicesTest { 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 { _, _, _ -> } @@ -111,7 +114,7 @@ class EditorServicesTest { 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.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/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/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/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/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/EditorStrings.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt index 63d15bd3..4bd7aeba 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt @@ -36,6 +36,15 @@ public enum class EditorString(internal val english: String) { 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"), @@ -65,6 +74,9 @@ public interface EditorStrings { /** 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. */ @@ -72,4 +84,7 @@ 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/state/BlockEditorState.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt index 81f3dca0..5d160f76 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt @@ -44,6 +44,19 @@ public class BlockEditorState( /** 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() @@ -75,6 +88,8 @@ public class BlockEditorState( public fun load(loaded: List) { slash.dismiss() find.close() + pickedBlocks.clear() + textRange.clear() _blocks.clear() _blocks.addAll(loaded) history.clear() @@ -183,6 +198,8 @@ public class BlockEditorState( */ 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 } @@ -209,6 +226,7 @@ public class BlockEditorState( private fun afterChange() { if (_blocks.isEmpty()) _blocks.add(emptyNoteBlock()) + pickedBlocks.prune() _canUndo.value = history.canUndo() _canRedo.value = history.canRedo() } 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 index 558e692e..607735b4 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt @@ -77,8 +77,12 @@ public class BlockSelection(private val blocks: List) { /** 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) } 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..097010d1 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt @@ -0,0 +1,62 @@ +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.hasContent + +/** + * 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 -> text.value + + is Block.HBlock -> text.value + + is Block.QuoteBlock -> text.value + + is Block.SeparatorBlock -> "---" + + is Block.LinkBlock -> block.value.let { if (it.title.isBlank()) it.url else "${it.title} (${it.url})" } + + is Block.ListBlock -> items.value.filter { it.value.isNotBlank() }.withIndex().joinToString("\n") { (at, item) -> + val marker = when (blockType) { + BlockType.LIST_NUMBER -> "${at + 1}. " + BlockType.LIST_CHECK -> if (item.isCheck) "[x] " else "[ ] " + else -> "- " + } + marker + item.value + } +} 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..6f89589a --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt @@ -0,0 +1,429 @@ +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 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. */ + public val isCrossField: Boolean + get() = resolved()?.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. */ + internal fun resolved(): ResolvedRange? { + val ends = current?.takeIf { indexOf(it.anchor.blockId) >= 0 && indexOf(it.head.blockId) >= 0 } + return ends?.let { placed(it.ordered(), it.head) } + } + + /** [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)) } + + 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, + ) + + 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. + */ +public fun BlockEditorState.replaceTextRange(replacement: String): Boolean { + val r = textRange.resolved() ?: 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 +} + +/** 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, + ) + + keptList != null -> batch.setValue(keptList.id, keptList.items, listOf(item(keptList)) + tail) + + isNew -> batch.replaceBlock(r.first, keeper) + + else -> batch.setValue(keeper.id, (keeper as TextualBlock).text, joined) + } + if (keepsLast) batch.setValue(last.id, (last as Block.ListBlock).items, tail) + // 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 index 6f0cf26b..b0651698 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt @@ -39,9 +39,20 @@ public class EditorIcons( 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, ) 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. @@ -132,6 +143,29 @@ private object DefaultIcons { "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) + private fun icon(name: String, vararg paths: String, dots: String? = null): ImageVector = ImageVector.Builder( name = "Editor.$name", defaultWidth = 24.dp, 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 index cd215132..59a9ed6a 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/NoteSpacing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/NoteSpacing.kt @@ -60,17 +60,27 @@ internal fun EditorLineSpacing.listItemGap(checklist: Boolean, base: Dp): Dp = w // 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 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: half of it, from 6 to 14 dp. */ +/** 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 index a7f3c2f4..a0f35bff 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt @@ -10,6 +10,7 @@ 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 /** @@ -63,6 +64,27 @@ internal interface BlockActions { */ fun registerCaretAtX(callback: (Float, Boolean) -> Unit): () -> Unit + /** The id of this block. */ + val blockId: String + + /** 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) 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 index 8b9bcea0..0ea1e2d5 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt @@ -12,6 +12,7 @@ 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 @@ -71,6 +72,18 @@ internal class BlockActionsImpl( override fun onListBackspaceAtStart(itemId: String): Boolean = (block as? Block.ListBlock)?.let { controller.onListBackspaceAtStart(it, itemId) } == true + override val blockId: String get() = block.id + + 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) 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..0043ae88 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt @@ -0,0 +1,180 @@ +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.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 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), + modifier = Modifier.align(Alignment.TopEnd), + ) { + 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/EditorBlocksHost.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt index 306eaf61..669cff5f 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -77,6 +77,7 @@ public fun LazyListScope.editorBlocks( editingLinkIds: Set, afterBlocks: (@Composable () -> Unit)? = null, ) { + host.highlights.textRange = host.controller.textRange itemsIndexed( items = blocks, key = { _, block -> block.id }, @@ -97,18 +98,21 @@ public fun LazyListScope.editorBlocks( 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) }, + ) + } + 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), ) } - 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. @@ -147,6 +151,7 @@ private class EditorBlockCallbacks( val onMove: (up: Boolean) -> Unit, val onDuplicate: () -> Unit, val onDelete: () -> Unit, + val onSelect: () -> Unit, ) /** Where a block is in the note: TalkBack offers to move it only where it can go. */ @@ -272,6 +277,7 @@ private fun blockCommands( 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() }) } 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 index ead9d9c5..74445eb8 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt @@ -6,9 +6,12 @@ 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.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 @@ -24,6 +27,27 @@ public interface EditorController { /** 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 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 index 7c70683d..a3b48af5 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/FindHighlights.kt @@ -1,9 +1,16 @@ 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 @@ -20,6 +27,29 @@ public class FindHighlights(private val state: State) { /** 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() @@ -49,3 +79,15 @@ public class FindHighlights(private val state: State) { /** 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/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/BlockInput.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/BlockInput.kt index 174c5650..9928a3ee 100644 --- 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 @@ -3,12 +3,17 @@ 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.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 { @@ -37,14 +42,18 @@ internal sealed interface BlockField { * 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 { +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) + if (actions().textRange.isCrossField) return replaceRange(diff.inserted) val lineBreak = LineBreak.of(old, diff) when { lineBreak != null -> { @@ -71,6 +80,14 @@ internal class BlockInput(private val field: BlockField, private val actions: () } } + // The selection across blocks 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. The field itself keeps its text. Enter only deletes. + private fun TextFieldBuffer.replaceRange(inserted: String) { + revertAllChanges() + val typed = inserted.takeUnless { it == "\n" || it == "\r" }.orEmpty() + defer { actions().replaceTextRange(typed) } + } + private fun onParagraphInsert(old: String, new: String, cursor: Int, inserted: String) { val shortcut = typedShortcut(new, cursor) when { @@ -85,3 +102,20 @@ internal class BlockInput(private val field: BlockField, private val actions: () /** The character that opens the slash picker in an empty paragraph. */ private const val SLASH = "/" + +/** 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/FieldHighlight.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/FieldHighlight.kt index 0bc3ab48..9775e995 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -11,30 +13,49 @@ 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 /** - * 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. + * 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, - private val blockId: String, - private val itemId: String?, + 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() @@ -60,18 +81,88 @@ internal class FieldHighlight( return true } - val modifier: Modifier = Modifier.bringIntoViewRequester(bringIntoView).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) + /** 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 @@ -81,13 +172,15 @@ internal fun rememberFieldHighlight( itemId: String? = null, ): FieldHighlight { val primary = MaterialTheme.colorScheme.primary - val field = remember(highlights, blockId, itemId, 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) { @@ -97,6 +190,9 @@ internal fun rememberFieldHighlight( 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 index 70e904fe..391c3f4c 100644 --- 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 @@ -34,7 +34,8 @@ internal fun HBlock( 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 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) } 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 index 7d7cc9d8..d26ec2ea 100644 --- 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 @@ -34,6 +34,7 @@ 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 @@ -42,8 +43,10 @@ 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 @@ -59,9 +62,11 @@ 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 @@ -196,14 +201,16 @@ internal fun ListBlock( 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 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) } Column( - modifier = modifier.fillMaxWidth(), + // The whole list, markers and text, sits in from the text column: under the heading above it. + modifier = modifier.fillMaxWidth().padding(start = listIndent(listFontDp())), verticalArrangement = Arrangement.spacedBy( theme.lineSpacing.listItemGap(checklist, theme.spacing.xxs), ), @@ -223,6 +230,7 @@ internal fun ListBlock( } } 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) { ItemField(state, highlight, marker.metrics) } @@ -347,21 +355,23 @@ private fun ListItemRow( .onPreviewKeyEvent { event -> val selection = state.selection val line = if (event.isArrow()) { - caretLine(field.highlight.geometry(), state.text.length, selection.start) + caretLine(field.highlight.geometry(), state.text.length, selection.end) } 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, - ) + editor.rangeKey(event, rangeField(editor.blockId, item.id, selection, line)) || + editor.selectAllText(event, selection, state.text.length) || + 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, + ) }, ) } @@ -371,10 +381,21 @@ private fun ListItemRow( 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) } @@ -393,7 +414,7 @@ private class ListEditor( /** 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) } + inputs.getOrPut(itemId) { BlockInput(BlockField.ListItem(itemId), defer, actions) } /** Backspace at the start of an item (see listBackspaceAtStart). */ fun backspaceAtStart(itemId: String): Boolean = actions().onListBackspaceAtStart(itemId) @@ -450,8 +471,11 @@ private fun RowScope.Marker(text: String, width: Dp, style: TextStyle, modifier: 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(), ) } 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 index 998185c4..30fc97b0 100644 --- 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 @@ -54,7 +54,8 @@ internal fun QuoteBlock( 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 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) } 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/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/TextBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextBlock.kt index bd783833..0669f88a 100644 --- 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 @@ -50,7 +50,8 @@ internal fun TextBlock( lineHeight = spacing.lineHeight(theme.fontSizes.textBlock, MaterialTheme.typography.bodyLarge.lineHeight), ) val actions by rememberUpdatedState(blockActions) - val input = remember { BlockInput(BlockField.Paragraph) { actions } } + val defer = rememberDefer() + val input = remember { BlockInput(BlockField.Paragraph, defer) { actions } } val highlight = rememberFieldHighlight(blockActions.highlights, block.id) RegisterCaretAtX(blockActions, state, highlight) 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 index 83b6d724..89660894 100644 --- 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 @@ -31,20 +31,23 @@ internal fun Modifier.textBlockKeys( ): 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, - ) + 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. */ diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt index 182082ad..da9607f3 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TextSync.kt @@ -106,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) 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/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/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/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt index b14549bd..e282c895 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/theme/NoteSpacingTest.kt @@ -1,5 +1,6 @@ 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 @@ -33,4 +34,23 @@ class NoteSpacingTest { 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 index eee3ad09..09f69cbf 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/BlockActionsImplTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/BlockActionsImplTest.kt @@ -131,4 +131,28 @@ class BlockActionsImplTest { 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 index 4a1a3b06..21804386 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt @@ -1,6 +1,9 @@ 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 @@ -9,6 +12,7 @@ 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 @@ -19,9 +23,11 @@ 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 @@ -36,8 +42,11 @@ 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.pasteLink +import com.pasich.encly.editor.state.replaceTextRange +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 @@ -52,6 +61,29 @@ internal class StateController(val state: BlockEditorState) : EditorController { 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) @@ -150,6 +182,7 @@ internal fun EditorHarness( onOpenSheet: (BlockSheetKind, Block) -> Unit, isLocked: Boolean = false, afterBlocks: (@Composable () -> Unit)? = null, + bar: (@Composable () -> Unit)? = null, ) { val state = controller.state MaterialTheme { @@ -164,8 +197,22 @@ internal fun EditorHarness( state.selection.immediateFocus = { registry.focus(it) } state.selection.requests.collect { registry.focusWhenComposed(it) } } - LazyColumn { - editorBlocks(host, state.blocks, isLocked, controller.editingLinks, afterBlocks) + 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/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/ui/blocks/SelectAllImeDesktopTest.kt b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllImeDesktopTest.kt new file mode 100644 index 00000000..23d59726 --- /dev/null +++ b/editor/src/desktopTest/kotlin/com/pasich/encly/editor/ui/blocks/SelectAllImeDesktopTest.kt @@ -0,0 +1,21 @@ +package com.pasich.encly.editor.ui.blocks + +import androidx.compose.material3.Text +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** A desktop has no keyboard with a Select all of its own: the content shows as it is and nothing is asked. */ +@OptIn(ExperimentalTestApi::class) +class SelectAllImeDesktopTest { + @Test + fun theContentShowsAsItIs() = runComposeUiTest { + var asked = 0 + setContent { BlockSelectAllIme(onSelectAll = { asked++ > 0 }) { Text("field") } } + + onNodeWithText("field").assertExists() + assertEquals(0, asked) + } +} 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/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 index f38ea3d3..5ed1fc0c 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -73,4 +74,11 @@ class EditorBindingsTest { 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/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt index 5e0a1e3c..87c69a29 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt @@ -11,6 +11,7 @@ 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 @@ -109,10 +110,12 @@ class EditNoteScreenTest : ComposeScreenTest() { private fun field(index: Int) = rule.onAllNodes(hasSetTextAction())[index] - private fun tool(id: StringResource) = rule.onNode(hasContentDescription(str(id)) and hasClickAction()) + 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()).fetchSemanticsNodes().isNotEmpty() + 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() @@ -618,6 +621,9 @@ class EditNoteScreenTest : ComposeScreenTest() { } 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 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 index bc4487e6..e899f7f5 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/EditNoteBottomSheet.kt @@ -19,6 +19,7 @@ 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 @@ -39,6 +40,7 @@ enum class EditNoteBottomSheetAction { CLOSE_NO_SAVE, DUPLICATE, FIND, + SELECT_ALL, TRASH, EXPORT_MARKDOWN, EXPORT_PDF, @@ -67,6 +69,7 @@ fun EditNoteBottomSheet( 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) { @@ -85,22 +88,24 @@ fun EditNoteBottomSheet( contentPadding = PaddingValues(horizontal = EnclyTheme.spacing.gutter), ) ArchiveRow(note = note.note, onAction = onAction, onDismiss = onDismiss) - EditorActionRows(canCopy = !loadFailed, documents = documents, 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( - canCopy: Boolean, - documents: Boolean, - onAction: (EditNoteBottomSheetAction) -> Unit, - onDismiss: () -> Unit, -) { +private fun EditorActionRows(rows: EditorRows, onAction: (EditNoteBottomSheetAction) -> Unit, onDismiss: () -> Unit) { fun run(action: EditNoteBottomSheetAction) { onAction(action) onDismiss() @@ -110,12 +115,19 @@ private fun EditorActionRows( 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 (documents) { + if (rows.documents) { EnclySheetRow( title = stringResource(Res.string.note_export_pdf), icon = EnclyIcons.Paper, @@ -127,7 +139,7 @@ private fun EditorActionRows( onClick = { run(EditNoteBottomSheetAction.PRINT) }, ) } - if (canCopy) { + if (rows.canCopy) { EnclySheetRow( title = stringResource(Res.string.markdown_copy), icon = EnclyIcons.Copy, 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 index 9a731a98..c99f0e2f 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -57,6 +58,7 @@ 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 @@ -159,6 +161,7 @@ fun EditNoteScreen( FocusNewNoteTitle(titleFocus) BackHandler { exits.back() } CloseFindOrSlashOnBack() + LeaveBlockSelectionOnBack() EditNoteSettingsProvider( baseFontSize = fontSize.sp, fontStyle = fontStyle, @@ -184,6 +187,7 @@ fun EditNoteScreen( ), listState = listState, snackbarHostState = snackbarHostState, + onMessage = { text -> scope.launch { snackbarHostState.showSnackbar(text) } }, modifier = modifier.then(container), ) { padding -> EditorContent( @@ -206,11 +210,15 @@ 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() @@ -237,7 +245,15 @@ private fun EditorScaffold( } }, bottomBar = { - if (!lockEditor && !viewModel.isReadTrashOnly) NoteBottomBar(simpleEdit = LocalSimpleEdit.current) + 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, @@ -337,6 +353,8 @@ private fun EditorOverlays( EditNoteBottomSheetAction.FIND -> viewModel.openFind() + EditNoteBottomSheetAction.SELECT_ALL -> viewModel.selectAllBlocks() + EditNoteBottomSheetAction.TRASH -> exits.trash() EditNoteBottomSheetAction.EXPORT_MARKDOWN -> exits.exportMarkdown { id -> @@ -372,6 +390,7 @@ private fun EditorContent( ) { Box(modifier = modifier) { EditorBlocks(host, listState, titleFocus) + TextRangeOverlay(host, listState) EnclyScrollbar(listState) } } 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 index e283cc95..f4d37398 100644 --- 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 @@ -2,29 +2,59 @@ 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.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) : EditorController { +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 { + val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown) + return text.isNotEmpty() && clipboard.copy(clipLabel, text) + } + + override fun cutTextRange(): Boolean = + viewModel.canEditBlocks && copyTextRange(markdown = true) && viewModel.replaceTextRange("") + override fun indexOfBlock(block: Block): Int = viewModel.indexOfBlock(block) override fun onBlockFocused(block: Block) = viewModel.onBlockFocused(block) @@ -91,5 +121,7 @@ internal class EditNoteViewModelController(private val viewModel: EditNoteViewMo @Composable internal fun rememberEditorController(): EditorController { val viewModel: EditNoteViewModel = metroViewModel() - return remember(viewModel) { EditNoteViewModelController(viewModel) } + 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 index d6d39702..aa38769a 100644 --- 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 @@ -14,6 +14,7 @@ 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.checklist_lock import com.pasich.encly.core.resources.checklist_unlock @@ -26,9 +27,15 @@ 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_placeholder +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.tap_again_to_delete import com.pasich.encly.core.resources.template_action_items @@ -101,8 +108,11 @@ internal class ResourceEditorStrings(private val texts: Map 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 @@ -248,6 +265,10 @@ private val AppEditorIcons by lazy { arrowDown = EnclyIcons.ArrowDown, duplicate = EnclyIcons.Duplicate, trash = EnclyIcons.Trash, + close = EnclyIcons.Close, + lock = EnclyIcons.Lock, + lockOpen = EnclyIcons.LockOpen, + grip = EnclyIcons.Grip, ) } 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..a5300357 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorSelection.kt @@ -0,0 +1,134 @@ +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() + +/** + * 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 text = selectedBlocksText(viewModel.selectedBlocks.picked(), markdown) + val copied = text.isNotEmpty() && clipboard.copy(label, text) + if (copied) viewModel.selectedBlocks.clear() + return confirmation.takeIf { copied && !clipboard.systemConfirmsCopy } +} + +/** + * Cuts the picked blocks: Markdown (so pasting them back into a note rebuilds the blocks) goes to the clipboard, and + * only when it got there are the blocks removed, as one undo step. 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 text = selectedBlocksText(viewModel.selectedBlocks.picked(), markdown = true) + val copied = text.isNotEmpty() && clipboard.copy(label, text) + 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) { + val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown) + if (text.isNotEmpty() && clipboard.copy(label, text)) { + 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 = { + val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown = true) + if (text.isNotEmpty() && clipboard.copy(label, text)) 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/EditorShortcutCommands.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditorShortcutCommands.kt index 9a9ed332..d66aad4e 100644 --- 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 @@ -31,7 +31,7 @@ internal fun EditorShortcutCommands( ) allowed.also { if (it) overlays.isTrashDialogVisible = true } }, - dismiss = { viewModel.find.value.isOpen.also { open -> if (open) viewModel.closeFind() } }, + dismiss = { dismissSelectionOrFind(viewModel) }, print = { documents.start(DocumentFormat.PRINT) }, exportPdf = { documents.start(DocumentFormat.PDF) }, ) @@ -39,6 +39,16 @@ internal fun EditorShortcutCommands( 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index a801a7f0..2c4c5a59 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -28,9 +28,11 @@ 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 @@ -41,6 +43,7 @@ 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.deleteSelectedBlocks import com.pasich.encly.editor.state.duplicateBlockOf import com.pasich.encly.editor.state.duplicateInteracted import com.pasich.encly.editor.state.focusFirstBlock @@ -55,6 +58,8 @@ import com.pasich.encly.editor.state.moveBlockOf import com.pasich.encly.editor.state.moveInteracted 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.textRangeBlocks import com.pasich.encly.editor.state.toggleChecklistLock import com.pasich.encly.presentation.editor.persistence.LoadNoteState import com.pasich.encly.presentation.editor.persistence.NoteCopyTitle @@ -625,6 +630,40 @@ class EditNoteViewModel( 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() + + /** 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) 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..8933bfa8 --- /dev/null +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt @@ -0,0 +1,288 @@ +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.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(NoteWithTag(note = note, tag = null)) + } + 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 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 + } +} From bc86bfbc00c758912dd31350f73fa300f0b92c8a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 16:38:23 +0200 Subject: [PATCH 53/75] fix(desktop): Arabic in PDF export and print Arabic text came out as question marks in the PDF and in print. Noto Sans Arabic is bundled next to Source Sans 3 and each character takes the first font that has it; ICU shapes the letters (with the lam-alef ligature) and orders mixed right-to-left and left-to-right text, and a right-to-left paragraph is aligned right with its list marker or quote bar on the right. --- desktopApp/build.gradle.kts | 2 + .../common/licenses/NotoSansArabic-OFL.txt | 93 +++ .../common/licenses/THIRD-PARTY-NOTICES.txt | 9 + .../common/licenses/icu4j-LICENSE.txt | 568 ++++++++++++++++++ .../desktop/documents/DesktopNoteDocuments.kt | 32 +- .../encly/desktop/documents/PdfComposer.kt | 134 +++-- .../encly/desktop/documents/TextLayout.kt | 108 ++++ .../encly/desktop/platform/DesktopLicenses.kt | 2 + .../fonts/NotoSansArabic-Regular.ttf | Bin 0 -> 271652 bytes .../com/pasich/encly/desktop/LicensesTest.kt | 4 +- .../encly/desktop/NoteDocumentsPdfTest.kt | 113 +++- gradle/libs.versions.toml | 3 + licenses/fonts/NotoSansArabic-OFL.txt | 93 +++ 13 files changed, 1105 insertions(+), 56 deletions(-) create mode 100644 desktopApp/packaging/resources/common/licenses/NotoSansArabic-OFL.txt create mode 100644 desktopApp/packaging/resources/common/licenses/icu4j-LICENSE.txt create mode 100644 desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/TextLayout.kt create mode 100644 desktopApp/src/desktopMain/resources/fonts/NotoSansArabic-Regular.ttf create mode 100644 licenses/fonts/NotoSansArabic-OFL.txt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 146048f8..23515cc6 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -74,6 +74,8 @@ kotlin { 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 { 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 index b77fdc93..710153e0 100644 --- a/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt +++ b/desktopApp/packaging/resources/common/licenses/THIRD-PARTY-NOTICES.txt @@ -10,6 +10,15 @@ 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 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/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt index 01be5a83..395b6f4f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/DesktopNoteDocuments.kt @@ -27,14 +27,15 @@ fun interface PrintLauncher { /** * [NoteDocumentRenderer] of the desktop: a note as a PDF (Apache PDFBox, built entirely in memory with the - * app's own embedded font) written to the stream the user picked, or the same pages sent to [printer]. + * 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 one font - * is embedded (so no system font is looked up, and no font cache is created), and no network is used. + * 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) { @@ -52,10 +53,9 @@ class DesktopNoteDocuments( val pdf = PDDocument(IOUtils.createMemoryOnlyStreamCache()) var built = false try { - val ttf = TTFParser().parse(RandomAccessReadBuffer(fontBytes())) - val font = PDType0Font.load(pdf, ttf, true) - val lookup = ttf.unicodeCmapLookup - PdfComposer(pdf, font, { point -> lookup.getGlyphId(point) > 0 }, paper()).use { it.compose(note) } + // 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 { @@ -64,13 +64,27 @@ class DesktopNoteDocuments( } } + 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 { - val stream = DesktopNoteDocuments::class.java.classLoader.getResourceAsStream(FONT_RESOURCE) + 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() } } 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 index b02ed45c..9afc2165 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt @@ -7,7 +7,6 @@ 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.font.PDFont import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode import org.apache.pdfbox.util.Matrix import java.awt.Color @@ -32,21 +31,22 @@ internal object PageStyle { /** * 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. One embedded font draws everything (Latin and - * Cyrillic: the nine app languages); a character the font does not have is drawn as `?`, never an error. Bold is - * the same font, stroked a little. Nothing here reads a file or uses the network. + * (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 font: PDFont, - private val hasGlyph: (Int) -> Boolean, + 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() @@ -86,12 +86,15 @@ internal class PdfComposer( } 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, left + PageStyle.INDENT, PageStyle.BODY, PageStyle.QUIET) + 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(top, y) + bar(if (rtl) right - 3f else left + 3f, top, y) } } @@ -105,44 +108,86 @@ internal class PdfComposer( private fun items(block: DocumentBlock.Items) { block.items.forEachIndexed { index, item -> - val textLeft = left + PageStyle.INDENT + MARKER_ROOM + 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(left + PageStyle.INDENT + 3f, baseline + PageStyle.BODY * 0.32f) - - ListKind.NUMBERED -> - drawText("${index + 1}.", left + PageStyle.INDENT - 2f, baseline, PageStyle.BODY, PageStyle.INK) - - ListKind.CHECKLIST -> checkbox(left + PageStyle.INDENT, baseline, item.checked) + 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, textLeft, PageStyle.BODY, if (item.checked) PageStyle.QUIET else PageStyle.INK) + 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, left, size, color, bold) + text(line, rtl, Column(left, right), size, color, bold) } } - /** Draws [line] at the cursor and moves the cursor down one line. */ - private fun text(line: String, x: Float, size: Float, color: Color, bold: Boolean = false) { + /** 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()) drawText(line, x, baseline, size, color, bold) + 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) } - private fun drawText(text: String, x: Float, baseline: Float, size: Float, color: Color, bold: Boolean = false) { + /** 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.setFont(font, size) cs.setNonStrokingColor(color) if (bold) { cs.setRenderingMode(RenderingMode.FILL_STROKE) @@ -152,7 +197,11 @@ internal class PdfComposer( cs.setRenderingMode(RenderingMode.FILL) } cs.setTextMatrix(Matrix.getTranslateInstance(x, baseline)) - cs.showText(text) + // 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() } @@ -191,12 +240,12 @@ internal class PdfComposer( y -= PageStyle.BODY } - private fun bar(from: Float, to: Float) { + private fun bar(x: Float, from: Float, to: Float) { val cs = checkNotNull(stream) cs.setStrokingColor(PageStyle.RULE) cs.setLineWidth(2f) - cs.moveTo(left + 3f, from) - cs.lineTo(left + 3f, to) + cs.moveTo(x, from) + cs.lineTo(x, to) cs.stroke() } @@ -225,20 +274,16 @@ internal class PdfComposer( y = paper.height - PageStyle.MARGIN } - /** [text] with what the font cannot draw replaced, tabs widened and line ends made `\n`. */ + /** [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') - var index = 0 - while (index < flat.length) { - val point = flat.codePointAt(index) - index += Character.charCount(point) + flat.forEach { char -> when { - point == '\n'.code -> append('\n') - point == '\t'.code -> append(" ") - point == NO_BREAK_SPACE -> append(' ') - Character.isISOControl(point) || point == ZERO_WIDTH_JOINER || isVariationSelector(point) -> Unit - hasGlyph(point) -> appendCodePoint(point) - else -> append('?') + char == '\n' -> append('\n') + char == '\t' -> append(" ") + char.code == NO_BREAK_SPACE -> append(' ') + Character.isISOControl(char) -> Unit + else -> append(char) } } } @@ -274,9 +319,12 @@ internal class PdfComposer( return lines.dropLastWhile { it.isEmpty() }.ifEmpty { listOf("") } } - private fun measure(text: String, size: Float): Float = font.getStringWidth(text) / FONT_UNITS * size + 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). */ + /** + * 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) { @@ -284,10 +332,13 @@ internal class PdfComposer( 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 isVariationSelector(point: Int) = point in VARIATION_FROM..VARIATION_TO + 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 @@ -296,8 +347,5 @@ internal class PdfComposer( const val BULLET_RADIUS = 1.9f const val KAPPA = 0.5523f const val NO_BREAK_SPACE = 0x00A0 - const val ZERO_WIDTH_JOINER = 0x200D - const val VARIATION_FROM = 0xFE00 - const val VARIATION_TO = 0xFE0F } } 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/platform/DesktopLicenses.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt index 0ce0ba8f..1a030989 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/platform/DesktopLicenses.kt @@ -13,6 +13,8 @@ object DesktopLicenses { 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, 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 0000000000000000000000000000000000000000..8bcaec873933d7aca98165908b10eea930144bce GIT binary patch literal 271652 zcmeFadz{VH{y)Cfdhhk#m)UbQv#&E_jG4h;Fc{;K#^jdk z1sA5_H=a-Mju>>?1h^;Rw-?+sV(gv6d#1bMg_zn8n>1y>mM4AqT`5Fp+1Oi#3`(f$DiGG@#rVBq+@L8F;)bR_DXg~| z!hhEILF0xkSi0pK{4NthzcS&L+a~>K{j)8E^&aBCMogGEY{G!6E9MGE4y9|ekV1(B zNXU)Y1KBSuF&eI8#qFSLaY^y2I0X77E-Aher-YK0w1ku)l`kw+s!D}rrCDi0S`DlQ zLh5_G*G>__aSkeAtO zkPa8plO)m+tKxVqks^F>l@1?VY~eu~&LCFu;X(TA0fj&>U^p-ym;!77wgI~U_{URq zKoezknImh-+On~1B8y~m*;=-drScltR(6tI;KM1)?M24>t$cKD0z!qj<_j> zs}Y(KF4+oxh3qH$p^gobgGGuQAxDT*xnBNFq{)xuQBj}s*4TauxoUz?9Ju$K+#q*Q zNb*x?kwS9HkSsz-#j>TykT=L1L=Abf94NBoa5-G$Am(R9F2_5N(>77wCMU}~

?Q zd>k&QEzm++;TSV);&?Hd>4b5ECXSJl#*G;_MobmDZTl|0ME^k(2Mw0fhuk@Fte8Gx z;;=Dd{MbR0#>-VG`?DenQvP1#tO%!x((-Csl?p;8;wdn)e zqkC6SA)-;lNHqr(vSLX(Yr7JJbWU;pf?9ganFD&#J}smt-MdxDqAzi7?R3Y7M z?Zc4!IMJfwUH1Esy>WZtb3ocUgs|4xLdcY2&wR+m?uDRlxLbo3IZK3$>u4W?eA)>Y zS3_q<#Indf!0losLYNnKQpjq1g9qWKz_kQ)xvLfKZgJMD3c)?!-Xvt=a(fc|_e0B& z)t1}ik*Z0qO1RJTEP(&T&SKEz&JdpRIekJVuk#{IPrl0w|DzL-HhY?LHt0+zS~_cl zdJFVUZc`WHU2UD4-640#<#DCDU9KJ^JGVJ^xcdneznbbne0kDFl$;B?6wyPtj^p|w z`UU;2`Xje0`4W%rza;;W6n*cH?p$(@o@QAlpW}MkC4Q=RlXsc-xOcsGW#S>w?ZABR ze(xGwhrFBOJH{8WyvDmJv47%n_`P(k^j2N#@zka35JI8n$>$=t?*^)TE{pxze=!b< zhkRFE^lQCPq1BYa@GBW#iXi%n*k3IDRX@R_=L{}$`VS)c{^P%@(4*(*s`5wW5v6zJ zfBUOl<>5--RiT=@DD~YWMYms~=Fa8xQtX$z@)M>6**SXJq!VFY?s}O zY=Vn_m(p=bLU`xIrFt^(qUS#rV&(Gt9e6Vsj6=ySx-Gmt!8TV95Q&x=iW1 z;^zNYyJOFgC6`YYNEq+&@ci&1&_&^8pv%H|3*j!_C%9`|i0)x|Z+JP}l^iX{5f>fA zwag1eV_}7tgja-T@$V60B>QV98}VVP^;n zw%|xPirnNbNxc=^aIJ+cP5xZ45il+ZcMJCl_X`i?C&(IY%6D6dt8te??#OV2+v4gJ z?u1mf4|fUo;BbxosNuqHVX{*#V_`zlun60U_MAu93E>t%3DBObhH&c`+9D(cOSUZ% z*ah~in}2tO%`4;>SaM$eeWM(J-$UduyaViTAe51E58OFq#Qd&RjqO0DD9%K`kS?n<(Bd$)QuY&V9R0>}Il+Yp|`H&9?GQ@UaB%;H9XSB<*HNisXwnS_-VyY*7=|bFLUHh6*b6 zQDQVkG8X)AhyT-J8DNQppi82!!Q~M3(VTWlf8NDBh;RsURt)4`Po##%h9-ulgrtl7+GmW3nQQvwS?x^7A+= z%;T(d9%p58-KoxXr=O@7JR3Y8LV82qP)aC(D>IZ6$`3UR6@@y6T7^nMyM=m%`h^CD zhF@0B|DlvaM?)t2{9kPaM;;9MgJ^ z={b&R7svD&$JCBv>WUU5g7W~XmC!c>AFO1qmp>Aj&U@^5Zbih%Uj||M$y3;s1IC=l@?XjHnjCCX)Ig%%Tu2 zWQsAIo{c_4>L<{vD7g32FT!$C`mX4?nA>boW*6a&LvE+pH%Z4?vh2ChbM^zkgA_V? ziUd%+P5F;UvXH7==na{~HJEyos@~)Y^_W_r9#<>XD)qElD;)X`k*cSORC^9E52^Da zT{r7jMW)^XyawzPnRY7R14aO&fV+WvfEkD(K|dDVtsh5vR|2b$+OyH)cE9LuyFV~I zdfFa|-=l$Pkf#IXz)bY2BmD>b2mMF<$NgXWzxAK>pAU$D9Y_gyfs{ZXkQ2xR@`L>Y z4RI9(S_Mi29RuA0y@FE%{Q?66!-M64v4M$!DS_#MS%G=vjt@=>P7Ri$hRqKy!nG{8GPowVKDa5kHTX(!SMZJC{^0w;!@*<0lfl!$AA-LG zBO!-qia|#~3|+vR04;zLpnVK*_mu;)fn5f{V(|IEGWh9%8r~X!JrV2#w_}%(O3$tV z78#IV-)R8P`BnmSZ#}RH*b2M?P#kZ>upj(=;4nbpoEn3%IG#R)I&uhg z0pAUL2pJL0ZYqjETi~+TT9Bdym&DZYHJ}|~30tC*=nT0#E-8B8a$-ic7v$>&*_QpqO`wn9 za^U^G40he4xNN-TABPJ{Hw7j5|KoqwffQZzUHWdkx9-;u==u6Vy+kk7%k-oAG5xq+ zsaNUMdaYisU)4MGPQ6R-(FgG^JET9-hxNz$i2g)>s*mZ<^l|-#{z{+HU+dHQ8~vR= ztADanZJ+J8gLc@iY3JCvb}hS(UDvK}H@B~`yV%|Aes+I*xIMxiX^*l;<9&FyJ>9;? zF1KgcGwplrKfzKEX=u|7w5K$*rL?I4+G-lwe;V3<(h+d-LvCo(%tBl^0c*StJ2o`O4By@y7~I~CgPf&w%NBLZJTekZ)4gH-%j5? zUuN2F-%)>p@09Oc+FpMs?SMbeuY4WT4*Gie2KuI?9r4XdJMLSO_N8x~zc}q%-yYw) zX=i=2d?);=zB9hcwDbNfpYYf7yL`6K>npwNNQFk&<#&lp&#-w+&31fm~sWCBNtVe z=UeDo;V(uimjJ7M>wFvWs|s6u+mY@)7qQRxu0P~Ey?yo?)Cr+Af1X zydW-u8W{2=}r~MMEw&B{5wwtjx z?LgYWv?FQ9)4ojmHtlTMd7nUuP?>mrDYyc-GFi*{&|{IW73+$0)DPMj4jqw}NKd4x ziOBI3)($k|pF~;-WV2qj`!@PWA4lU+2Lf|Z8l)L$1e!1!Aq|}2a;@ZiR^`#;jdSO3 z>+j?*^Y`)h_Yd}u40!zG1L=XyP*s1$G1u2EGlP z_b&_PC(aBO1ycgq{*{63KtW)Ge@&o=f4zTGuw(p`#L51xfmLvO#lI`CJ8&d$7L(F% z1RMJI2U`UL{`dWd1D;El|Cs+|U~cRh9WYlDinHnpHVm9MSFi}_IE@%DUj85azXUdz zD}K7U{1J-1>hec|7;^E|D&PpVLd@BWBoszE?nJH}n_zTlq`<9dUKT)eBcY zTm${X{bO-W^iT0m_s{ar^Dp!-@vrc&_OJ7A^lwQzpLE{8-M`bn$G^}2F0Mnkj`~md zPx;UI&-p6@D&Ptv1X3|5&k7U<@&ffh`-dZe!GYpHS)g^GZJ-l)|3DvLaA0I$Jg!NB zskq8<%?`{DED9_OtPHFPtjDz}ur=^XV3%uV;Ellk!25y2fn$M_;k>}<(CWYsn4LKq zIv4l_Gf<&GB%BxY1RX$9cvrZ8cv*07&=*V(W(RAB`UMMuXEE2aF4!d40^|Hj%v@!K zvw|hzm7$zq`(T&w8@L9CR|b0o`vwODhXzLnCj=)4r|~s2I5)T;xHz1LnIT-k<-t{a ztqn~NZU`6B72F)$7TkfkPF%s=;gg8_K=2@6M}o(LUk1NL{9EYac?-<8n7NB^Fc7rqTsA|O#q)C^kv437^_3QrA}hiB7lF3sYG_lMsPpA4T4uL*ArzY<;_-o%`q zqC14c;bZLMhwv|9ta*wD^c{K{tf_ghrWWakW7gDTdIeikPr#mf60-x(>J9pN{es@2 z|E#y_zv#c}m)XL44K~*0*45E}v#QSM@AVJ*N3yQ;&-xert3IzQbfvzaqgW)Cwz9Qt z+fLhUd+a#dYsX`*A=OS}J1hW8ECgFD-OjM9+cjb~S#3Mtu4fn6#da&ZwOwMD!ZvGb zcd)O8h1S*XZeM5jgq?OHEVY~L0ro(9us!rLn{A9e&c5Y0mfI}*K6|!3$DV87Z_l$I zuou9VQ=&gKbBAz3JJUetYU~1mmgWf;H1z<+>x=XvjGZ6W4+}T6wgfIq^-_#pmTAnL z!y0`|#6ycKgeFZ2TR)+nfd5r`6=ss2)K7{y=oKr+(ChOe4Z3{+GV}}jSN&I!2o1k1 zl1R(K(yw7$9)PZQLjF)6K}wG5qe#gY`U}MM750cI*s$M$p3y%d#uQ@zi}5`9As2}$YDDSIo(c&oMC4O7qnj;w1!;+w5DBC zC|JiiA_W$5F63gn7$LW^TL~{pq_uFPR7yZg?NZQd>}%k&t=$%K2fG8pyw=A00ZOY2 zp1sb#P9&nldV=<|`-ymz+>Ig$rPm+wP4-QY2iOCIMoA6?9c*J?07`Ty=x}?uuwjjl zKwd`LBZUW6`6$Sv?a`3O*keQ-N_!mWE%q&-)9h*Re>Y|_EZFVXr2yOg9?0c(Ipi6b z*|1>8&&0F$Vuy`oqgIG&R4;@RHDfMjOHex?KVUxqd4atECd8jmQ~W5gJ7f^ExmAB8 zC0G5pjAQj2^~(HJ@v7VO+qflR1nI^lcuxe~uL{e?c$Q{+Xa>j&nf}Ze7c2M}i4qdv zMmsCamB@aop64HbNHoT#Raff8bYAJ5c1@UzQtOOe7(?I>s~VSGCY>g&m%_N*l~QXi z+D%~gg3x{t0?lPrfkOU0C|+lwwHEKC2x1cH=5mlTR&W@Uaua&hZ)`ooJp%1aF>u1~ z9us|3a)3Sk=~7+oa}#Z>+YZ+6wsSqPpPTq9|!;5z8|rcA$th zpcwzrpXyg7RU3;ttL3Xqp9GyMCxezV{YYYui1>$`LU+}JplgV#+gWZET?3a^(I=R; zgnN#B1@dC1-Q@|$C8`K?oIuY8N^>H2s~ph9iq;q(V%o~WZZ?r`p|=sO)U%**ib7tm z&YRm8JVeaF|;>P9)?!FA}Y4Z z7a^xeO2aGiUyy}BA1iv1i~I}*sFZJFHAsqEu^J@BbkPj-5m5^IIc9~VI4J6Z{u=!V z^!q4w?a1p?HfT3h194R+*~(#;_R$i!ca3&}&pV^^cAXP_6m)L%JEluOyF??P^HC&_ z0Vxiko~rA zVmgofs9g5>7ow8txRk~0ax+t^rBeQxX=9=)%<_1m0MUg+C9S|paUatQOuIze!li5E z@8l=i9&~=>Id-8Mdm!2n^r6VRpbtl=*4-2N5%ie|>GO^#wZ&0U2WT&q1bTOb^fr#? zyB~@Ug#2)nw6r7gJ?QGlXP}d#{Xw6NlK*uPsW7>=9drZG!dMDGROkZWXfaz$a=N)J%ay!ZL1E!sr7V_OnmU}ZTWtS67pJDnp zcE6S7(Oe3DXWG>&M|v=q0(uYA5lnAodKJ?#Ov{<}WIC29W@YeHJEl{jM<5Sjc>&XZ zGMzzGwIV7_jVe`C!#i1M3u2NrmFWo3>DD&T`}yuHi`srUxBbQP7Ra5s?blbQAwOhM z{a0li0U1ijj#QoGG+1-jJYy2tbh>r=QqXQhMA zwr)lW-{l&$TrGmj8xCs4>l_b&E?3h)U$UBj-fvX{eb7QH7Aw?l(7#xx+t2^Y)9Loxe+V3aoZ=idu z0O$dt(Wv?WvQTJ`kw?@8&@)6uk)r-hv8-5@SM@KJp(!pJ7uXQlCB=( zcS>0lr6YG^|ACZ!h$<(`!%ZOs6uP#pP1Ya%ZNGDe7AmuslOi zYu(QDHc54WrsYg$GksmYk8m!q{3O$Bm`-4NE7QC99XgZcK1|0lUC()WglRW+ zd5-BE&fR4BGD04K_Z#X4yKiN>Mp4awgysEAw=iAJ^cbgS56cILqO4KkC}pB&h@zB< z3Z{zP_pvOA!Wuw5mjj|Z;HQ>AKPl@(p9IZ|-VT?c(e0qaqDY(cV3k_ROgWJ0XwZJx zFC(#68&our)HnWvU1m$vd|8j>T&AV+4*2<}oDTP<(hK=&@eAY+WHM+eR#~KokUREZ z)7|xo+Vv|$jV6jwWzyURNvKhORfu-pKtLpT!kM_w(5gF5l{ld?1U7ZvPt96#HH z<(pXUM5)3qc?vn5-G{N9L+Qb;A97EY)c?$o6F{G3m*Gqsk-M~67DVBPT&A&0GtSo> zmK$)$hdDh1+2{9s*T?doV`(F;7E1K4GK1Z}X7^N)drXGeJ&L!7l+#(B zL4HI_auGA5D?s;iyW5Y|Wvq2Y*MrVtm$^)zV0w({Y@+fc+pcdD6zFA^+^Mlj$6GsmpRHQPg+#&QT@1(`c9asB+M4T<1BpauWMA_Qodm8Dy6REH`1fEz51m9+N?) zlZ;QMeVFDj9mljD`>D@#5xXoRyH4hFXt$9I$_t^1dzr3hKb_c59MgJi2hK1u-<`&C zQ>N3{1^WUJCbtV&pXCEgKVsUED8?G(M^e1v499NXYiHQq$FUsbFn#R*AiJ}*Ci9HU zxyWN$hyDD(?vkH9!_V$z7p?=6t$Hzl<1*Hrk7Hj;y{PzH4FK&SP7;;$9-P7N(+N!P zQpIqY%)RKXY8K=PMCD1^RVu$AD)x#OL3bk_8rK{GeV9gMvYA{3+J>lDLA||L%k)Ej zhaO?NNy28s$doL%$xMe5wVGmwrNsFkP)8<>Qq=?UR?s4r9Yp0LN$FMsOKfZku90lW!jM3#bd0idss`I zCG~aRur~gVwgp`Yq+g7Y6wBM>F8{=uY|nC#_4yL}|59EDKVQloOpzmKe+lR=_VahV zhtV@g8o#_L6F~nV7lF=1g_Gj1Oh>>Xqn;rOx|HcEqVfg>Yf!yFT0-ASVTOpx)||&u zrlXkV6NSxBdEdkIDyDOoHe>n@(;TK@rrAt;uw7b7cB8DrR>Y@FyD)7+R8+EjKT{{W zuw^2?W_bf!q&tYp*48Gb4>6t3bUf%c99JU?E@D0T0j0asEb=)gM#*I^`y9`A8?fAp z(-tI~RtD9lOb-(!%U1q{QU9MvXi2C7ZIJCNcY;+*@1M zg9g>pER(i_IB`ije~V>$YMS*hXgXQfpp>dnoa<^7jvU8nZpv~a4lRR2Ys61w@KcT0 zPX?#C5kH&3;XGndjCDB1N7#QI_Wua`ufu+BBU#MkG_R-dkyE4=Ru;L>V4HtEKii94 z=CihESVwVpe#{T0a~jFW3zgR#rVTj!1ML10r>&Vu8;5X)@A^1|<%%@mWB28p)A5|s zrkvC9*5|m}luKtkhtQOt9nT>*<&eW%zS)#c)FRSkHiz~#`^?~2*y5G%a0vIa`#b#9 z{ak8c_8(>UA?$w!g)DxgF@?n=am-kum064FuDVO|8vrgI%mU@dP8pu#Q-)b|%20av zl;L?kWmqa2;#6f{IS~8eC*bU2xtuE(%4Kqu8mLCgb#jy3CU?p=wiu4p40fkeckF+?;fic1JkRqN#82_;sWwcc<9%+l)VZVT|vI@ygi!l`+0T*c@K#Sh!>H8)>&na336Y zr@3Dm^G_!_$({N+q{ERP<6UC+2gLY(JcoL!$P&SMZ%Aalup7^F(2i26y1}<`3n6~v zRsu;Q4rK)}Phg(y2(21_wc%p?WkTY+7EU(cE(+dC$Gihlg`IaqjcsFQlW3&^^SVrF zwp?OI9?>MG=BZjN(~LUihmi)fB#b^(g_;~ zG@GfHX%f{nX9>$Gw8N6l%kn8y^n^}?K<8NPQ{Z%RMmnh#NM}c~Go4ugxxuF$0rvse zo#`w@TpnE4LdMyDq$&=la+DKi`Nb+RTRaIl8}h7}jM|0$>mLws=Y7<)&U|OHcnKq5 zN_P?PF6)}=I=S>ioc&@h>EvP`0oW>sD{|=Efc3ru_WRC|)8$OC58=M)H_CAjsfDl1 zn9F3iEMu1}xS$iZTRC00kGuiKWv_h>E{F#(;dI1P>HHMF4FDN>vk$`$g-j=EST{D0 z9N0xVyI~D>kG9Nb~J~qzfBmLSj6#GW` zLWmy3cPT{b<&=Iyq{~m`G5MK1EASi@V@?D{s6bH z<&W|wc@DqN%3tvGJUsoYJTEI`rMw^`63Zk?Dy1ytP+Hl_iFdqPc~qS8!cD7qm56t~ zMWrmC*8R1KA_YN{NS3%5*_2MeJNe%DfX zPdJx|bgb70umd0q>-@E`+oB#;>l@llXr&)_N}$IQ@J)6{dp@+b40l)BYoNJJczQ9O zZf~bxk3q=Jw6pCz?AOS*J7LF0p>Y#J@1g`~8;JSTqwo z^<>P_k2F0K^0-ugVBbn#@rch6pF0;g7dw|bSK-?M8=RZzl)ZDebFcG&^PuyH^SJX% z=eN$Y&hv4VUWeD~P4%XGbFkQNyS%OxSHP9&%5mj;>$w`bid?N+rLK8@F>d9H=7C9V~&)!s?2b*_!BEw1gZovuCJ zY2I1h`L2DgcU^~EM_nger@V_@XI$r8m2TyBxf8rA+^P5yK$bhtUC&+UE_S!}u5q_@ zcXF4x`?&kN2fIhQ$Ga!Fr@G6%8{D(KTfDEh=erlVm$_HE*SOcaH@UZZce`J4?{dH4 z-tT_jeb{}>ebT$necJtl`xke_ zdcO6X^_=&f#_RB`w=&L-^Twsb1>!Q}a^mvi8pajHwTdf^>loK9u2)>YxPfuQPQzGj8-2Q=BGB<6OOtej{Xr>w)Z94{QK70_ZtBn}IFBRy=j-i!qpS5@sdS zMq8%$$|mt5)&;0T>?*q=lJ2rELPn3T-cbkDhw3AB0Uz9<8K~d+B26rhiqnn!BVF_L zLhRF8haGO4^ecEuU)0At6bs^~b$4p-A>93M-@#FM7p}xpPvTvvaAu?xhf#hp4Epkk zLf=Dk<nklObGys|dR|9Q;b^z(23vdI_8$gT4 z`vj|5z-XW`&9iA_r5+)!49kNvr&NDBacHT?4TV)K`m=AO`VZf^5B}kMy2>J@mPsuM{en1w z)w8bX5$H=)J!0RY>O)_nqMV^u;B>_LH}sl%EBDRVL54YcqIE!BvK`CynAT+%>hWB% zG1DeYZ)Q4>=`f~4nGRw)nCT5n`!l_fX+Ne^cU|~808u(|pTKoDK~`s(YD$8nb7u*% zCesq8wU|=vPmr~l(r79{wqaVzv?J33rWh^ZZgZx^pg#1LL@9P3=hVmH`(zor(DxC1 zTu*(n7t6hwQmTBiC)2)6uVYFn^vUa)(%EC5>;sz4;iq%@)8zV42dN&h0jn!^z}uG9~3GlS9j7U1dtjT_%T_$)RO(IGG$yrX0(6Nqd=` z(@dGoat6~DpxOLXHpi9C&t|hWve{?0q;E=Ob7*;-k~~g%9zT`GPvx_FKD*>|O7b~v z`Fyv4?-uag0=`?o`7PjB3RuGh+)ov<&q98-ko^~O2!))gLXNi(>)G_qrLrhu|3&0q zeMB;7E>qME$XQHD=dDQwr8?7Eq90PnW0d?Hr;tHWyFd{F{7~I!9g``>*8DY&)~x^5 zvJtz$KlV*fiL_<^Z8^S%ST5s|Et8q>Qzp}yQXS~e zdF;=n);C5e*Eexl3}%-hlnXILj$zsjbTFr9u%t7agZZ0tgJb^b*}&Wu=O3mJd71-sG9Z1L73IXfJwWO;F*>ffGR2OPudT zTwh^q4(kYCtyqrt_vOFnjgI~let1R|)|nUk;1x!icmsf<9|axC6txvH-VdPYOF;1k zu-D)jhjl&n+-jtGp6ZL=oef7{Pc0ZGyjBkFP|C4NWZg1TWYPLL%@T?J88C1 z54eyigkFjf0zR8bcQJmaHQX$uoo24guH_sfyG|gl5;B#a2Z5as4%bfn-i0@+4f~3A z9M^z7(*U+i3#>RlC7u?~;a#~!Y!feI>{<=`K5EKZ_~J(+tkSl|3TSV6BfiWy3}3RD zjyZ#7>UH%`^@-)MT$b0WZFRSA#yhq@N1c6~_qb-d7PuaAMKkZqT$%Y}mOCpc zD>W;Wm6?^Dm6z2Zt3_7#tPM4?vs1D|x%&#*RJ>TRtKzGQii&7uzsez%f2v$qd8qQE z%I_=BUTArt`-MMUc>TiL7ow3#k$WO%BbAXTW>4ua9=Xqk<=7B;XbC&@Y4MEMh&*f+ z+r?|rBYiRx>%RH205!Nd)|qL|WPlusZx)S*_L`|Z$ipEEQ%kS_@{k9C^8}+YukaAQ zmRIJy-ZjB>FGj}8GRu*NACQN*tZG?-tn^qOn*UoK$|^QjyjO9mqOwv{-dH&ld3XqU zI9&NdqL#YU~OxyEk~u}yyF*KXB|JF&<9%2TN|*8GWLgEBjS9P5S4Z%_TyFrh4}SjbL)%B zFMj@F;jzbsIC<;I@h8Wf9DQ=cu{%!oJ~s4UC;oNIiHDEw{W$$y>;~1>3xRo9;Y1B_ zHg_F&7Q2=^o9U~aP4GL*nQ7%xc-AiX{8iG3j{bJUP$lCo|Lu-(!?s`X%lz1Q(T%>4 zxeYu2I5atx+~p?Jam?zWl#XEMXY9{fQziW#w-!MwJ%DLI`ISB#GtFX;wWZ~KgBuk7IsU_(qM*$&k!xQAG05~pTw-- z6R7RWFz>ViGhGk!mwonQjPwr1NUw{-n8*G?TH?6)TsqLQwu+CWmQTyI@+rASJ|kb3 zAIjbGF6`pk2n+QXcAsy-9#gDDpm%F)xl}s~UujhXEKA+220{-$btmTU9u%4CF_En{ zh&;7bG7-5P#wHIsUfPdh~HqWqrIE z+R0btBsp8oMC)iE|0-{n_saI-GqDD(XuC*&J+WN1R~=MGp{=@BJ*&P|pa!YIYKR)D zhNTBg^Ew-T&GE6GZ> z!syF0tQ0HLsxC%ZwZsds3_g$t8T3F=m?(7vh; zs1MXxb1KJ+VP7HtC6~m<5!)b2J5RF5gk$^bcrLX z)72o|%5gyl)tkDPj@R|oTe`PS&;{yl-44B9u9~LXYKP8I_vrTM5A#&H?x3BzmU>us zR*Q5ewOEf;3-q<>Dcw!2&|OjIuGUGqp*pT_&?&l5ozyq#RNX|K(EZS}HpYm$r>f95 zQJj`T-;B}q4Ll2SlNg}WFyla9Fsr3UsgHGE^uvv;csOoe59)k9etcO`1%;YBO zp;o3I={T=z=y7^HMzkXxKcg4FMNiPTs=BJ4s*im^4OBxtN#BlM?yurJ&S0FwuGF8! zui_hgP5DRhgZNHVif_gD*jrQKIO+I8eC7C3eC_zkamw+vXwd5 z9Z*wrsa85$&}<=QnADy68g1#C0^dPX59*Er-$GN5VaV}yowLy2+E?pZl6SZgt zN^R9;+O6peDRpTkO1-SF(;iK;77b`dO6}A=beyiM-q1a@SJzYT>OOdrHBcYn9YZry z^hG_Ik?Mrm6U-&4Uv+;hpk!j~-ttAsS0JNJBzeT^J_$SpcY4b_ZJ_C_#52HpxRd4fTnzMXqp*-Sy&rswfqn;k zE3ggjv_eU5Pyp{FAzqH*Q}EaDu5`lvAUJG2i8XMX%oNfGgdt-GAjVTd)`X1w$ULAH zH0CZwE14q6w^DJP~3XJI(_7d;wbx5qP43qV(9cs&E^ zb8Pe#1oWE1E--*y#_fvQ7J5%%QyT(cs|mZ20eVwm7aG8R6Lw<*>XU5P)&$se+$O1A zqL&qRkpb*HZlBaf(c22U*Z{U4w^eGd)MsJL$xu7NTLP^R=BMCS0aqhmR6ZpJm{}Ee z8w2$Qc&ULxKVy>~0a%gTwyAxiw-z?(5kQYE?Dht*F@@d10M@3kI~u_56!x_S=*5Li z&j1*W3%fJW1@&Pvcvl1Y9C$YXZAMN6FEfzq!LKurl;$1=G(y1XaE7EZp|}Bz(}msJ z0Q0rn?{MGqD&*@8~`lYZs`3n4E6e+YOK@(OSY7hF9L{y4A_GPSQK4Ad*& zs|;Yrb6-jQB~~MZy&6~x|NjJk23QCAJ@97@U~vhX$_x-C;8d;vW@m&=^?M`a!{9Ff zTOc0?|1+=+@^|2*Ye1a`-);aaP1sb|QHH7le1`$n6ombnfuelxG~oW7(hb0(6ZS6P z@6Z+LZ8z`_$ZqgGz?+c0;QN66ki+0_8(1me2Y`3ro(cZ00jxq{Q=S3p@9p;uFcuN^ z2L`ZWgnbbB5Ossx4jJIwl(0WCkQ68B0+2|b{jq_h^d2$5ct+Tt7+_>0?4t(s{%V7xrI&o1?1!4pBqTh%?SgHkA(d%;3RZ~dS-uNAXkBZX@Ielu)i{( z5xRZKK%&g-uYuDD|6%ZN3?!A$w+3n{IF&1aQJ1jK7>Ie`-y2{gChQ*!FeVfBj|Spi z@Uy_rNZWkyUw~gBQ@y780jN#jR3`vMb*Iumkq$2yNEdj-K-%C@1A5;(%YZ(37G>(Z z9zcCjGr?~F`axa>exrdxy?3Hq3F<}gn}C}kqwJgmfkBW_hn$0fp^%|J=P+P6WaPm) z0zf?26D*vg4HWX~90QDn`!nF^9|&qQ_;}zJ$g{vF7^s)PZ#7WQf=@J1v%zlz&_Aea z!O=fB??C;`0-plh3Aq~hRNyYiwZW$us5#(w1Jm&=+NARy1G1%Dh}Q*usLtRh4;Rvc zk*sjtXJF-nBW?oOKrV#kLiv!fk*@Zevz|696tu;`ngD#W}L7f9%2SE2$P4M*wA`ARE1F{oc8w@aF7p~_G z1j@>_(LgN+M}I+3+rc*(U}P^`s9OXv931t4peP=cJ%Q|B7s`pCj)MQ$fb3t_R$v?4 zDK4}Jf}(U$901MVxn4HF*=gZ=#XymsNf!Xw%`PgNop=`Y$n`e^Mdi8606Uz83-z8r zwzzAzf%*{q9{|#*sLbgepnd{>!vM1p!u3xBLE-N;z!@9idecCn%v}2nIp1~fP7It8G<$OYh39sr#zbCG_(g-mtfI|GS&<2nO;5BDkHKN!dj z;6ED3mEdO$+ta`iAHhaBW+FYANQXTW!*{oVSPLF!Kx5@BFOVpB44st(84xIgtZG0io<%yd z0ze4zZ18j-6Ef17m2F_p2hTIG9|Uh;z;QK)+yefg&#dkS0(#4WjtFE+)j&7|vZu0B z4D6@D5hnpVzC`Xm1A#ItXk&mqfuf?!fNZ&n>kM=;cndgU>R+R|!PLeFnM{_-q5U7<`U_CSA@opfN%P z+6h5l3qH?4Jq7-Nf$j!A-+;yu6%QKduHf|ipM)U$zXI)qpp(GSP6)aoIHd_7o4@1-{9E#vK(e8fbFg4D3SR>jr<% zKz$7UzJaDRd|;rH!4DefM&KVBSn=S84D?{|j|^yhRB_lqQ~RZG0V@Igh=Cpg{)vHA zAN;6+MR9#z`q2(Lb;3sKLu1EOsad81~k5_xL_bCoQQ$rXYibP0Oy8UyhQcv}O3yj*B!AbtgJZy>$_ z?_eO#fOj;&>Vvp&tpU~{#Dz`FoZ?XhJgRkAR?0tXs#LGhY*o_ z;HMb8+(02_AVT0z8_?WrWUYaw@_NQV zQ-0SO=$hcq0_&l}bnxd4X#O^`!9aHee;(KfKbhb!7|>j91a+FA(LN$C0-NE7()^ME zR?kFaivjlIh{&G}XudbH)j)R#|BHd9a@=O1YlHt4*p4vsz+X1N3Y>_%Vu0P#0$)&L z&|Gn3hk-5wf6YL%h^D3EBhx zhJmJV{t4_wm<8Z(8ek<*MD`ib8d~Hn14U`sZ=idCzipu7zz-Pcy5R2^D2nG@1Kkt+ zJp=6pf8Rh;nm;g5>e`KJDA2!g}gMVy*l}ZsgVt_SE5%~l- ziafRj|I`4hm?Cn_K(q({%z);}@qIW3);LAva|5h)ipU8A(GC1x2I^PvlLop!_!kBm z(c$ZK46Kie$X5n5#~3+ffHhMQ`Pu-hry_FN0K4Z!TudHps^1tS=;`)jgxC6=hjHBF+e^RStgrC-c)-p z6g5^zF=9xL?!oSZA)j(8~(Zx=QOS-mk<7@Mry0b$UW~VpEa=6ptuJu;09!6Z)iI$9As8i&5%qKeFu#_fm z_HpL$&?>4|jIFRr@7}kcD+6!e|DC%Aw8I-qc{!@+LCAsJyha>(oNyU(AxLVCmq0 z{l28he{O*?{zs(UP<~y}tYvw#^6z+{WAnRTy_(aHr`BWE(-O!tg>W-vyxMQ_d_(ee z$!Zq7>)NRHyBb zov6AEq74}-q}9ZDY?Mv6j80M8n)z+6W{nGrnl@qmq@^Y(IeYWg6i;V6vFWTkzWOlT z*R@)WqUj%`4H%c(Gq;-WnoyIgGa9$c8`?bFXhQv#J)7VaCk5hm+<~3;d7@!S{hZWl zXe|o+if*{$@b(o45q|)vlCNVwWf4*?~;(& zP4`Hw+oLow+&+++#c^k6=cB`_iu)O{1-nGDN-_{tEY_PXSrw^Q#MMl8phHM&*0`D9 zX{vio&x*K4o@Xcq-D8C3ri*o)^70Jw5~jQa|3hBjC8yrM=cTGOLA}tg?I|qI;LJEe zty@rBZLY29%IJM5JBgu`IEp$k!&fI*l^^jj>RY17ED5_^$PYQAwpBHRM3Ie@JGZtg*_~1?nL^|;7vKGVNoUK_bdydK=D(-Y6Pc{8m#bi( zbSmkPgx5r(TRE&+QaftnD4wGWUN6#8IEH{@+}IX7iWWsNnSgnlLD*ZN#dY|ML{2*X z(LicSe4NOa`A)P3nO&ghJ%HB%-T_X$7#bI5T8$g~vlA?OBQ!3g)*(l>zh_W!pC@l= zG^Rsl{nUg|n;Hcj+jP9)Dz|EJ@AMI^yWG=x$nf+QQzMhJt{L90|H?aalWKT$r?~j! zU@*By^{e_$Y~TL++O^xJ)5#{v#~9?J?iKl{jeOMkU-MBb_hLTk%DR7?4}UBlRQWg` z)7sxRs`bG26B^yxJC=_o-P>Q=kMc2R*0|Eb-eZdYAR~cbvNSnq(z&6Jvl7e8%g9Sh z(YvI_#WJa%p|qnIUk_-Gla;W6|Gi+GE;@BT3`JuP!v2+NHu}nDP3zRES>s~1TFRF0 z|5!ZOcpPi5D8X9)V_`LE?#gLeQZubu*q7h?+J?Ht_&$yQbJ=-|+f;W`JM`w?SeDwl z*Ql$j3Xd^f2-f-zd7>X;(yP7anTj|ZzWs(asH`Tf1CgRp^!7GJ9CHFBdf$I9b4QDISsvCv!JuA=RFyAQ0B-B}-q%2kH`Q$-Odc|xh}TEs)p#KRv3FT<)NhABmY)b>(;ix zyl_%+VUu3%u4+*|%~?I8TKjg?j~4ZAm5|qc;B?)ib+ejvwB02+n3EIi=&YYXd8pRB zc}o5KbS^ve=lHJMK#UDrmK0x}cI%27OUZ_RjZQ>khbXtFlpt!z8e}x!jRK3sm~@xD$?zg0a{-C^uv6=t zmO1TOT$_?mGcPGEHQiUMqc8RPuFVEiN8-BHuN~?!tZDrl9&TALpI>X)b!yZN2arlm zSM-Z$2s_Yw!fdi%@{m2~uhkm;vWXE!lI6%4Hm;60Gkk4cjXKwK?|!mN)(r#er8g;U zl-p)fy{m_JFV3O-A)J8RfpF4Ac1iUBtPI(O-;{VqVk~e`nK=b!7{GC`-FaCZLltvm z3>sIzZs^+dT5$>WYf-hzYnC^AaE?IpC6*L-FKey@N+`84NN(EAj`?tdhAJ}9` z%89r$FcNQE(l8M%At!`yZde%-r@*?TR#UJKNy+H>q_oIVOR>7u&dbiq%^@R1lU)FH zRt-y_aY`EAwP*qU+*B%f(>UatwHnnL+vuuZbvo3~zAoRBdtGvZ*OSpa6d9VETeCwb ztE75foiptk7uRXmrq)$C!Emhx{T@nge`kY&&Y2lq8{HMms*&ppX2;UpOwgtP~QKp+9~ zfPlUGzUSVV*F4}(?Md%g<~8ncDCF@)L^=bGT=LtY zn@nfD`HZ^!-YGanh#vqRiX_{^Epn6fu|WwA0xzE( zN9P!R2C3xoTMqeVuosGyCM|@IYTztz2rrW|1vwUL!{Uk9(?04aDXZUr&Hhp1Rqt6&Q@WyJtD0O=O9XVYel5Fl~U zvTn-a*A|_e3l^fu5K4JnrfH=qQ3@p7Hf@7Iww!&B!8P1xZ%=7Gv!tIdOxWFHez$KU zVQewmK+LJ}`?C^Hrup)JkF`?eiuHUs%cFX5CK9H3qrV1v48hy53@42Y2B(aqVwJ1L zvY2K=DxQ0qWSEfX5EsKaW|u8X)W3fL#Q%bGid34keqf(J7;nyoc(Y6XBV?BrfIaZJ z4j(Oe3_iTaJ3mmFD^fW0N)bbE(L}e3Lf`-eR#2Ur${o&r(oKnT zi*p1WM-05E#)wjMtA(#sT5I85mtNUbo6n3scK3xhh7^(U8~AtU_ACxNyo1SG-r~q_ zUh&=gi^UbJ_=>S$D_*=*%FPsdF6s?93I`Voye>OB6_CjW2OJr=2gUg-X!n)@co+X- z`c!0Lcio(lfFM(j_Eb@{$rsBKMjaHd1eBn^gG&lb zCTnp?uk_T^S+0U4vCh&BO{w9nC(gJO5>dV^UdahU^TIuQ>fXUR<0i-IbAxkTwNfFQ z77;PP23~;OY(T_jCGw@=#bk+$1&PHthsFch+b?`+Nb=WQIu$V(mCfSzp9Nps+K*-b zf~w?pc$>dHeaZ^>TS5jspr3=D2G(qYmvkC1w{JQFvDG+Jw@a7H(HAa<{=(2d=#D)= zCOyoJbXTV2Wy%!+HOBA+Dwqnq3tp?nGh>@CV7LHk=xpO^;9tD99&TMG$Ge+X0ejqX z;~GL=vB0>_YN4;#mrlhZ9+w6jl51z}P4p!Ve3X0^4M)`a@;M!AR&gCQ6kzt9%ib`e zcU8NBn)Xt_JgLzJi=o{|j&)-R8BO8Q0HMk;IO@;+-hte{Z9|Ug0<9v8H5rHo)!X7b zr>)UWgDhV>bIzdE?#q0^NVs30Y&|E>Zh6pEnfs+uT8DEmXXfo)bD8SLy zg1dx~=J5^qXdY5DFKwd2i7FE)-=c9um;hQfA_5TJa9XkkW)*m_=+%(0fC~M_H5f?a zlBVB-t^f|4kudu4B*OVfz5)Lb3pM%JS>dldZ-9?JRyrRTgF~GLSoB0#`x$CJpG6!m z&RzD5PAw-3a%TEe9(3vzXNK;mE0~!X^M*=)2s)ZH?+6N76mq6Gx%n!Zdh;U8+~s6Q z{Bp)!ag#x-5tL|d0Ol|1aH8tpQG>Hvj0A`rTby#KX=SlvH$9bCdq>(FZtJ3@_wGrN zKbP|Rwhh~Z!!aT#yrIZtZ%R=P03^d+4S8r z4YIL0!y_V3a*j^K#BRhczSWF`TH!U`pg`kzJ^S%!PjDup^M|v4^y!>y{3A=}8glzV?-}Eie4T^rXW*Fq^&T@MP&(lET2xRYG7Qq58f5V> zw#aA8${h5-d6p*!M8i+pirOJ)*NWLmwicO}kRXVs?CLmv@xatz zslVqyrBF7fQ?=1#r)Tbfeffdih1p%n>XkWO+ta35ZC5tX z9#kYvbC!kc=6Yr`7OQnk=QIM|A7+R2!Tf@jFQ zl7l~Ow3TI|1z9SPjqI>-oviaThi#Hv(EW{Q+h&R)2=ysa*T!ytZTo#)=7Nl==)I?3VL46_`3^FTunrapDzJbt`n<9ZX zU$tn8sBJmN%#thHpXeBm`lHr^w>|W&R56ouzlCR-_>#We|s(kI8Wi z5|J(r;~8*J02(3-5ml&06tt;Mkpa`XrfFtufZ3k96+0Mp!3!U9I}t8zXZfwbwjpug zG_p-91p^N^9g+yPg58x*cDVAzg?w=eYFOKL(U5rEF_R_mh7U7WIhgElB;2kx_a^Y^ z)wa=6$F53kN3MO+E0Yl=hm{}@-A__Av!$!eS&BudyA;WTFyI$~oWq(-SZGZK&bcP@ zSt8?DEg9ahCL6xT(`E+y9FncJX=<9tB({w&E#zoEp_DYAzFSHrbN<@P3xT#yX*!^A zxnr!cN#t3j#ItG|=%u~>T3&SE4){qkr%-V0jy!y5^i7KLu7!cIaY zsX^(4TVxhM%#k-9%texGNLPYcj(dCwnur)aCc@r|viZ{}TNB=~;0z@-mNr|9oXb7I z16Lkgr)I@ACaAPgpHeYVYtry#QHxc{Dw{|cV+^c~pr_iq;r;F8hU@iqxYgwfSY2DL zRgQxvsX=|%J`TNBEBS0F;Br9ZwY3)PX+Fj+*X!xCVq^2SZ?hyPo%Vs1`ti5elnQIw z-kP7;9=hU<6YKN)lj4!>&P&E4?IXc~$y&t$!xW?!!z4p|Myo&(;5c z9W;%#+D-FBFh?#x_drUIT!vz{hI2rzgeHX!6R=be;U^2QS}t@KyD7>=auD-uSb*#d zUn=Ga-%ui%5QU<%As3?9{@cJ?%cA_FJc=`BDxb*Bp$Em0{ zY`6wmy=+;!1W95b_U7e#>cMKIEyaOPRR*d9?YUIBt(=HqXkK$NW!6I;Ig^epSd<{T z+FC0ZeBCMr8d@S33t##kGG}*0oteTyp*)$)M$>ykn!&q^UAG>~dUHLwwq)sEyWelO z`Tg%~d7x@V`y z7RMI{2f9alMmsx@>EgDS$QW@->0QJKT8Um~ay4RWAS5RkgT%GZ0hnsQ8Sf1{a*j>F zAsPdG^1B0pludi$Y(RRizt5j;cPr46WizOV=0F!Sem_siU5?FpM;DzO_wrWciv{+6aFYZw zhN}r|&M8!P^oBsGl=JlGq8$q#INmSgqX=aYi>5KGyYGmzk^Lc#CLHv-#Sy_!L@bMf zCtHk&)Lg=lHRD+2O6FQ*|&K5Sug@Cq?#KB}2rT5!iY2RdU~jd$ga z@yPDS2Wl5?i`YABW}*q#K;32Qy>JRDX~5*_?&~eZRnC;wKQJ7f%5KZH@4lp-jEQk7 zeLUt*P?v6!fA7Uu!(6cL1Hc;b-wl~=VJ;Mw5_~9hK=A@m?<69J-2=;k(#fKl3wlPw z+Gt07s-V@Ro;VrVcg=L@)i;?cM|LZ=>FsX4bEG5Dol;{snO*g%Y*)r{@cvmxB`?~R z5{|FKIC30fsPS+Je)266+j19&APWrgnMDMZ<@qSZ|pf2{_6uSbLW9KZkmxN z@q(n_NO-B+bZzY?vM=&I(EGFXHY_B@X1pGX!(zoFB8S!0NZpik7L!)VW#k!Yty3DM zXfTor+OV*iu}EhCDNTS+r0}{sPqr(E|KLO0ANxe?l0%ohzPoqjwkgy8J7?Q2m_K;^ zh-dE~8F{%%v4B5!#uE2q)n*fI<2~)>3467}X)r?{@Fh$B{xM3Wkj5*2HOAY;)#{Zr zSac2P`sBfC4O&L7yJh&v#EerpS1IMQ;?TR~T@q7x!J%(9jn8W$LUb}j%h6FE@fV*y zze&062GKt+SWSMXv@T`WV{R4FtjSwf6h*!i&&SVOC6NvlwHXOR&-HvZm53K9Aa1XP zrF6V4(IFH!WFCm&Fb#8G`HrP6(IP~rrP&OP`HN58if&qN-#M`3wi(3KC6(3eu}F9# ze)uKK1>L*q;q@8dB)=gq5@+K@DKGL|V~6c{j=}EzqkCUyfc=96U&QA48$_9g{6osq z1hLbAIG%((@|xqeHW@T~XDfl0Sad^(MUnj#5QFHi+Iwgvp|CEYA>jDUhpHDZw6jKd zd~#{}q3e0}{13Lj?dm~aCv~02J7qe&Gf8}4MN-84ZFr}oM{dXk^9kO z!~Ee@MY+o%Dxl`l!P#7mc3dENZ~?~X9J7^AIq0H?w$j!+)F+Ow48?iBYTau>g zuDrmT*GE$B)!X1Ladej0^Q~7Wec=e!^6JO&UIX6i=i2L89jKbuLI+!y@!YbQDTL*#*IgExz{x)UPV{%?MGOnDN8U=BqRZ*^qc`RU|U_=U_ zp9HW{`JiuV?0biA=uoQUJ7t30>1ofde)mK<PGcD%hWs_J2aFLSD3&#c(G7w2} zvPD-c=8{9*w@SW4sITO$Y2>d`a2-VrQc1&<*{#b??OV>s-Ic^-wLfvVQXXqVe$C$B zpVc{xtJe*kI2!3&%$XD#=?6@lv^JQF?NK9>1 zjmZ_3&RTuQ74064^o|D|xoqg&dacKr2>QDdHtKEOyY?g5&)HF=J7WLw=~HIFL7&y2 zk*hMH7UQ{?zGd@4#Cvd%oLlQxD(K*b-w5skt^h1O@SHJSdbkwk;Sn~XU~MbrD+YNs z1%M@wS~EgS7b_^&O9}@qvu2#B9zKugFM>vCryT z*oYfAe>bvKrBVjlU&Ihp8vgG}M`QWln`@%zz^o@Y*yjva@-An*%|qfoIcT+Z%{!{W z$o6nFY|(il)_TW4PDGWpR;!PA{G+|KnwdDR+Y<@G+{_PjRZF=*H!A9YFU{(8JbFSS z*SyBvD8+h>l?c&0EG~W~&G<=gy>$d0o-~t90uK~hr}`AAY>E~5zsXo_Fe)zyw{ZgG z%8NGatP&|^A`K%Ga8KQs&6En6TDBI8CE&Adu5K}zPSlI>j8e& z9xxSVb}#we36zCIQX)8_Wt+7l7+;+7x=lK(Q}gE@kH#5yIOao+fq~WQX5Vt$H+AEI z!LXy=-_;`q0h~6!H#$;C>JZkB5SjlgW6lSJd~&SN9|SIN(EZwZ|m#(}a-h z>=8Max8D4r1bs@UY5x=fO(|E9SVmk-)yy1fP=2g=UJ`GO)3Znq-*|03boynabkS9! zN7o{!T@9CQXHlD=|-d z=?_|T<#bdO+gzRPXOPrDC7;oKq(M`R<$-(970LAf2|ntC=5b8ocp49A1ulJXe89~E zzlZb#aH9yg0~1zIOohsM_+>=SM6pNIgcAsU!!0UrP!nF!a*Xb&n>a2W4fyabXEK4n zOVc;{Ld;L&OT_FOn4W*naHxKdQju9_deW3z49}L%+aJ>F+%1NOfsl^n!5+5#T#)3p z%_>JT1SB+rQ*WrpYnSTrTACf0uw+339ilP2u1;OhGrX_E=}%7kbAgH1?4NtXd{c?4 zj6`BR6Vb%w3yJQVM+!%F*9x?Q6~S7nJ<8WJn`@6if_pF!z>{=8jz3iqZ{g<0Or%f_ zIgvz7)94nJYem(Xdypxd`z}zlKILp z%wY9cynE26?pjDIvORi5xTmdSPrq(v_2cdBVwmtJ&VWJjj50}kW^rptq)xpXdKqO6 zZmZ9?8L`1+4^p~SP=-~wLM1y%%9;{sgDYBwT2DdV@b9V>k}+5+CgSK4Eanf!3yA_A z7K_J{v7%1mNu`>YDk$lcP5)QY;b**f#`!gNNkC_E*1f0rp~Q-m^EgfpgJySKPBmfaQB=m=Me z$n;S9iYo!|rTSGg?;`rnPuX1mgK9vM(vQ-M1 zAk7pk8@7|hY?R5SkVLrQFeIaN7~G6*vBHo*qO7QXAiUSo=;$b(0n0`!w==P9TO_0i#8r8_W1C9lpUiaaH&`tFW0%eMq4_Y^~3E11PGfki$8To6Q zb!t+!gGAquI1X4+aFI&BDR@Jq_$&FRq0GY_*#QEII3B)AGG=+X*Ab*8;xa3n>z!{?~PaHL@Y@DwYy|2p3nmIr3lrnWZY zut6CM#COlwY+ie<)4KYz(52TDBin>f%3Qr-IXZTvn}(4)Ksz~cYaNdGDRAd z8#oE#xP(TFiq=M{BmBu$k#kBeTd4FRHxpHQr&HOip!g& z(&2H%%_hCi=X2MIM@e`@?XaIb$1cj~W>a8N00=24U`ZAbrP*)sH_N8Wl2@DDZzQ>r zjfxpD!0_zkMP1|mN@aJ4DKSzmRSfBLzFeJMOkFqR>2JH>XpghI7Ts1(J4-#W+=a0; zyw|cO-{erMgZetuD|B}h<3_~Qz<)%W!R54L!ELdkuwz~+o7QWUaxTn}r9dusS}meo zPV3=CteOA4V{5&9%6{WDg}aubfmc6_Zv@B!fsSt(=VA;Sz=w20;kS=DQB) zH?N-5fHN>ot0&#QarMxAlRWmThmBO>hPgfUojDs)0hI$mSiU011$nBtlN@Tylni-W z#BtH;AH^Dk6*=rELQ6-L%#FUD>Tt(!TP0l~70Bf<>eWgzIdqbjLNWx)=j6TCv#{Q` zCx*h#NPk4Dl_h(}W5u!48Su{6>g?aPb!3VC(q^c{e$5HHlGv}wIILCAoY+^|IUipN ztJgX3(aU)-;A4|Qv|h2N{Y?feFA)QlXyN{LgIau_$$W3l-vy-3;j7CDMKYTHSXEXC+?u4K)BC%{M z0tD}F$b0bao!~Z-%-aE&Q`C2md$R;{N4>X}@n8{Rs|j%tl6WJCZ$1ILM>_Gs@CF{p zLTrJUjG5LC5`KVpcW0?UwzDVzlPtLo?=uA8c|&?(X`yXTM(0gif*Y3fSzw$Bx1mmr z7I&M~x=juiziDBg4Zd>$eN#O0TnaUQcR=T*Jl{+`A3tYvKRb25~Z2cVVeG|)=iGB-JyoRLWKJcc`_R;ral zmra?h8&Xf+zqmf)e_?aHZ?JbmF2?=XtY>o`2H*)+xQJTEHi{B8peW`N!1F+GY>4iF zKNj(~1=?T?$H}WewUuImMMI~s+w2Y1Gpdx8NtBAR+~(-E^dFwG)N`}#+1d8deS2x~ z?JswySt&)y6aZ_mv*4-aIuG^VaFZbB&i^U1X*45yLmi>08*(%S-@XR9A|Y5Rz4b0w zvc$6~-4j~lB^1+#prxKKRN@*5Q-yvw6UhX8$o(`LH1HFJScv>YaVa(&CGWBXV)B4A z64#v34M`#YOru=X@3~`%fINHUuzm^?@Z|a^C*-C^9>^@I9!nSH*u2cy*NZ76ruvWq zva~mZv=3^xfU(zCe=LsO2Mz!WlGM4|a2QMzRTHG&w+0^YM?(H&AQ_7!qb3zJdzOT8 zwQ+zg_}T#B7JS+7PlM~Pk50^3!E$&_u0zUJ`LUw*#^KiJXw2zD2Ut&Yn4%rXf1f>y z9`P7q09sQJ^;oo~Fj#WTp5ZrckLS-c2Fbw4%bLK1vKd941zbzer$ujmBcjoLQUAo{ z6VbxhSfOovcYC^5A2z3*{+?nWia>a@bm;E=+i#m|8iojM1Pc`#Iv|8g@wUa0Hm7D> zWpJemF0;?&_9r|jz$l0|BF0Mb7z=j_kVamEMY3lxlda9!wlinYPyk!8v}mjPsxkJ# z%j&Z?&n!$>j~o{>@8Uea_wj3yDb*ZqZ7hTWW>3PllnM}_)mn9NUw8RZq6Nlwh>rf_BT-|U&bk=_+^IopGP z!YEG=pgu#lVc(Zi$WMvZLrw;bZ3c1CGyOiXUS5elOdTDK(%^im{%e6`E4m{OpMMx6zq0j87j7E1D2lcI>Z+H7_VygMB+M+ zzCxq61T~#-_+e<=+62aJ0Nye*a;JkrKH|2Lsuck&9E*7ueR8s8HH=?5g?=~=IdlW;AS#Ed_(7A<$``Xf7{vrXknEq?S)D@1P-e}Or zwI|FWr`2y(Y*T647CJij6lk~)x`)(-PR5Sg^(@?lxQDbRux42=aBZYmGW=T%MWI9l zdnCJ&$f4!nLLg0n9JGyjKde`_UjKaGwoTQ{;+iA>T^(K0RNR?8pH!UC$b@N-u!b3E zHi~UDVB9Y1bH8Sf;eI7|to1zleV)&|deieTnTa(w_J*289MVy@qTdoHb}_mZ&qe?e z**73(DTP5P96+Q%BPB*jw|cY4Hx0DC$@SX zT;}i~`HIWQQVo;?qLO4+63K%|3m26Gr)=7)R8Hl>v9_6dTBTR3v?^;l z!MYQbT6@o!{7qUVV@@A%U_(!fY3`HgW%h1#+I+Op>+I21FEiWPFW4C9KSO*KJ(XAp z830IV-zdO9`xFM0&86d+6e)x?(OnF`2r(>3E=aGjxHp0kR8a4V; ziXw-3DJRs&&3R0`%9RO6%>W1Fijb_8*+n~f-sG{(87Ltp2P$u19n5vZ#+C1Pnl>k;cykvQfc^7beXI;yv-iwRja8# z?wCr9bynuv+S6BzTzovgt@bvyGWp4l?Tdw;zF4RyTD)XW=b?igZ;?x5$N`Qrj3Ld{ z>wPh}c`*o?5d$zlRLE!IOb&8^0)u1>iff2rM~n-6Wf>0ukmIp%ES^@Xd<15}-V*c4 zYBPX~U_Iz!(}>xR7Dje%@4n$+=Y`qak-~*LRt}Di?cO)m+r2H7O&puJ>IyzOvBMzO z>>lr5?l3#HIo#7j!#&eeeU+|=r#Fzjm}meMRD24%d1z2Ug={k;Dt{1@hKCcp0O|n7 zkM_Yb8PLO+kIiYdS{w2bEM~JwPN@V%4FD@HBu8y!rg!fw`bv(LZ2O<6UM$ngY*Q#`xIXX5w+{dHfA@hS$TN5!qZQn1}_lp=>M}j|reWt5g!@v`gx!06E;y zq9I3|O$>`U4mI!i$xG~IlO^XI-CuRdl+JDS16RN1$jFt0KOLAG9c>>=Mn+h1_H8#z z$Q9E{Wq#oZDD&8nt4@yGb=%^Ok%eS$D6^vmIHN5F*7>uL8PKD^FKNXA{s-~JJ4Ma zj>-HNTp#a`XD2c@Uv|xF+eVU=$<7OVYva-0SY>?dt5dU+v)e{YL!AI?MRCAlX!e0!K=LEue-!n zH7AQ>`#M~_(y=tS|5(?hqa#<=f7(AkJXR2q`~V?Yp_ta^=U2KT#bbx3FYg|`^R{i< zN9Gei^7c-k26E}oxv#Nf*!4@`-c0)oiO0UQ_8hy0e;mi|;Y5~+>y~irUvW&y-5a8H z7+rggcVqXIGUe*Dw*if8CLFTc)GGAEfUe0;g0^wA5nTkuAh;OKq}oNfB_OSsT#rst zD@oaQl6P3*R&-oMG4Mxfa6tBzZ3>Oi>N3u&4B)pJgW2y8#^l=WsK=@ru^J}_hTMjn z#~RRTjB15aquX}08ukYIGv#Gs zQy*Xr4O4}mS<1cj@Iqv~-|iY33(bYchst{8*yB%sXgaw4W6x~g{mG}tLlaLv^++!P zo1XU{c;4gd&tn0ZQ-Jz}H0ET#ti~p{dLaM!mQTcZcVWEW#coMT?t!ke;rDc0LOEss zh8>);l(T9VAeSORhm6VEqC8cWsTif(LI>l)Si$GP->LElj;Wql+-st2z;Y61vxrcK zQ`Bw60>A{kCls`i@GIhy|M`qywEN8qI-4p|F%IxX1lw3Na zAh)gD^T;~~9O(lEB{QX*?RCwROm4Q?;(Dc7Hw^oL-}# zD;SD(O+0;3=G7;+NL5JY#0G4wHQZRiQo3V6)_R}HsLWTx z9dQ9W@NTcTvR0j0yK#AT1rX);E``dDx^~g-Ac|9f#VDo79h^os@JV*1+~zQ|!jXefV|@9d zC8ySGH7gaqs~`HrMM|@E#%NF}yY74UeaESwgWmZo)WDpbcmI4VR(lTKZG-&J@{fwV z8yQ_L}wZ>X~YF^=^hfTFa&rGGL@Rj{I;%}@~;v4ua*=c0}`7eG;a^$an19D^#I{|mG?>lTr@NYDiFm2uUJtH-IgiJo&UC>aXiRm1gY}?%tA8gBS z?{c?0^tQA$owGzb{F!om{MZuj-}k1Pu{>2M&E-|9X{8X!S%a0NHs-GvUPGembXkVq z@bhS;aY6(1AIIrW5irMNn3p1)2>Y zO|j|okhBazblQt*2P}nqWk~WAtj64UD!<^?`3)nHthv)NkoToMT8+arbn&)SV16-N zoX+-jruwbnu4^aN`!3J7r4Dzt??2Q->k>YB{PQTr<|JNjfk*^^&00#qp(HcWXhF~> zHY18f7;B;;C@Qu!yU5uC`GresgQa~Fc|~|K*1j_k-qqgyYRmk4pLqXbdhG3QyB%%E zl=4Z1pcdc9tsOxZ>W{+5qI_B<@fA!?lKp~G>GO5&v041)K(wef9DI8+e8GU$B#fGB z?TawaX}oM3_kZ|zt>15jfI&Fa3E04+`!~MYUK@(>r2F}Io%R5>_T#nha2Eb%SZ?UA zjR!K?H9_h$&(V$qI3Y93RL+cGE}JdL&VR@A5Z-L<&A9J4cB@3Q)OtX#(^7XUKnZM1 zzwRP5a0VXcoM48VCRuaVoPtwGnoZ;GoztG_dH4K2&zO(Dq4Co*ho8^ijc5NGp8YNC zj;o>CBXmE)wg|B7`5E$KlZwY^m(SKn0R(ZN8cyR7%~OmNFT*RNX4?lxT~}S@8odMK z9&%rKg?s4v-^ZNrju_ryU4IAkTrnq-8nsi(3oMx1@L&#ILh&NFhZt#|7*XpTQF;T~ zr<=`pNbmTdkH2=veZ`e{2dOu>V`A;6+~>I);3>6|uTi2NI45Ee@?pWD*D~!VVs1}7 z?s3OD;(N5!dZgd9>LgeVB-4+VYEHE8e) z$F=1AE;Cjx;4 zhPrzF#xbKzn>pbxKIfhVwX%>>e@{J2K0Ux`FZ9{J<^BQtM^F!7HNvB!lNnkHFkoh8 z(akLR(L2PMit5S3`jE>OH1rzX%xU+bg{a5X+2soQdR0od0Udv&dH;a@C*WrzZ5LHe zjKQCH82&0AssB80;pa41h9-mBrm^8N6})QzW)grblc!4bJV+qeD;Np@tWQ;@&dNa6 znh4(dg9XfD6xe!zYO9NH*1i`*z6Qsj%mA$AZG@=?PbbSWo@^mlcy^{M1 zeAqGgu)jb)Y}qHC|3b@$y?5=~u){vcwQ;-ZTB`+iWLWg8PM?ZVk|eOoV-++qMmtjnOwnjp)Z39B-`$x?50v}2wast$l&hsuypYUALI+07 z=3+Q2=BZOWw$4J_!ytsl{v2Y?0G8JF4)6GMu%oi_8P~vxqoG3gLn2OeYd>Xghfj#& zSSxi6Nn;anC$w=Z0SG~m7+kx&{3P*^W!!>lajJZvhz3d@ymkNj6-{(*IFFK^a0tP= zcqkLjP~@Xra3fd%69_>&;!Qxe1e^b*i$r2o|B-{e%c)>wrqn%P$tJ=v zW0FYN8;yR+>~Bl^0@Z=s;srkYnAu$^1k%~C7iy&Un@y^K6t}JU*lYODpx$@_Swe@n zs}NE7%ep6wrhTL6ll!X6=BMQ<^^Mo^oT|nY21R53HI7R4mF%Pqu?_`iP@IIn1bPB>K$40a0cMd+=qO3xjUp$*VZ$CM0KKf?d$-%10KNbS zRx34$UL7sBE&LmpXG~URvi8vjWrCf^-4)J=O5f8LMuHT}t zyWDmqy^Vz|4m2@w1Q`8N*<4Pm>Oj!0*BW*3`UgSZpp_D6<$+DKax>8iHum8M)@kK6 zL@TmQv?733gwtsSX(#7FD*|XmV8Us%vcLGCJJfE7D5FEzt@&y0)Yh z(%p#Jl8oAdRv=capb%$^US2eGcMn_PEnewBoy(Xho+r==Iy(9)-oNcWITL?+iV-=t{MS1s&bVyg-4o~`&9+5G5Ltt7ZKR~jfI+jK4k-g=e_bJEk=RZ~z zO3&~QOtR~*lYi_2mHfjWmaF!Efw#H-0#75n*= z?M}fSMNms9E9^CExmHa^1v`s6(Qv3G7~;;VB598|!$}|~MLDUc3PLUVh{|| zd)sZFy5o-9r|(;RaHa1D?|IJ;-pf~y9$o!jva>UZztyQp{u`64f9Q@x-1u8vJ#vJv zUh=WYd+VRxa6A8v>GaCV^Iv|?zdiQe@3S`^UHyr8Gahho^4`hGdkrzCI~sL6WB+*M z$R(G&8k`z>4)jO96R{{Cq78^f71|Fr&~plMF$%_TqX>MEs&i($1UQmm&+J+5bx8INwUZXf9WTXP~3uEtfWXc?O?ng2RB zLHtj}UA+2?{O7VMjJ_Wh<4*2+?iP4Z-obsP{;Ui9FUVBptM>3Z)zO2zRUcRI25TwH za~37ygl=S8BFj$3n3kyprd~l%1|2KShrJq)Ol9)%I=xD_Vq|(dvl{f)6|5Vl#o#;~ zu=!;cxr^r<7H%cT914YF4?Y|eM|LO{@4e@?TW`E!_pX`g`are9xcl$B=NFS8u!%PlQ{avi=h4n4QV!e9hxhG! zYS!*f)%2Z^;-=1|$1(eneFvVNf=6-wp%L`Y+a{BbKE$eCY!Bgz#h*8zuYk)D4`arq zVxeGn2Fq&x0(Cj)uoc>iCHyXFo_|tPiZ^bMr5iV_zIE}TQJL)dM|j!T!{4i+3KcKf z|It~iBiX5`)jB(Mc)5M{)ZV>M&N%%vpw6yNgX~v@b~UW$Lh*}nhbIzqc|#$u3k{;l+h_j%t&Be^N8@(8A)wquPYupLpI8cX&e)H(lzEguIUUVzD5ol*&?x{4EU{ zgs<+CqAq{>?|JO#^<()w_UXGDZ1{bo+w-4b?`!SFw;ShwA)jY-UcJTne`W9E?`WKV z@bvTG>(T8M@ ze(ZtE9S8%g{?cVAcvU)?LJ1QIWq?tOE9eSF>eZ~J-N8%&J&>D91l%Ea%qT;hE1-w^ zmr^J)4Hg6ZHsA~Ve(qeq0W6#sNMwfBpUJ0>4EZ}LgyAyWUR|~ef$|We(f=n<{)wX7BPUQv6wsV8^{wm-# zhW-=;uT0wK1-!;Lz>DC+2^;oLK}GiE5~Q{hq$DUoB)u#!$v1%MhQI$%{-uG*b>WG- z-`KwnC$yNe=3YFY6j+WtL8?Jtnq7d^0q*4VsRGuvvl{@L#$G@KutIw{1c1!|U?`KM zGpuzC*U076LV)fnv?`yCPjiw4f3eF z?Tt@;`k4od{SUnN(R;2AX#b5;$$6(`=@B z0vAHz242oQ47>m_^vH7~MtUx~B+F4Lu+Bl&QFK<+0e3%?aQFEJ?v%it;^i$90k~5{ z-O)Gx@z=lcr*BVp{K4nG_Uu!)MK$`D!c>fx-2aI$eE!3CBvWtu%wK)|Pu}>{pw0^X zA$65@KmZ1FF9CzdS3|51JXX-?&7||%*4_kRb`=!b1oyYZp`{Kew0Qzmw^LNGx$_0> zVbVH|O>`ZdT9gP4Z%5z!GyV1?L(1{Wct))~OIU(t{siKZ@xmhywJ6)e?lk+7ic zqe$w8lhE86!WQbCX6 zfTZSpz->79Gbk1M1yM%3pPXYHzkRf$8PBaM9L~kYK zU#td#5RvT;PuH{MHlH@0)axAmL*A=q+a_wdX#sm9T49)APds=dppQ4+4SoDC@NjV~ zic7_@pufO*jl-GTdJOXY=QReA-`yqe;ci0Q$;Wkx5XeI|P~(M0_7AQh)(&W(@(@4f zks|Fj88s@3uu;54gaxeZhAJ;w@CqRghqh!kDZiYWfIi+EEq3YDvU$UE*;$p|ht}H1 zjo7<8+7ax^T7m{P^ZJ~iaT^^rM*}wExB#2~Nql+jd6`f7YWZ|RPI8v*1 zl;JucDbvj+SD+X=Vfb6n# zk2^v|rTw*mCFPh5|Hl^t4lkQ3n-cKHt*tf3kNNziIKKeqb06k|-{I5ehi<_!$V$u) zed%G!uE=@NBw|6k#4looHDcF{g9-1kn)TEn3^SvWS{5!LUly}TCKKf_p`_LU10ukO z;nc#^1TtG4@{h8+6%2)e|MfWq-zFFMi_(Mn?5^GE@Q#D4F*X{g+Ui&4Tuz?%$S#wO zDe?c(K&_v#wtB?fleQ*I z>d88hLAd~VQMixFS}my4rq9X%HFv`X78@PjzRpHW}Ax z(!~pJJungog)ez*RTr2UHmWO`V$~j*9jGqk@-umF+0JWhUdyyS;rh5E?h!m~=0X9z z`w+|Hqw@EmcE!zgimM@~2iy$|EC4u%g9aMa6K_ z4FHR_;VszRZBLfsp=~#wy6K+pZL{^i^VGf*Pu(!<@<#cnw|yk$@Hsrex+`R~d2MZY z0mhQzzRNs}y6e0^js-lp_S8?mJ2&@o?rX;TpT+x-0SN6KV~}$BDOkLW^+oaiD9Skz z@2^E=Wmz*|2cf7f08A7F+r#h!Q~{w2NiK{S-aGydxIXE4dhPt`ogsbGI4 z-Th~e(?cF->`U<{o`~Tg;s<6`TKgXR2>)SxLuLi1c?KFe!hSN!$zx6m;?+P<*2d@6 zb=Ehu`a@U5J?_eyZfbmbJp70KuX9IiGuEu_`G*=`V8YW|oSSO*$BXRr92 zN1nz)gd>ntYCX>2c^3f$=9VIjvLnXbiWCQIYy^(UAEZm>p=FZ-ea0Q6HlOtlQkPG= z<1818`pm~ryhM$6!;EHOPcrxFPv*XO*ORyKPp!(C6=~8UZ?czb;(3L76}J@ zQR+>^;7A24A)!ScuZF3&?p~EOL3@#0@3&==2D{FDNQs0Ovkdx#&g!c-K`u)R>WD)Z zGAiwsE^l7$(ICw~rS}LLL9@!QW5h&xd;D?eHn%52f5qj?7w*_TInmc!EW~4OmtM!XKmNmye)?md{_uz1|McTeKl%p` zz3-jxyZ>!(e#brUxbydJdDE?Ly7{K--*Celt~qx3byr+>_>v1RTfS`Hp6v&B9Gst> zSejfK9_$_O8?RLhJ;fgMer=Dp2mLPiKCEV)Q}2{AfeK36M~sjm8QL2YM5OLb(s8sg zZe0`J@O|U;8;^@2pvE&_spA{Rv+=!bLT9n)(2(ppz16D6_b)bnk2OA5uWg(dZG5tS zZk&1kKN?4P&FM#4*R1w6@4Bk_yk{FnnlHMcaf0_)^m?;dueUgxUz(pi%`eT*oy{-J z&r9e&`fD-yPO6YDLhSs`|>YjwGmQrw!& z3Za%a+G9)KS}p(LQh?S`V${K~dhvYwdI!U1ULwlH27sNPIlH_9N9}vR=S& zUI~pJ(7Q(1vGx#td*nOC-&e~Q*6zdaIUaJ5pv%8??cMmin!k%X!jv4<8$>_C^PgCI zh=8ns0Jx97sPN_8^hM219g)C}>i6Q>arr3!9C%cc&9DWwo$Y5UsLFdayMf)x?qQFz z53xUF|7d-^*cy58K`ug~0e?yS(E#FH3~H2xv*FeyB>%n{N{f%Pu)S!C!-Mhppw|8w z_XVUB%h5Qio%?;*+w7ZAEPbAm=PQ%gp65xmpodzEj;RwTZ0}cK*;K*|!!1RZBuaOJ= z&QYc7B|sC~*bcrIw2NAQ%!>bi0V5G`Nn7W08TEo(sBYUpx5Z|KrfrL*n#sMImzdPLaY!s~9LJ!zT!4iyX|4lF+{MczE{7QqD+IQ!nppa_CLq*9$ zu{u*RI6r}QI*3WpB45Ipiq>xiz`hm{KaDx$8%meNE`EU{$bmf<(-0j3lsQ0~Z+z6t zY^1ANPI49~sXiDIdZ%9HJjrQ|1-15SuAs)}2`I4bIbZwANS@M!(TdAtkn2@e{m~$E zhL}<7G_HgLI*nY7&0b_mxmtO&Qsm{el6((W>lrJL%1eq`=_pqnXPv#(&XvJB-#gIH z_x1K&A{#>3gB{Ge9cXWWZG?tdF1O)lcNhE?3WXwhs3VhZODU2`Ym(e9w4=qRp(sLG z^q%Cr&=oBw)@KmZtAgh{11{(^2KR9PJ=37=IKT1=O@j*=gP|2DWG@FDFfBJge+ZV% zhM(t&8z(09r580Rxw5E~7f+s*%AmoYYjU*;Uu;lWace5Wv?zXn-5^_2f+@X$+QeUH zBQ|J-b=uVa1&Gb4Eu3twm*4RjQBuj*&UZ#(VnRhI#>8jV~U#j z|LuRnyttlHv1c#M4$MwgQV$5Fa=e#Ei{2+Q=`OJEgTInT{Hr}lTNt;FG5eL)^oJNT611G-#ITMVql)#db!pA@fDsOB$mvY z*IW0DuzW*s<@Im?Z)Pb__i2VJ0UfUqr>IdL{S8gAA%OP`K8%gU?`5~M+t`ix?*ev! z%`#F;IQA{}HweQ0CHr6OYwRoR%j`?+&)BEfC)p?1$JtZt6#D>sKkj@Ke~++tv4_|L z?0)uE_7-**yAw~l2hY3*PrOz9dmX!(UBNDA7qdfn!wTMW1n*f8|K`z%ZI}(Re%8x6 zSqCez0ydq>un-F{IMcC9Yz+^@O+;{KES8TaqpkGOy3{)PJ|?(ey8a9>Btz*o6H zJ#3GU}z`c%pEq5dL zYVI2DRos=x55JhZ2nCDF2n1}W^k;4kn=TK-tJDMFSGgkmV_~4m4%JNu4;LJHfKL=< zjsl1JSt*23qr?zO1+UiVQ_N@<>l>)FL4kvef*ceob|4Xf@}HAQN}~{S-Kg+^{OWLk zHcmDZ-*738QOX-K7!c(bFPA#_im?;wLy2SJKbxMAY(7f-f;0drZKW|TJWsm7iOY~3 z;S}?%#nIVq;=@qljPNL4D`q2aY~EC+%cVEsvG@|*JZ75PdK70fAR4zxZ&)8Wz1`tt z7z=?9Z6O`Cq*B?c|3JM)sdW$+2z1eg;KRm%fP%`h@tRhYA{s|>2LO(Vi2Wv|Hl0cV z^&^x=h@dPLp(q58b@hH+s?!5mhbnJExVwOT?NUI=0xD1?q!soNP+Njir15TTO})~eNGvxbXy~cB;-)Vr&O6#c2CCUaBGq~MpDCi zb=+>hU>w0gJ`#4ud}yO57_COM%=I&+-eJZDsUblh3$$s}9+y6(uogYxjM6e+F7#Lp zesj^O5(J%sT_psSj>FG?%Z*&t?$zg%zX)mVOwb$kat8vxPuR5zwV)8Ra)Z{Y6`Tg6fR9_NH0+CJ%qqA0D7H*7=uJA4Q|$=_bAtIAuTd9^0BIU6@GI-wUl~rQ zG+wP*s|qKSny^OD>ukoXLuW0SV^OC;t+hH;R!_jFF*$8*Mi5W2V&ixI@Q2HufM-Uh zlgY+p{so`S=;WBGERV~5aZk+iRZrR5tydHH*Z)2mV6Ld580hGZbq{@6Zqn2Y z`e;IDkSkqr|6PKH=}dZ)%A!a6MP|2ZRAzrjuD7_f3OPz^#5l#H%{T>Iu>TI`{A)04 z6IM)>b-|6l$Sy*h={nQXQk>#C(U%F)B6=-^R-A%b-~WvJDfbibS~>i<3UK=W5Ivtp z?w9BsdYIdR<00Ig!M#fOd6n>`e;3^U2Jr6fxEoOg@N|ZTZ*p+|J4I921=(T1Hza>x zKo)e`)QeK$E?ghQ(No+9ARX?9JWwD)5ar&E`xD4>do|=!5Z>1kmu5S0427Za*hlcB z3fIo4>?4kE78t-+;5)S#KTD*c5n;G#i&-Pf2&13~14`as(YaI1!UYJB32ym~Lcn%j z5J1wv;7Jhz&Dem1_uo1MxVr!oKWE)U3rgVK|1v3oB2O<&2^1r_^>-W&_X9#UK!n!C z8?XY*`1Rseg`)+FQ{uDE8im8!Ag}Qqr@7pIcWLD1!-gVj5}mBW<_xbo;6k3>)8V29 zG|AKZySH|JTW}e?`!rH{WkEuM1Y3fEos(GM#qT6gOrk(J3sXG@OF0`@oaZfID0C>1 z%XW+-3?@a3bIVYSWXws<)DIh&0HGo%PI3xJQsW8ECD+JZnv)z7eymaRNiJcwW6!OV zkh77HF|yJb)@xM?Wx(e(btvR6rTnOcY5dF@)mT?NZkx=E&Z_p5*^V8WGs)DhEX%~w znLPy-EBEGl}FN_g#3ID|l&d27D+D>vdv)^Vu@xoMf=Ct@1 zOIj~;R#fx!!dUw6m{r3s+`^OS32vcD{I$ipAqVQSFAYnvOd8Ah96aEKu~o?OnFb5U zoW=syy^Ak$M&vsE>PwwbITI_ttQm3Tbf&zQ<^|3sOP5};X_3G5r6Q_OE^L@qP{*r5 z7fN;L@R@x3g>iM(q(`00{_1RqGFkpFd+z~YM_KNT z&->1tUgyj?XHM_E?C#licC*>dZhF~GhhzgW*;(d{2sfqWCtfs zCD|pjZ6`5pi?9#z*oQiK!H!+1i$wjz?upt*8{5UnDN)|$^HR}GR6El=HLm{$$JefQ z0BqZn^?|6mW3O-fno<9~`rp92$VU7v$6pVicAfZh5f=a_hqzbpmqiW-+c7R;us%3# z-$eG{+h^g}1;_1~WSDGGKyk}q-?L+gtRekm5ze#>=Uaxebt^{~DdW5yI7*}mF5MhS zk|^A|W>D8)P2&y`J$D$|$}8|3u;aII?EZ@T1suEo1$M+Az_t5bK>EH0eiG)?eM)ia zJ`AVseQ@aB#oY-|MMxWXEnF2Eb7`b|-mnPonR2b`NrepkyZJG4ndl$?pmmc3P|W zgDZ||_O>`<8`@Mex(4CPU;_q!u4 zy3jiU7ODYP22F=P{mFbEl5TnAA06R7sdja;=M&tWw^rSpeP+~qS|uPg1htEf?9;5G zUxSsUBWZ-7)&V?y2G+mx$=gxL<^$wIY{46A{Uh>+iryK%OaT%5Y8o!ZDD@*24r26UNQi6S@*y}BAn zV9X=*LHypzeFFdL@$MRYhshxVp~-w2_*T_Z`Y>8o??+sP;McZ#f3n_WNrmot?@i{1 z_2J52z@r11sHb|LGS|Rd`jq(`S<#;3>C@sdj?l?{%J7K}j8^8E_Mf7omEnr#(z7rn z2R=5(p54giJ?=pPbAA(60k#4NSOE&k3UI3QZ`Vm#4H&+7F8#~!+W#;1Z|@0SZRgs* z%*XuLKlJ$h4Av1Z=FzwCNI{5D+0!bc_(b`ps0^Qr))yB7OYTw91qbiLK^wQ21%m!Yz zcTo>5Z&`eL-)Rdr>pE7IJNB*}qN@hi(ABF}?_Ni;S#d|kNkvf{TE28iEE+*w*!Zsg zg)B!}8;`?1zH+6slDWqjMEcpex2ME)9yqld#BkI9!P!K&XmcOn+!oPoxnle_`SiSR zirX~TW^q1;(KpP!UN?(*&VO_)ZR3h^zBr5etV_Vz0|^WM1^2PHJI6 zv=rDRo8j(30V>Lc;wX3AO(b2Vj;<|j(t&bC~*Y3t+J7XNU%!??(LppP|y2K zW)Y*8PfVlPUWQ%GDrV1~w0AZxS@YWdS$0nrQhN3EZ*l-cb3&!vdpR$==icDxjQ5-y z>R3IrWwH`MYC6O$4QD~8Q>Nh|s_074*iU@JS#0XiT<>_m*n)hsfM5(L+Th$4G}Ty# zmdIP1^w!}Gq^YVbHt9zj2R!P9L18>;eb9eoUYfr$UXHIUPrr|ys@)JDk95H%7IQpjrWr=^~B{T-5Bykt}j00^rh+f%pW@6#+IqFtRLT;`a!v{hWJ|`zca1 zpXa{E{X0qse}nrvxIey(jER3iVC_lnQSPJMBizH>dx7@*ClvU;1Gy5nf!6<8paAzH zQ=$@FyMQ~F+sp0Yb|bX54T#)L$decZ*T*tA&a()xiNMr)aU|ip-U`zoXED%*qm|$n@qaExN?02Z<(!b25;5*+j7a{kL)O)!m^N4Fna|~mL>l{z zIxj>0T*=Wedo9)2rM)4BxqY^j4LA)-N8Zi9rkwupR zR5d7{tOoc3fKVJi1+3!*?yUf7XaKAA0P;bAhqrT&1I%$V^75`lHphj?$kQ`qDlq5| z;WsRHIzo05BV3X!`{E2lA1Pv%?btidL7rfeBtV+#g#;alU0#L^4+?lk2cj(f1bIw^ zQR=3+Hteix$TFNGfmb%{S`0j_WNwfP@GOA{ zW{-_7iY!3EEU8;>N!l#iN-a4)*_=pjiT6iE!NxVW+nV>{&LPvTb2OVt^Qk4RO~pcf zu`ZEH?CBy%soP~jbr00M?C5A4;o909ZOrHCgP8YEDJH&+pAo?fnz@rM@I)b5O8)!Q zLYl9rb>=A~B{P7PJ1!Uv2b5cWN{X3-m>=W>lW;9(MEYArR+C{|R?fukyXkem@29Yo zZEYPR7@|^J#Q+-k5G;uc5!XGUWSIR5InzG`tnN8j0M7ze_XX(xpMn1WN&KA)3e>gu zV?p5C$Str=u7f>t0G7#qoZ({S*atx1ZrCI{V2`YWJ+g|dAWIS3W_jE2WB};b2FoM| zh#%_8A0Zu54oNPTICy% z?Aws<+uT>cMEI}Rn7)9c0s(vm@;(D;pHhxb!E$*FdEU%&c@UZ2_X8OCUf3`1g6(n* zNOmqqM&LQv-p<9b7MmSA&HyNIGc1?&00yq)mSfjwgSAq`u8;&2FoIk#KkOJQvb;gd ztI+cdV*jeb0ij7(Z4M~Xs7|6-D=c-nW_GZY;xiKN%8{1LQm^p<((|fqgR8Lf;DT}=%Oh86%GNGDF|(T0aMo&* zu!)k$gl=nPN!^u4P4%&@`dA@Q1);W*tgcNfnd{0$lBm9=YpqmUNr4-7Dv`$f0CLo% zt7pGYk_|E`rd#!2ozhjEa_4wQD9P9)m81RjK zfET?wt5It(flNprG71SF`Rd^_^AaBj-(_$JLa=1Bv?UzzqE(~u>xd=re%WsD@Pf@` z%a(lpu)!!B0w4zV>!_z-id!A6-p(ei$#45e$iv%9^xImWZY*H8+k0`0y&eAqab&y^ zhsPlLQbvy)H9PHg2ffpoi5jdPU7^K(`1ey@*=jZFyi{YcTdW3+muO5M+oiY7C@Y>8yywXlV=$=EBj4Mkb$B0n#4DmGkzWRhGm0Weq8k~_8iU8~ z5QtfC!hcr%qjs}SXE188h+F*2d|Q3z+x7fH>K(h2CqBU_xfVuH3qrEnuJ~uKJ($#) z92%pEcNc>w)ow+Kz4wt1p(umiq_f)W2I6uBZ1j~^o<^rB|M9gapYi!D8Mof)Yz-QH zPJh_!w_Bqot97X_W<~Q|V>eH6Ib9&E(|Zhhqb?ZH=!0lf!0dO@jzs0E$T~MW&3c2) zW^lO!Hb-=!7z+tnITkhh+@84I?9o`vQK{^=yz+|OFSxuy&ev3myy4BVpm9b}=*$59 z`Yzo<`V}afIxuzL!hMWr;E?f>B$ig@3xVMc@`4Go32<5oZj91|Nz^!>n4e>u54RLA zm6&ah$uT~wP{)foC@@*Ur(MP@!Y?{-O{HAnL?#BsCDYl7%9&G6KAm!Mwc{YFmagKU zK}(diPqpNsWFK1)t4m&WvUl?pNlT?tmLPlc=g+e}HN(&}xZ7z{;#$YP? zI&GF@RLmHxxiIN2Y8<5w@{rpm_z})G`L#wPqWE5o&gpWPOo#q#vn3ZS3GvZzCKz*d z1(OAbRl8=p)v_+pW=t<*>EIlD!U*vglj9*F8pO_`32*4~_d?e2JsQ)p=uK4JnR1dai--EPJsy*iu-kG+Z9 zFm?%ms|B4s65rhs>Dn6egugxZKfaL3)YNXWrfndeXztWoo2_O?vs8@nJ^fxwC_p@8 zze#rn0aGbS1|cZQqUL}%XGdmkZ*D7 zl09WdF_Br|cV-N3sT|&3T{7qZ2YG!l)x1ET z*P`e^)IkKjH6sVyHizVpWb3t}gBRYY$&MXC6Gf3Vv--$kOD!F_L3=O#4(7)0>fDGx zVJL(CWgY*AH3wO)_$HLXy#_wVOHmZ}eAsHNT&@jz$<5f6DXc{+(5(!mDPgxSBE%ZQ zzU_c1;KIN6V&7)CQWt!fyRgISVWqOy55r%%85d<%=z$?{|z<()0 z0j6|+8Ngf?4EQ9J>9R4W_eiQNoYPRcxO zh^Z7G}|R-+Z&ZF8@sr&pQr0g_t@RbO#fC29@WcP z!`rMbz@Xka`HS`+e(A()n*62Mkq;j^>YX!JXcmbH`?fw9p>HH6w~kM=`}_%;+uiJ| zZ=(6RZlXllMRP=Q+qKzb%Tk?5Ha}nrduh7eNUVWqlhI`o0@2SjZmzem&2{&@?W;W7 zra>ffY@6#kXD&*L%BD`ro12?bo)$@?6I+gMljXhTbWbm(pNscpwds;MGB##+JHtFp z#?8^%ep`Fwuqm>A_d5D2wyxT~%kuO#Ae(P5d8@X&vh$vH6dT&Ts=e2QeOAQoJN=&f z8n!U6vgf{l9W)CDd_8=@AA>j%SlA2&0ZP|!h`zr#E8V-GpO3h%Z-@m2jW3gu!JnHd;wDpd3jO zhYQsYFh>Q-p&6PQUAkkJmEL*BdA^rHNqzs-vt_+6L9kFC7K!g0v+9WYyXhaW=br_8?k4V^u_mp+I<*sP*xU36Dmvm; zkS$yV&&(x=AXk4C$H1m^w!38}-FC2U#ZMj#Eu~v@r+8QQhxMb!2yB3e^!# zAC_-;s<$GFHqF$EQ@#PNnH#|J<7zrQsjKj5cY1;fUz%QpZ%ff|T2bMlHAmz$sDldG zL;*T{wp{GY3yExEcZ;IKH&4*vb2+sR&$KF6Yl9Ad^ztwrIi;xZZ6uZUrl7))O9gTG ze4>zt4xi%g_3Upfz@OC#ho5H78Psj!Ij!Ef?W#p(J+@CY!M@IIh3&O>#@ zH{)PbO(WQ_ci}h##>QrB->2bVc#W0V%va&)!?wN%#{!Tt<$xbgkO;8jZZIUed` zSMgbB-&u(F%;G9mLl0(&nof?HD#1xBoi8_WH72VdH=x^(8yd{gQ2syB=0*wj2L6vL zMu*DQh=B&bYHp})jp-c9!oTi6{PHvo<>`a4%$ZvW!nTI94KdgS1r~y>c$3GD+M9fN zV1d&+m=E;^wZ3joZl&AX>GY?Zj*$CKf>`TjR5he&>V7Vro=^tW(8vkI(OXX*?M7>NP3K`-<~vX?(Hffe!j&6+7b> zGg%oN)N|k?GzaJhU_68o*DL^nbqF4hBADZtwXp*x00aG0QB({dpfHE2UME>ri4(A> zcAyRFPxulrPl)vZCQ+Gc>Q$i)Q?auip{bLh>2#=G!G2ds>QG~@s*sF6T7z5xjZ{Ac z&NdNE!HQ+H!KYA_Qq}=fp$0;*>%`(63Mxydi^Y|TBI?rt_!V;vC^NRqB+;rc7wm$H zqk{E91xvwx97|VG6q@RchAg-oKo)gJ!E_D;^0Y=ym@T5;<(5~BeNgp+-@E_tQjf)< zH;w&7dFWV#8C411+{R{C*l%X22VWbQ`>LkACX!)ACInkZZ;pEe4KK811Wa+yRLh3m zA9T9yCSW(rHol1$MWce=@B}1t!6>yAy6s{Nr6@dw$P`G5j`P=}Zh}VR0&F84MQMAX zInE#zLLsXc^NF$Ni;?0uN+alZ`9x2%?9F&eL3=4_4LUTG@?WdKFGzvsJt?c~j5uw9 zJv9hMuF+HA^AkOapxC8ANJ=*G6J8BqYTKPAvrRVXD-VaSA;nTPuv0bMMHO~}SVI15 zsm1}I``h{!rK|&`ZFx-Ah zc&EiK5kede88t;D$z%O1^@i$Tj)Y7|B-s&(wt8FBCfSAW)X*ABbXuFyC3=efv6pIb zqp#PV)E;5tmyURJ8NL^$b{Z)Fr(-MM3(x20xIcl%(F!1binMCCRO%{kfCe0dMGpJ` zfFD{|@ytGE3}w7hH@X#%Htg)Iq60&CtjD8-XBXqq4V=dvc!VB6bVQ5rGmoW0h69vQ zLjH?l4Qqk?h=U&T8MrxlWD~@=55ObAuo@mXN526biN}?l&}0~Sc7R!S$cJOtk00V+ zW~Z)!UCKD<8znX2^VuX7PqL%~n;W|G;|x?s z4Y~uI&W6e|S8z7H+jc%D>9u+Z$p*2wCm!=0;5^Q_#|a*mEP<1ooNX~tfzwbyGs^Kf zLJd3}Em+NYLF?CRheaa>KC!#?z^C||k!a)hjbg0Gakspe3$>7VEEH#OkZ39p-J2n) z)^v$arjol8K^O0IIz}R4Zj zg4V<+fhKk*s%h1Gbd(cWe~RY2p(laF(TCsr>WHA1LGs9wgkOM}$0AA$djAxhtp~BTKaS%gpu1x5`brtK z+kpkT4rm~j82naT{V~Y!C``LILW;eRWiR%H?U06{flf!F@Mb{o2LZQdsGtEQMXVbn zk?nxqXRwQ8aU`*ndoY!)Sf&N+EgBpg7I1cm_#O~vc~%}wL95r0txBmiX3w0d3z#)BV2r7NrTv397D6&P+! z+Nws1H*08q{HUT0R_eR4SE)K+HjNbLcEzf6Ijxju*r00Dr^C|nBYcQ2xvji{cLhwo z3V7AK0C+X7mv(sG|g?X5-3u$9_zv%Oe#*A6?dzNj?6+6zYKU=MmD22(OrSZrW8R~TMj{XhBOxc$Wdt%}XH6KGbaL*y^E zF|00HKlZn&5VE3sN?a_{!k({bP~XdT+ndA1tQ2)xU8W`!S!>A}b9TF=O|EY3$VAFf z*&DEEv>JbFB+&h64NCiRjw#i0A>)kqdkTx2fV|9gyJ;dW#>Xy$L=_Zq^$~N%rs!#( z=ld~x(onFNp7t+@5`Q0aJ_=l683@AlMBe|fiZDOn-${w3lxKSV|jixlZGtPJm1}am*xAY4|H_LGjG5{YE z!}R8e1LyK1&cjf)f5db4n(1c@5!@&r%iC1zXRoJxOn=!<>qHAh^xhUo4R+vZ zdeM!=n6c@mI2qZjN(AY+`;sYV<9#`B+UqDL!AkE?Zah^2N0VUDoGezaJ1l=)KCMdg@9_>Qa#U$|;B7T&#DJ&><^Z7!4H>boe zqAn+NNBgcs+~;l6V9wf^7ReA%56(qM3<{`ZT0y5(sbnWpQU<&J__(AZHK1sVXrm%i z%_n#2(aGyp_bgM%Q%oh7IuV(X5Scml{$;FWkIzl&oWt(O*(*chBubx|dSEck{i#z$ za0J2p$t)-XBV4i&Nva3;G~zRD{>%SR;$v0{n3Y4-2E})AAkmCN#eZgw&B)j@CwBPE za2GI!V4h%cskH)tv-n7*1xhGrc;I%0L7s8Tvs_!oS`;TS_&>gZ9NA}-+QlpzCZjR{ zwvy8U@jVm2JCh6oXtoTS{&F0Zv;mf8uoM})EjUvd=PScV$m5)OIC4M-fG7}$T;0Ec z|D72JKZR*fVGjB}uK9h1J?I&z6#t5Y848cX0Qd;^A?^W0f>;jjonR2UscIx#ggDT7 zFbY`gX9pCK5f~KAK-dVM&mhnsj6rBI3e|jS!T{RF0Vn;O(^9(T@#a5WXK&VEb7$&T4hrs`<*5cKsh~`)=2hBu_fz-1J zG%DLc9SBlibwXyrBQx0%B*M&t^Cp@D(_0g_D|W=;r&Or1hOkZD&rGJvsjK*WRj@F# z=Ea&_a>X&)7yKn^yW;QOYP3hQtZrfa{oe6d5B+hYwehprtc@p>?7rDej=6BH{6>`>@VJVfF9oDJS>j?z*_ZAI2l--hagZ}tSSbpj^QA0!fM8>5mcYY`GRnB!Ww~-Wui6G zDC0zJe+#&2CRfHdZ75`r8gP2O5K~xc4~BMpFBxF zgZos(C}8~YEnIPmyAS(GCGSy+Dk^63_>Dj2PK9^{49?0xvk%xcStjFAKUjGoPXhNW>M5jB@cF5|iSwQ4et~%u#RNgYzP>N8PD#mUwd>?@ju~Z z9)UgCX!TwR&Z@VOYw){kz*ltvet8Lge@Qh7>l~ct9N^Z^KpNI*NWWTxGj7CrSrXTB zoO?OauhI&Gl^=YP_6#iBr&t0# zFq$8RHOyG4-m9=1UJqaRmEh8St5T%0#-qCfe)17m)AMT$51e#sSOs&Olp3#2r#eR4 zctZBn>tra`pp!FXYN?H8;mz}IG#`3ZWi(GnwVDSDT9sC%i=KF6`fnjs#uhnuCd>K{ z^KMzcW!$o!KJ97_7Irn!>ZBOjizX*=O}4lz_VzwLtFpKEV##?K_{vVC*$wt4k@izOJ9KZP{6wFBD=u%M*-L=5>^K?1f15(cx*z9jM<8eqt%6@76)vUb}qK! z>mx0*!B21K+oO}#3!Ub1^i3K)+k`&IAc{J_J}i+oJ(PeD8gGZ{kXQ*Wq%E`fCWPt z4iT&n6Ty-*4VDuleiq_arY3WXWarcbOie{mt2i6(nWTAEqKf{EE8|B>4eHHe2p|fo z3e~n%d<~+R8awUQ4t+aNEzI2@m5IX%0tqj1ccrN>16KB4gX24T6+Ebv>6bUs) zLwif4xwmB@-_+c+8+x!bv>=y_c>uL>1$HG9k#JBG2uOiip!R_|NXQJ{Ny_la6O%a> zJ~WSFdeyy&{90ey>)Xq@yGT0YPLCdEpE7U4BeMuw1XEuzP`Uea?c3uNdfG9-TnMTV zxMJbeV&Anqaqs|B5% ztuA6Gv3nVWxVC6dT4s*sUd-Hvx?kXK*!CBqD$X9V4OxzBk=?kM3?MFEuESv){Q{T5 z2XG;L0~g^q4?ciBP=wFMu@gb4VW`F1aBPMjU@%mPN@~g#1sS)cbEGKl1yEFW}$6U(G7(d=_z~PXgb@h+!B%HS+`93Kq-jQRjg9 z0xkr*gS{hm{o-)kEKV=cg(!TDyv{-8G=_%p6FOP zm|Bo%g*3|P;^u(t)^o6gzIk^6@Skfm%@1F067}hx9+xv9+5_%+@B~N!lPM@hmN`L^ z3)1t5Dh7qP;pKsHDWKu|cY;$fyzpZUu zIS^`V2^>?P-D0xaiYZr%!?kpE@S8`4@SRJDeZND@WL&Of;=DP;+P}hYZLs~X=Kc!& z{}L=~!&vezBv%-(sM!9EG2*$z+C^COUxGF2;p_+Urq4}1c4 zE2|&I>baN^MbD9yAg+A`-;}u^a?SPdnlckZCTGEu`rmjSg8k3p%n|q$Sj63p=Q56( z-QOtbOvX_SpM3HqJ=SRUxTJov5IxHH5d-L3W>IG6v1ge_WxQtxwj71UIk{7hQ&coA zj{(4j0>x0sPdOBrgQ6~QJ*3?TwykNx!JM26%Hvih$5)NEy0RkGlobz@73|<$&NA;v z!pQU!Pj{9Ea-P6mE(z5yn9RY%jPveHj7FN>kx{jgsnG~Km<7u!)-Z!J56GS%%b{KA zR5UDvLQ<$^ZGL2uwpFo*)sixcNG++z0@+NVIvyD-y4iRv8zPTzvj{9h!+q>{1ok&{ zD!e;^tRFq<=_c8nCp$W8*B*=5%v^qSu42~#;^QNu-rw^t{`7-`naz#hfyQB6ltXec zG;TJJqaIF%@KX`c_Ds(A((r1;0$|ko;`?8Z^W5~alT>Tw1;rc zAz-zekj{x>JW4vJ7iC3EgfZtark>wbNkMXE}70|k~Do;;=H}9W; zll%bUGVg&EdZR+-ehG54-iG7<1GcEzE}a5X%?#9;L)SA=xsMlPX@qsdI90esQYYLt zt&o5;kcUnPNoVHW9u?wMd-QLeRojjBR-YW!!EhXCZtX^~q=G@m8{)1;f}T3kdzPG0 zHPYMRH3+fQi?b{8!evKknZ9)b!rOPW2=Bkmw=sHq%^1xBpUtYI*mVs+?To+ZN%lsN zlWGXrkI6PQ=o16kKv#a=te4GRhqZ+jOwG7fBI3tV!q6MNBDasSkw~=BEEwP zJXp;%{MIdDB(~sXMxTG71>^gW2^~(?Kq7Kjdh}~fve3IB#!6D03 zk=iM=6Hg(|@f1uUIHpqs>Qb;>`Pfh4V+~=R<=IaWoZJ_R6gWkHU$rNv?1smQTl)Od zH}XOEq|OYO(3!qfibu6_PxeluVEAQ^><3i`3gb));DyHnc-E*sBIuZvh1AL*amkX4 z(W-kKvS&1a>goXg`n)kz@2X@@vRioZq$^GUtEn$MW$dVWb!Orn-No6>zv`r191ybc zh7WOpfPF;W3v38oei8-o#;AfB3`#>Q8MjG@clDmuPm1G|;A78! zr&?p}?dskB@V~3zX7u6t-pR+FogNOSW@Otvdl*+))V1C=Q z!mjmx<(w72Emrq|M%m^gG!{Yxs$d;H>zcDZJ$?vyu*@83NQ^w&vAF{=dYP`)w zw&N7br%Z!m6-%71%T%fr_$wO8&=jJv8EDVyJY6om770O3B<6L+Mtw4sQq6JR>zBNH znZL&24~(_|J{d_zb|YzA8cHTSZl{vK5e+8N}S6KU9d3B$C%>!%SW zgZ!fm%S`|$+&l|4ZRaqB@SmoWCt**VAbD`Bl)V4(TQ!sgsF+QPb*$9 zkqMrV?y86v+!YPeU=Tw~5iKMZ>qi@h3H{bu_7k|a?{)%6{g5SP^1Xcz(VbvQy3sUY+ zBK-O&Tzwx=%A!{se)lT8ZLBOhqhvTxWo{d%WnjE*&q0Rg=9#yxbX4GzN&4&blniR# zHsxmub6QzZPsbUzrvRL&KAc7uo{(#zrf2}%1Vj)su_}zyYpe8RokCB?P$MDAduGy^ zkBO1(nQ2+E8=xTLnsmt3y>|*ty6?1pRT7BW=f?Q21-)9IKJ?qF_iXHG6&p6ytF-K2 zj{`gC_h(bMkCuvU?An>=tZVvrY@+&IP1mULBjDx5(=!~@>8yKeD*1Tz9H`HwBL{@f z(z`)lat284F9MwFNzA|_+;{uGv#PI)=TXK+uf6V_RI>fV#Ig9jDG)y>>$3UV}!KzSXvkuN;+*-t{$^5QGsa^9OR z8olVuv$vl;bjI2<_Qmtr?Bx+%0KAJzttu2OcUQdR6DomMX$-66hINWUOh`t8IBrrh z+7l~HEBL4~I~ORewdo3Xqe8%_cF^N@br=_(xxL~PN6Hw!K8b;`c0O6TK2AMRdL}$-sdM)A3uIf?=F;jxM+lpAGWorV z!`hOFRSJ%eJ?{z_^yMWNXF9`adob5;&!zw^je7$@{x=nOue}Cz8hbSx%Lh}DjL{Ua zI-2vAxKZ-vigGp(N=t!=Z%Z?AWlW}){Gx>0lpz7P-{bPhPVb?6;RkPUv-@3Ex2OfR zwj<&VJIzI-qnuk{lUwYvUK3qSx^n*YDSz4P40bv_;^eyGYOd)@F>$LY*Oy+LE4l}g z)>v#~Ni0N*0a+@9yd6$i-c@s1*B-}S@Veb=3*OR(NKzz%U=C{2;XpZ=?u%!8B%48U zJLEu0Z@Jvz)bW~Fdv@$gq3(?0pU&k2MY-pe)d{Fv6U<{*o%cch3;pCkrdh!_oB#H3wDEY_vOW_f!rwcFn3q-Ohh zL^R(|0^-HQ6V&UdM9hP5$1*)@njg!v{J6hs>WkKXM?!WdF^48y*D}c18VtDb)m%Uf zih?hh7@maN?J_cK=4JEo|uvFO1DBA5J zV!$k-*rqv7f*v#S)%ggaPH^FYyxHM&>}H%!WF+biL!Ut6u*XAo)n7>Xk2wPwck{J1 zNjM3rhs3`g5)meh^_1xr+o{wojCJtT7%gnJoid{(2w`sFWQ)xr+YZcO2>aQj(kjym zN<5oYj8Gi2N@j)ckAG4rLzpcoH%})NCZvXNTc()s$0(aena`4k>{ZQuhr>C{F;Z!^ z2yF0A-$%zGP6+D{XRfS3qg;qBKp`wZbC5@Ili;8J%@2Oy)?4;n#C-0LJp6%=J@~Qv z?!EKfx4h@p_gsJ7HCJ76`9(MHyZM~GyU!Zgx&4gIo7S&eJGg4a;{FBQo$aNT=3*`r zkNUlCm)&MI3KwcFRJ`s9c-@(;z-Xh(Oe3h!Kf7hVT-*GcDAUcM;;200*!X3pXiV{$ zPIhW_aGEoXqD?s?ObWol({4AkkTjGY74tMZNtJJULe_e%e1}@gdAB3&kX!m} zb!5z>5U|8_H1BU(x5(pY9mtqu(J02mbbn{wVdcYpiwB#;f>Bs>-bS~rd2!w>JM?12 zky+fHvAFrjz}h{_h(@$`pVqTp5;tiBUCi)2~wkolSxyZ z%u+iU_Ib8mZsB;XPRr|189~tMgey2uva@Ai6rOf1uYtEu;PfUzKPnQFM2uz=Rw*-Q zF_^I^xn$zBF?bW@Y}B^fK@0}#h@Duik`+{!+vj>_)Z;Wt?7Si;nMK$Wb2H=jFt_E{s_&)BqX)ygFUeG5BEiMUrb z83dcg=Fz$Hgq7@34|kO|4t_<ZcPv5!N)UX<(T^$eCt9E}u@58tP9k$?|0XdyMUaHc8$p=K{gB^9HwH#{ZJ{Z&i2=-H3uS=P-x} zI}ZP`sxBuNCC)Wzb0+JPcQ}i#x>i3W54qJ8?QpKi2OErHiy^TzAL=G0R;=puqBfq@ zEDM$sslNDqpAwx~L5R0!pRHY(>D?mcT}ZQ%r?g3ve`6 z2c{XxQiFV10cs4a#8=Me-h!_(JREFeD7Iz{;5T!7aY7zr(F}I%)A5cSpFE0K)DXBp zotWZU9E@Z9R+JWy@J;sHn{eP);1nl_N(BjHLuH&T0XWEh4;0*s2$Fr3dm2nE7Nj%> zND1GIa8W4HgSZ00vD!bd<|K(UQ6Mt{Y)ni>Yv8R5W*ir+x>R z6myr728hZS3K_+72wHv$o|!A*@w^CDVhh)Vw7%u_v_3ZoeyWu);A_>x)Uo1sR`^-} zgp!B9;{FS9q3@&Y;kV(Jc@Ey4XRDspC*gDb6pkn0YyB8}tq+4PkyXZE9@cBQYjCw2 zaLpUIOHm)=Ld=efaO~pF7v5LyFjKy)f8i3NIMU%h@ zq%70Wnl$PKw4&g^$D$O|sEdliiYoCM>u5|PmZaJ#)Cfja2o67Ct*bb5*lH~|U=&op z6&~9Enrk9TuwI|ArBtsiK_5^N>jc|nOVp_`#1}-Gb78;S<Y9jBdt>%XY&N|DW&GP!-%EhaOpfRAzhFID` z^Qz0=IbKO){JNFW880+k@wJ1-SP1xttXOh6BR^OBXX!HKyEYqrhOO&&HNyYKt6bD- zO=QM7af}9g*e1Z$5b>xzzh{?8u z!|1l1QQJ%of3kg{v&m^Rqbgh6W{aYd&Dcx9=BUFkEwH`aNJa7kNoJ3*SqO-Yd_P|)Sp+{a$3Y-GZO7>^3dDuoC#LFU4mrJbzwUAfCoF zUysiOY?VvEXALe|Jm+yN#k4OVcMf?dc>R6+f*~z5SfDPz0>I;y7S7}y+%TTAis)yP z-FT1H*?yz4hvk%z5d}L=gxrKI<`goKJ*bKH5*Cdx*fq`t&5wxR2Dva9#V_J`#<)`U z;CTiIct*~ivMCw}C80r#=+<+JH;56a)Ek8TQdDgI0LF1Zd%!~+opJ~7vN>GXIBe}i z6l~Z((f!yn74@1zz4ja7RnyS2*J?4F+H_oqa--QKg%*^_g)*Z>lJ6-j;B%3DjuGa! zv_@J+(dVt<=GM_tBFcxGQn4-}7!L>cqKnh<^d2DGBr@W(Q$j?=hsH`*yoXZ>MP$(- zzg{8@br2W0mMZeOYMcK9oWtYtI6xjzm6P*yLQKyoBxV@h_;90`NfN?V8Oow`+%d_^ zW!Zhs9`Ai_?9>?q#qjVDX_)mt?u3m1gLpDUPp9LKkxw!R zRj3~&zkZ~sG7p2X@CN){j6bYEMI>PtgCg_>o8+I~o3OZXL76MqKAgdY(X_hW!V zzXwk4FL7T$0Q>V$sGfr?-%`}74`E?`7{`OiT44qTJ8EjyO~_!m7z;Q%SU{TW5!R@f73P+hto<;FU=64t*ICTk3KKp1N-t1s<@T5V=7cC5Ag(B6HhL0zdc ztx{N8q3ldDE-NY(Q$dud*93w?<~Eupo-`f}tHi0Qg{K><-LmSoG6G}DWh;&;H_n)J z)(YOL6dWF+PVhKE?^5ej<3LVTo2F3`Su-a5wjyO|n#7eU6wwJjE|rv(U|q$wmZvnu zr`0NHQKcL4eEIBLBLq~m8`G(JCdnLwbCn7LgCstWGm#Qs;c!Mw@n)xzne zbz9BtHf=9ip;W{@w7#l=ZJY>Htto1K->zU!=`|{$@0_eqlIjn7dXs~p(5lR#FV3!` z@_p57lLOT!wcz1x`~PX8>iMm4)$_iZ>WMizu3ixu4UGd+1+QIfDH*J3i|C$9Jdi13 zzGh3zM>Laa;x6?&O&Xm|G&n??TxhS9#$5x=3qC}Xee4$#V6rOZu7-#`{KUAFGpeDY z!!FU;wOaDlT8>S7O=GM*+L&TI(cv+bx_aaY7dqn6XzAAhat-l6#A1==B>t`Jk@V5G z9eE9}4RjNhODz;Ujx#(?>8FppcCaJQVad^t{Yy&X?a-@|Pz4uqD^T=gJC>;3SiWwB zDtR|Rw*LdQ&X2`&H@S?w6ASJi=nDFN`UriJet~|IK1aVte@K5qe@*|y6JEm`93DmK z8F&U}B!R|AaM}gXGCj~FHCVy4SP!p+zho!0%^dPC;7f#9I;@oY;D6c8ou#nm>ahB8 zFiPJ54buuXhi#|}(?Rn1wuQ5j^O1F!gKl{}&S>K-V9t39*^4zk2A&5K&SmCoSpWO* z+Z0yTFGGe7bjP!JYy)Zd*YH^8+USFL?BUAvQo4yR6sFcXgZE|3>6 z$0)2*VV1kW@b&))cVqcUSest&yD{*8{sE@N3^xMX13`JPZI#Ue3qdEh>^QcHwK zYHdkSo7rj`7UrrEJ&=Z;Zqh+77??2d+Hrvp_6#F5ia+q`D=s)7DReZ8^pTfCk(yUvr&buoDJ zCEMS6#_+nKrArnr=t=qHRWqPFt?saDxzrZT*78gfc?YT zLT2lg_9=UXYiFI(ejJ@LWrHVnjCGoh8L0N7c3|KWjseycZgj<~|FB)5y6q-*+o$OT z0X~QEmRB0iBH?Iwu07{#pRX}+K1t=q=S6E1pYsKBQ_PIx9|MgIKYjD+RTCTUs0{(7 z@u#h!0|S+Yv&h^3&SS&lSQ5B6YkE92iY0*$Tr|g)zu`<;Ulgz;@N-%cj-%x)$>wkM zmiH{*w!CNCeBAnZ=mE04(gDk7UlWg`16aBY*1hVOpWhBBg2Jph7l5m;Iuy9($~b6l zm;n>lzh!JwAj`2Q@XI=vFT>VxG3i{<(Ruc4Tf;GTgVF=7Ikxi8Zx7U4&bk7wM>hA- z38b|hp8E;gaHdL6xnUQMr}SJJoB%jjF_K6*ZVGaaR8(-FFZZlW7#FYTda+Cf`rk!ERz zrfHIbMUMukkGiRZBuEW8LJpH(lV6e-$xq3T$&bkMk}%FRORUs@Q)I8P4AU zjNluH?foCHDSZYhSD(f43HYTyhC24F%Fi8eLMzpn$)?)$esgx!qCKb6ssH z)gD!m0yRWX9W|&8Kj)_%rMyY2lP#ubA?Ryzw!ZdFM@zw|(Yr0?NHORuN^O7f;+@)e zLSB5wc-0f+8fU^TT8aVbwO3uRAS_J*=`TIXdrbl7;kQ4b*F3?0DdN&(G)}XfqzqP@ zL90#ZO`)*Qu8sbQv-jKeZ?g~B`7h$1-yf9MZb`&k3)~TdF&Qo-@I+v}?wixY8Ub|Et+8U3!dfZ`y zDG@Fvs~<048OoXsZA({<4H)x%c5A|Db9oIB$(%3Rb%kZ*6DnbCR-;an&_P(QT^IdJ zYxN@=o!AatH281TbJ?9fL&S-5*|de_KYxcP%c5PDNlH`D7;klW%&FexOJrv#Xt(>r zZqs+X5w~PnsIi2ya@c7%^zu55uQ`(tv^`p>P*~6sa10nF%jv_#9*cb-z1A&tl)T** zS8wr;Ey1kKR`3*e0Yqc4ISem7(0KMtLADteY0SKLUqw&U}7ltD$%7uieP<23O_SPy2G?UkuBB|EFd?3q2zhvg?d4kHu#~CR#C+%6n`+ zliv~Yraab?Npx7O0jtTL5`8(VL!R+Ncf+a9z##Q_?4o?$jW-_tQ}0{PlzT=7+x(8U z)~05=E;!IFI=f`2TNhr^W430qR?!=c`v+Qu)3?Vxh0yk*$2ZXLS-&I`$^}|N4reSb zIqWh}KMO+3ee$w(Lt$IPInA+w4zsIEGP`X0QXvo(G`-ePI=H}>PWynjk2)KE59;9M zd3kBESoUTH5*=Gx#A4P`a7%?$TiI1!StwW<&*F6Yysf!p%xE#CS1*WXHh$v{{lcKv zCkRcsOv2L?BFP)Pz6A?H;m-EjH*L1I#XC1Hi8U_`_pOd>>J{A^H*JfyV?J;kVtw2Z4|z9&PbmbnScK09 z#SUqGc~Nh9N!OCrmTWrStkZe&Kwe@; zXF$gqm{ZoVep2cj0Aj#&31AZ{39yxXl(d#WcT+ZQ+yZ}JBxyFU=`Stn+1}pLCgw8b zW$A9;+B01{-#aWUzUTDrGt15E&g#f)J3Zzn+g4t`P+mG1-!|Gic79?>+0z}{cvHJ; zTgRf!Is1^QyAWy)Ytr_$j&+xAa64D8Pps{0Th|e=r=xkIU9~0{vo2qd?LrIDht9D} z$kXHs^xN)Sc; zsnWyjJ3iXUT~6Q2X>muC*GHCemmm2sUN5Y?rkzBqd>*cTzJzFxJdDr#l-GXtIr6Ht zBvE-i_B=j+1g{r2e4cLje2MCFH@=^#e*U%U?-BEbe8nRz+z*cYCq75p@Y>7d<$ZV^ zS6=(ZzQSE`0N`Yz=23CLVKDjjbJY1%obU zFi1OtPG>0ObOr$^=Xl6|J0FF4ApPswK!&B^9)&=dE@CDANf z0Pu@zPil{EuD2iejzjxj zILJnT5+<^Lb-o(BW=Xfn?2F_dg#)bGs_48Nw``G8rY)qG5 zx}1GZ4_?mBK-v9L#w+XNe1C?=y}~WS1MZoI3Ou`Otbf#f0uyb*-BT;b->bIq{#sk> zceLIzw1RSeUdz48zfZZhE&WBrQz(y-g1kSB6r>CD6nB&Ibk>WjJN{p*7rn0f$oJGP z+EIJL=7f>9aetb~6!pwuTB`0jnlI zxaMFEqc9DDkr~087{Ff`)er#p@mp@Mfv^E)(VKa#R?=cH7H9Y-8<46W=(HG<>84^V zDkG<&y|viY)Rjp^3$a4L=Z?q`yVYdWX*m~lS#b}_Y_}@|246{8s^FTC`d2rh)o6P4 zO?rpDkTFt=LvPeHX~cvWNjf{Jr(m_33u&vt;q^L1pYL`YBA%be^Q46moAs`c6qiw&=I|W#Q-C#GVv!*c|*hSR*x|Clz&%MnyuBx zeKqMGstHw}&9yf$^f~^>A*eGyp}(!JYsRO-Udf^(d~05UWt@US;+EZ7S@m3}dcMw) zF&Iq_N7`sK?Qmp_hU)VhRL|+HCcK+58nMcxSFvwbpAV{@E3*8f^jU+PJ^fu+qI6Wt zSJM>*K{L#;@)CLiHD}JZR((6ww|S`Hmey`Dt>4<(9j)JD>{pfX=dSNpEBcp0N(0qb zq|^}&6B7@R)1);rg>1vo?myE>tbV^y`uiGML!YQgUwgc~_N4ZR!2udZx|n-`Zvv}S z^W)k#F6~d4u`7^4kLMPF=dg}MlR5$Yn= zDQJsnv(0Uk0*f-TM{jhPN@*z-w0i~?)5V>jbC4s|s;eyS? zdEMYk30h2g9p|79U1bGp@WHA!hJ)_jPOHuk5UtsAGScl?r1dsQE_-*}LpKRJpmzik zTA0l=dz!=7H6@5o!fk+jbURr^zlgnYu`(XY4lJt@8VK4FwlT7$7Hd47V-sh4I5)yp zf2Q`?t!ASJg_ns-1KE@zg3YU*@UD34m`(Ods3jvhg7%c$8t~@wUVFe9j1=j17cAUJ zFzN)5GaIlevb1wA(lHo>+9xa=!P?nGnXw0#Ge#M&+hT}BEloOK&}j0xHARivD{E=H zbIB5y*IX*QeLc;3$yhA6*_Cq@aIQZq=du!ZE_NavP6)#>-N$d}F+^f!^~rp0`g`r< zlPTv~MD~I!6&n5%pg_gBSmqj9mPqt`_}svG570$jiCta23(lyFYYt$-z5Jt?V^Y7} z>v0RCm@O1?Gm(2QaD_> zh_^qXPk0Io3|hXGP>o0QdZ=huUDw6E6FQik3-o(2=2kJ;tGYUyZyOSEHklznS* zm%`f=(xd`bPjiUR3^1A5wg1i!0c_gZ-<*k4EWU$2uT7`bu&eXjJ0T5}nPEH-9zzo_ z)(wd~M9}E`H4!S#=d!HRGEu4?X|-FtCRWy>vFgkkAtMlrMsLvxDWe{qKZjPZ+iWJx z>6(;ftKM7WOGdrfXowkYj-uHgHtO^yZJNPmosjuiem#-_+WK4UW+=Z-bQ&GB5pFe3 zLg+iuvI(m!x;e~+0~vwc9(Xva%PdBPGAwk;a?8wfa7?!vG=e$dbw>1pS)Z_?6E$u# zuB|cAVYyqfrX9hUTe1c$qOa4-`=dskA?$AMz@4hufo?9wVIQ+#rW^GPdx{w?V5&p3 zYn4?AEMY9Ksk$od!qO#h8$B}RLVh{siHX2H0}1P%up~`VSnC?=4USU5M737E(X1(J z9BF5&Aopn;v7p{=E)^WK8P{Kc+vl)9xYuH9iaDEOm$^eG;u5W9bPUC%y0M&w75)9f zsdV>NEW18MBgfJV^^kCTbZ~(1BUrCQ0qT0Rs;=YRs$$P32EI(VUCQ@24)@DUJ#SRW z_2aAV^eXv`syFSPR@Y{B3u~K|{}oLU%r#i#O#oZJ1ZxhypqDC}5K7q5b)=h*C^|?R zyK_xwVlf=zH5?tBgU*yrdfL2gYHP1i9ad`^6`e`tOjFI6>nm7$doL_uGx!3^YkCAt zi3r9-DvRKRwpC+VM}I9E+1HF>?eU&;(d3kjMjPHU7#&6vJpY!Qvy{TU_0zT7VSWeN zmf)b>htaZ-L&P>iF&<~KGrY1nVM$aBdJMG*#;ekVY6~#q(EzMNES>kAR!Op2a5VQg zn>7YsAP_YEU+&%m&d#H{8-9Afz4zYt-tA4hlCD<0S;cZ$cganXv1QBM*dRAB)dgdW zv5kX64Bx50cOVB$#{wUSsY3{xeV6XNx4;@VNu3+AibPq4i| zLgix8@)MohFXA@ZY`@6oTzZ+n$aBvy_rvF8`$e{OA-ye^$O-)&FGtyQckh1h-UZy> z+bBlKG{ezx9(8sEgQ)*SKRc+eCJqnV&p}>i>@Uc=P4-Mb*(>!!xKVMVxY!Tzd7Fav zE5uZD?jy{9^u7n1-?a5F=$=dDToRg!z!RRLGERdOpfXOyAqRmuprD?MD9z$U689Oh zjyfsgZep^>W9$NxJ(G&%<9Sm6vQZEC4#-wZ;I89}2e%F49?hMeNzutqu+A6?kOZa#rPR{FG|jZz7p~JqEVkW zV!sl)TmHaTWv{!5??&C`3EN)(j7@ZxxsLrf*W1kA(!;HJ&=yL}cy~lZ3Mv)0c!e4a zL!FaENaz#VbM8>B`@LIEu{K6?FIcwFYpmC&Y}8o$js1#cOMD8Jf7ZnD_wp$mk6La- zkLOePtc{gpZ`xlh!SVO8jom1^`Op3R-@s2B2K64XUi+=r&l0z-jTL#l!v2Eza#M4r zpW38jKiQf7Ahxj^v*Tgx{@#`gi)~{2vqNZmB+Hq`*o#wh-)8=~_j%ZY4V)&u+q4B= zy4$h^!NqgmV+VVm#{uC@)K*$aY^5A5H9ig2&O;+-5r`qRYlv)tX8>9;gPN<1h)=A5 zYcg43SD~+59V|&GeQF!k@bTgL>fyzvwW<8k=nHJBM#E?Zlf@uT3x)HJp&c(6{*=eB zO{_^ID_-50`(8YqhU#W2MqA8*47S zx(gGlGa5-w^ufexjSS*^*TS}_jTKwo#+q*|8C@eSZH(*{cFcnC5`5NZ9rA*Q3zmcl zp$CvC;c`A}H?NolU(Gny<&}|<3i%g^#{>AMnV%{n!%;J!86~pd|6X0)j`{GgI-A`Z zWGGnd3@S>`CtDdl?|{dE5o7_j5Lk{6Aae!vT8CpL{Civ+6+eJ~-})&2BYLLKjQN1K zmx=j+4$0ngs3S83xqY%2kDKDQ2p+Uo7Nu#j_bl2Pe3fktx!U^wpo>~m#^+Xb|iXl2ftlKb&e~y9!-KexZxYh29+Dzi45V1DHuW+ zaqCfHEJhsLV!@9vB9CixpoC;-IRP~8$}n^5ON-+hUYo6t*l(~_po{b#1TUcaF4dRL z-g>l6ZCQdn3{xhwokli*KZ$dY0d(U|1Z2@qv)$B*!XL*&^yR23l8y6HRRv>6VhrMv zF+df-AgDi|3N*8*ew^ZJA_;ZfrllGlnH&p}I`(9?Zj) z);#nx2euwfV=y z+739Y_b7_l?Vb0Jj-Pz*K6}}{8+&N)4kR$Y_Eq}3Vz1iYb@PebU62dD-rG=Z?P}^b zTGw5(IOe<1VFdXgU|t~&g;S2_3BuNQ;^6EpCknz|eOyNn_JlBZ1@i#+OZXP!s=Q?*5tKi_e#2%)|@CFIBjWcqR|+?WOIJw#q47%GSkIw4yjC3X9xUx_8%d&v)vTFfKo}mKc5)q><+vo znj{ZRGB8?*0P?Y^Df7ABS6IN~=R`M3ob4!UfbEGr#q~&pJd&Ei6GXmzg>b^j;Wq;w z{KBwf=V}qVt9#w>3|tmJh29ZpUa=Px!sXSS8n=4X*S? zqJcmu;0+Ci3!}KPfUFn3$c_sSGBvEfH1n6N%pO9ih?^)4j{c=df~VP=Od2@w->2}T z+9!D~;XL=}Op?%!9kggi8{qK8pPLP!WShA@F@B4NA!N+gzt5dDF*8ZlQGnZk6q#uh zhe$G`l!DC=iLP*{yvpJ)LKv{jh<(c;>ceom7$y+|w67t6d2G|x%r7*PSWqIH6d57` z!lc^l#uB-~dcAQ(OW-ybzc0*f7~p-Hr;#a*jF;B#dB~qrqA3&+L*rG)vxOT#hAXS2xe2Lo9(Mp?w#Zw~Y4&~+un8 z{dhkzUpXejZD*e0OE{PnXQ~v(Q~TOmJ&71SV&8;; z#}SbyTx*3@P_tg9(aF{RKqa_x8YMABa9P+N?ym6okRtJGUp?F(P*qJ)0!By<`>LT> zK{a$)l7o6!#`q?Xvp9`46=e!cA01yIsUgV*<6CA$wVPoQVm8H0wK|Ss`S8EHmX_p_ zHdp2YGT)~t10|EO#?DFGoHb@tPfJt>=DDh`TYaZLXR#GoV`Ti7^83Lvw=h>?joku- z=o-6{aFu}A9Pk!A6z>V-Xj@qnd4l_v>Aoh<(-9!4P{aa4CIB)$uxBCl5D`{t5eee1 zb~9CPLLOK?LBy-&5{SD%WVvNl2m#^~kNMx1jQM;j+QQM?l3#3U2p*$yxEV12D2d8=Oi}_Yv^9#l@6=kE4Uo z`Nh7J{XTc&05&;1Rp+*|3ca`1hy02ylv7p{Zx}Wu#CNbKU~lITJ3C(NFyI$St@TtO zWfqVpu|vBbw;2gt-&s5L?>|P*K18Fl^o&R(!fjvk!5LJZ4WKT8FZ!_k2FuWrMGD^n z&p-x&XWqUsP4aWuqiI40NK(j~A41-QvA5=#)9Dztda6bsN=AzBS69vX;o7Iy8%?`PHyfo0LvDPzC!wWFF?&>4`w@Dpy^S zZ)}{Nb#$gsS{WQFadpd#GKnmTOv6Pb8zkXgTVSWlr-T zIRaiR*o|%GdF1MooGG73bCb-|pg606bSFN!jLpuy&PslAJLb_=7H$2&YD=TC9@VdL zPYKPzlD+(3Na>qMMn*eEBGO$Qmi&VwEv}6`tfa1`Q*y^B=G7@Fnv4>;$+g+Xct6&_ zn?Y9@f4@lA2JmHnjXv-_YVubhBfbV*_swhsP&k(XS@AVfR-g?6yTXJ;mNAhhUt0)6 z$NvO#74O5|U8A#g;`0gC!f?1^Z3wrfg;FW*3A~WEX~F|x?g{3fXqz!+n6~=zbLbcM z;P%|uz0EG^b-s7fzmMC*d<$~ai(U8R`iLKCMe(RF>(8n(Oa`wb(BQ1VK&|bc+=fSX zY@&YCb3Rv;n!#YJGSUjQO5va+1#v?c{&Jg&W7*7#+UEZJWN9#2G*F8%S@al19E~Bk z<+$^pD`o#r=}PA^1TUd=Bfc%`Mvookc_y<&SumKT%?5+?*2OukXsu%Ha14HJ* zD8K{ZayfvTWGv0WI)rT1K_zFpr;M>}5u|g4l6k$J@QqD`l<_mTTLvm&)sxBw^4>%+ zJwftT0+4~f;`<@ZWk&f^38x4+k{1X&bi5`XIds*Phg*#nxATgt4z*iNa*T%ZD)sTa zN?tuJRjsD*kGrCpN>?iBRF$-;aTVs9+)vQv$>KI4*M|0*#^hw9J~_!%CL4{3iAH0R z>|0hYD112NP#@R(70nI(31nf5IlX(Tp(7$!hoC1}+6`8evl&hhMc6zvNffun;FtxD z;)sHbT?&`ZK-qjrnjb&PO9ODV8;j}-|Y9`V{f4h8g>4Q4XKZI2snQGy1;mk#;rmnGhm=d$4 zyV1i6OdjPUgo4EJVl)C_k5Dc)7|3*-spC1MqLaf*w9vQ6?ZurFBDc%u&8Cqm8Ss{T zrAkqjki6$c9NC@jkMw;f5x9zO9QjP{gel zZ$-{6)a+;&?TCIB=Lulv24Uxx;kZDzbVdc06(s@eiF2sZEF($WJ_VrM?o<~V8W7}} zBP1Q~B+#?nOu6L6QL1LG)G2o|sd&sNdW&XO8ace`UUbkd&Ft;tr%Ha=D!;q-#3_@! zYFEAehZE;cve;*7&Khjqe7XRN6fVjiN~T!&AcH=Dh|il zv?nN*j7F?lQH9n7@_~Pm9`xjjs!;K(`DkK1A1X;+HLEqq<_?*eTYeMGm&k7gFvax{I19OfAmyvrmVay{P`brGPQVaKi zoj3-J6}2L^i+-5xVoFj8XzvBK;8Kw>BAD3_j!+hfwups*1YoRSoOPC@k8j?E!c^W+ zAlcA65|WdViyRzk=eonX;NQ-jkuOHGes4N7R1A4jx)xGBhN>76&H)HW1mU6MM6GeO znMvk#-6i8F+v6aMfMX8gL@^>H5bPwcRuvU|;Nco7S%W$e6jn)pr&L58-{H<=|74?9 zY?s>hi04b`n-eJ(K&6m$drF-{3uACTmaO z&S5M3Dx3|dF?Z5Y1d&k)rP>^Sv1az{$4O}6U5SQQHToMqJ{K{(IBMe!eH~{$v+a;csMXzArk>2z<61Z(DspVF1S4}& zl>!6aPcrmftJVQ4G9#$Ars=EfVjqKdvFRiSezAtKC%}Z zD7!10DHSrcY^~gndm7C-e|baApL9^%>XRZ*&X=ubTl_H7$DB=P+ZRO%3Z_vi#y~Ha z3R{or5ZF<$Im=zOSZzvm5qenWsK#tptyPloNQj(wr+hp=CX`38e2|dY^n9F|#77m| z8n#b5uwjK~0*?|$MYSa* zJbw7?Re8wgRRr$~IFlH1e$WnPgUOaz4K=pmS}d&0UOpHvN-9l$*##QmWB{gyNVOcPD#< zuQCCgv$JeooHPQ`7$d|H@+k?jXOI}r`P9#{0~hoq{Wx>L1X!4KwoGyPfaRg#;Rvp3 zcLFSbHnSnwDdk$Cpr~J+`+6uP@IKw+Ks3oEZQ%#1 zk@a0FjhbY$>up#*I>J)yu&GNBkbCCFsN-72`Mb= zLT#{?D7DYeAtl@ZUqFvJS-ieJ_j3~O=SbWT@qVbfXRtPtT0vdF#w5 z+7I`<%=5h$@cWza`wuL9f8acy#qU3$zr#1S_;<3Vdz+byd+)(s^>Te$ZvAK_&3KK) zK27spk^4&dn6&v7+UBeCX}NoF`?T!db}a7I?j7_f&HW22^u7#wzJm#Z$4q*gtVLy* znd|Z@$7}mV{%PJVf;3f)9GL+ zb3rB;%((xV`$$n3K%J&jVK=s!3n{i|8qltF31OZrd=W4yNEIhIF9mwe8j0Q5Rv1~u z$s`%tr-*|Dq#6QF&;XLzMa(JFkV+@RA>4@Lf%g{%Br&Jv2V?8g#KkrqE7~<8iKS6< z+01{ua{bDYSjrRgi0Nc%U?{^Srbddx#Y8Qjh5IAc=`Uemy1#F2P~y3M*$HgoKoP)6KZGzo8UyNC|$)|_2l zY{#^h7i-Q3iDiWA!%uUo#9|@r!4W@?C;Y-feHXZ)56r{;Vh;DC9-){8g9x^9d>av2 zZd;`s;)xkVZrCeOMgK~uwi1cJi3jnp9oT}gi+EG2n+FVk=LtY?lAmP;`}>;tY$_Rx zdUR%l9kF!)Dn#auJ{nG6a8!9dz>cHSM2EzmYun?Irl{f|eOwjW28Y<1PiW!1vJ_F2 zwysoDS)Ua4dsJaq7-)1FWxt;DMuW9@xE0cd6eH5l52;ehlM4CMHE*dD(sZFrQl>C( zgf?FnYfKfn`=?RrYzYN9frGk?5@Tg@K@d0;8V{@uT4)SeBrfw1PEkUZdW~h;&Dvmn zu$WILG2|*+J;{)9z~4+#Fs&Y9^bA2S!uYT2jE7s!aIqMSKw}`C8QO44rl#{l&m@{C zc`(%W2?NberDW(?Z!|>5s@J4|oE*y6N);TJuS`7CjwXC6QW_v%0nH}FVaOH?92NoH zk8t1?!CA^9S@7@Qi(6swZ=_V1_Q2_lqkxehf-5|Sl}F>UP?Qmtbt%e(x6Sw;4hU*Y znJq8WORa1S+crSP0@k5v@soFNPmUC;iAE|EPz)og$%&j)A8LjDMUSdCgn?kaFi;5P zGI_DZ1A^D30lAz>`z2{W)PT3ZL1x6@&e)MztKDNKClT5|cxa^Ag9LN4JJx z2Gw0d1tZVLrDO@I8n`Ll^w&M983roc7%l7dz}T?lYl=!PUM)(BJnhRw^C?vQTct#0 zMbcH=2j*2&uSZk7_UrSNhEj=z^_USdJYIItS4ocNy+uzhcAKB$^ql33oCd8Xg&)A) zNHf;%5rKybCJymM;*Y?TaS zsZ!e=_7H~F74jqTa5a%E7t)1hX!LDL(Bq4WK1~=HD5v9%fl{$q2xhL21T;BrkRsbD z@Zd3V4d$|J7j+c2fI#z?HKPuG_Ity=Vv%-+bZEv>+`-Gt(!5ez#{UfDquk%B%2Te3# zBYT_JMHnx~Oms&fv3V{Gmjj1%0222&jvFExNyQ_%Xs%KxZV1Ha5c{wN4tS9`<>$RD z!;OLPK-?~&G8J1rVLWHoQ4uyqFURp!yNU|#ZM(@Xqyqe8;5UF1Yh%b<#SQGtttig@ zAoDnT8KH!ECHklb<}3T=T9sFscfRSa^H1HqdDEDxsrRiK9n6ce^78)=bwb;Jh;E}P)uJcE-z2QW)m_R4`wgj`gM z+#{kz_*2Nu6j3JcAcLJKHp`$W!rm;fiDI|g>NJF4nJAnADe|gYo9*s+=-qeUefw>x zWHf5v_RjY{^6tkUdi?GO?|$&Mx8MHuTV8wpwO3zt`DObry=dQAXYSa(erDy=-_=>egr?nux~&K0RVYaC%!~wVWe^9A$wD8FMv~^4T~=T$3>MV@RE%1w_r5BfK<- z{sJsIJRBoj;P@|S5ptJ=wl-sIOD4mbTNT-p?RUhWFHo&&932r$-Yhl(5ib&nXCiY} zP?uF*A2VN2FREWW_9SBeS#K5W~bPZxJ+Ll3NH6$RHLYpcY%0?kBrUzCSC9a_4JlZQPE{R z$gc9MGQ6mwH;lbTkv-PS$De(c(~OKdD5!~WJ*XR<29MezUXRd^1c4%lO7>DB12U<8 zDU}hlxFTsD4_62YgQY<9hPUNQe%+7cXT{@DdVJIobBE?>@r$pT%JKk{F{W<|R%E%Ct%&pmTD z@wsdZu|ak#4s?;&Ude%5)9&NL!RuF~c|L4`iVi4I>-;XDdpsVN+$#*-ZTe+ zrJz3EMxa}<(R83V(Zo_Sfzo8blZ%|Jo1E51Fn%Gx3P-%y#oRd)vew}7gmP^WY0L2&V}fBJcov#u!n_cESuGM;B8007 z%#~)A=VBaRZHr{GciJtQQ$wdj!jv2bLmwu_%c?Qf$Em(cohjN!CrHEuA)vB*VUqw4 zXsS8ci(3}y*g0N9v#8P~f{MDJaiWH4<49b>h?1Rg7iFW_ZuiLRFFSb2B_4$R?zrvs zcOALwwKraQ#ldSXyXKMummJuC>E83tIqUR2J5SlVW#jslQ)45|da00%8y9;n#+uYC zNh_Nl4ogRbEz#)QPFKgDX>GCe>IWi6O+|O1@T5La)3zaRD-hJRc~L163jhHj$WJIy zBr+5oVv|I+Ks|itwnG`m(wPiyuj}-DX*=Kk(QEE$mZ%|`} zw#cOiV{uVS1dNhD*vXu!>q#SC4{B4~!#E=U&4{6*-MJNE$S0L$cg7QST~dS}p%yi? zOU3(CvEt2n6gi}Y0z4-t{h4wt=FQ28nw0SR`M^NAfzu_tn$5^5BL9-tzNuK&HIE`H znvw~iU>0vk&p!KbRP}gN)pL#!mH-yWmqXlN#Xc*l)Bi)YMH4OoT?qX=)6`F*~7ZL4XcW* z)9TU(wsBfwA+$uG$t_AtpoQ(nfhLQ=g614#jsxAbu$~7@1M9?cSkKG9bKxvN`Kj4x z#P1O#HY}aNh()2m2pBJW* zEbFuMjV|wlnp0MAN!A?e2FC7nF~)>2lhHB<7@>g zTgTUq7++Xe$NAdr`W&8r63SMAJrdFt)i9nnU)al`yw1U6h#WK&qm%f4K^;oy5kU_| zgC2QMi$r1&yd8n{dJ>|5C@}O}EdVW}16kZcS>i;%7g6L)GMk2t&w9;fCx}XlUgqw$ z-c-%+_bvP={APMuj*#6z@r13{r=h><2{fsqOtV+b_(}yuPi4||wH|^zz9tdK>4~5c zsCj*gKLPn%i9n!xNOvCPkE}+JC_@|d*>9}x#YLZ?zly{U&Fz3+cL-N3?Pktl_OqfX zr^%|swHg|G)@i3stq4n;R!3?fOjX$!V0C$95T~uAZCPZJqecVZq?5uyB_xfGDH42E z8pmoRCW<>g;GPhn9m;Tq9Ape1s|$usR1v$VDAJ2iR5aC{LX8Minmz+|_$z*Tw%a)G z+#QIyUbOGr{panUJ!8l29lLk!+`4(g`k8KjyHY|1vWFRBhy0EuMi^}zY&_3!rgdc* zQm~J}jAYlRiUAaaSZzL%zL=d!HSCJ14vMJ>4XECh|ZzpY`f`RF4F7sbA9j zT9HIF5YAV_xw5bE+CZh{QTr4RD~9|bu;75e<@@6vb@K19_s{hOH6!HJ!bdc(SA$6- zjMnPKra!K8QNdeIMFVMr<20YQ74QTGqO}4i%ieS#;`cYAV#}x`yoEr-SCj&(KXJp& zgC$R^UBdn~QO-kZ#YvD36cmtM)QO{-#8wQ+0ww=wGcUBP&2U$@^eMsd9_$I*n zc~&Rekj{7z{U&}ttw%JH}XRrk>4=YdZzzHs*R9osi= zT-%+R92zK}TRAsgikEU3BWA=x0jl6eY>DjRsip2q({?|p;ybo57ZK;;OgTJmJ2-K( zkE7JG_Yye+LD#Yb+o$kZ#pk#+E#V2ledtnW{Wvr)ToyAcs0mR{|zzZ zRpoZ7wu&Upk4i85wT2R<9;&_;Dn3(v4f`$i6?~hMk;Cob)3MyZiehwyN0vAwa(b^8 zNCi5@-?o%gD(i@T`j|MtT!G8x?!+Z@4>KPooZEf${x{unc+arpF-~PU&&CZar*VY~ zvV>l9$-Z-dkIcYW9~|I}Eg`7KMm!h`M7`iH@xBI*B&~=G7mm*-^1^C_zm~I;oi5s<<{4~_RgE{Jap5co36X&AZGf4 z^Uj_R!787hSE9uf*x%~D?w z6=4uybdIeM4H0PW(3)!pbxSSR*B?lv{B-{mv41LqDK&{yE~M^Y|3v-gbSNGYcrjNm ztKn5SC7CtgK2g*GLBuX6iXDVtV4o;51YWvYQ00hUi;JFoF{u|yrHrKH?pe5NUbOI| zFrX!(imXXK#j9mJ`Lb+elj)jT4@#PZgJm)w7U22u3+-gks5Ja~ye{J!4X>Qe3Tg^2 zc+Km}1-*gcaFXoOvNw|SYlH#sv-!{3JcUJ{8R+MPpKcUywPfJ|=33-jzlC`eHvt?8FZ(4{ullS<=b85p*$;l$vlDabl0=5j7&mGklt7 zQ6w7|cZ+_{Y^V^(3(z$EX9azn#v~Liaga&LN@_M%4D*V?!qqDuNa~y#_2zgfq9V`} ziN_y&%me3&HLPWN z(pfbrsk2Pd99(kQ!I2&DHLmV_9?`c9C4;Qb>eKi8_eogma?&T+=g9Q^pXijZ<`pOD zlnMDgp#vBz_=fi|KKl6k9)0B9?|R3*Z-3jZZ@Kj?cfH}}o31-@{gH#$9=!IdLzi4g99}!NZCt;4 zdSVQB1RbaxSfUk_UYS;K{HCBOFztx)yAtGUZGv5h)m=AuQA}s{P*L`xb5*?1-syT*~MA zR87m|8tGhd)tb3*KsF4vsUQ@q>Z&H5Vat5`HEF;3!V)7bm*6-h70#AQ$zlpRFej@E zloCz!E#6YfmX7lI7VAabnP{mqULINV5OO~>UH8RQjSD41ByI>BjXjE3>Lk;(W8%nb zP=pvtvn$Oq%65*ivN%pIvO<1?fHTa3j{I*yIe;suu&szlyCrdQhF&zq>?4-~F}tT$ zu*|BJE4owNk)e9Em`kC+i>{#-<0LytZkRG-;xK~7FzM7XC$S}{6RuoO%K|c$MA=CO zz&jF>#Oz+TFH=WA1+g6wDIQ*3s)&K$KrWeGU9+-7eNjOQ`+~`6E{ywswC0b!33a6~ zup$oAo0WZUxWlWa;z$zpilsp$^pt7ds0QT<7w{<}PK~uV@m+B*n}E`cHB4wVCZ0q0 zYo8x23B(ge!Z_=eYf5?DD{QN~-rWpNz8s+_z0!7I zP_)c~dZ+F>W!w6-ljFmK?N%L1WHuAulGx%-f(SShK5y>2tq7htQWi1-Q5zkn%W@n* z(jw@J{t;PzSj5y$!26FbV2WH;R5gDli(uR6)M9S9(5p(aENbEqag*5pkTm3(n=@HN z^1tB@pCb79P$JeIDovCY@y4e;*n|mhAi){DajSSYqVFa9{FVo2NO0{n77a-%fmw}{ zBbO3T92rEcn_L}$vrqXv%j8S>QjtU!a8i}cxvCVn2?INh{BPyN;2l{vU*3B`c_I^l z_hJyy4#nutri_)RY`i2OMh^J3L1>Zv^;`x;6EyF`??TeKp^B1M^2GzfkmSGOg%_yp zB0<;uEAd@0CpN%Ob2)P(^Kkdw=Vc5XsodwMIgyXSrGDVj6*YmA4xYYeEpiD&*5{YD zA>Gb^wIibNt|%SBc|qK0^E$=|nr;@H8}*X=7kThDr>Q8(;wR~QxGus!%b?;0Y*Qq@ z?>cnFri~19?bTP@c<4srF+644#@(BCuUR=U)Zal)O{G*sf>Y9#gM^v998E8?g3gF>+tpEGvTX%H7Fm~ATTt9bm7H?y zZ}joHUrXseK@9lnn{&Pv%GOAcP@z)}D?Dnpq>_1!lXD829L_i8P;(OU7e~jd{Y^#J z75w|I8clm5)uhKSdT|Rt964EruYZue&iY-ri}lUDaAqbro(k3i-f-3HH{uaMXXIEs z#Rv3o=F4BGw}N>k9#oY~GX;kgX;)FSXEMl9N{2E9G0lfO{ur+)Y02*!rTqd1bB*)Z zb3^O8{NQ0 zxUCPd#%6fZy_j-ug7x^l9%xCZ7Lw#q&c=5DxhJ7$I7G7FqJkKO63rSBMByY}fJlua zRgN{t;l)VA*vl9ZW7pxUF1h%uS>jKA|Ksm{kT(tch${@Z+^|ei?6)o$_vh$ zz38lqcJDl8(>i#MhX!gDTtE@_W1AGt6i=tzkhZx2lWO{WhY2IlOe51mC3}jsWL$6E zZnm1^4<0NH4$h}63|3qs$aHR)jt;lzGAmY%#m2xl;3L8-B)P#l3Tj=-P0u;=I|%>$ zStr9Pd71U)S)b1vtaw66T&v*o2Mi^RTR=j-fF@=*(TMrO9yKR>N;t2i;(%OARx=Sc z=kun6b+0cGVqZ_DT-J5CaOw2+S)Y%crs1OL6c5rkguMsBWa>Z{wlSZ+?ay(hg2b5ez0c)$eQmBYp0cR#XVQ*hl4-N%NrI;6TG@yp! z@lewEHo7**wJJ@Uwd~iquU6KFYei8`)~lXeB3%!9)2K>Sjdb(R;ohb|AR7b^>T_S@ z?gtMNKVOPj!R&!=_yBWn_wDOg)i{546|eYl(hRwHXCbFzs~27v?4bhoP=mO()FY6l zK`y{ENQ)(@I(`KKaGwV|wy4aKS4G?#(X}w#+@d1xnx4v}d-(%iOPB+=2z24zDdbeY<<`qC7Gm6Cg!l#PP}!Lb;w5+DRFfKs9Y&f)CX~iaG{FG;@>z%(A#wmR z16j2kqLR8MstFDmm`vQy%%<2xA(`jno0M?d`D55N0e_dW8CNA7w1n{IzSQnjwS`~VWQ&O4{xXlyK@BfabxS(z zkMn#^%AwG>gG@W1Sq4$JEObNb{)jHKb43s!8&myKW#6f%jzfS~x5N?Onn%m|8na;&euivRJV zr8Boxi3L(+nbktkyyBCD@;fd5pR!)(zLu`(`Di9PQTyqavSGbgl(ZBMfXnRU#(XkR z@bCz@EtZ7^B*}7(z5uecOqp2ChkRkLB^S9sHPvXs!E5+5RZ8?{IzL;y>}Ha!q>w5q z(NF5Qk%VKb;g~Ho?N^e2tP zjOIYRyZb;s$9cu8n#f1<&LD<1!3y%t=W#w^|Cvatk+${YWWm-1t6}qI102dUL2o0m z4RVhccIzPPHAL?LNLN`fa0|Il)qLbEJDd!-N*JGg+FnNUX}fN`fh5qr{+1hVzw!3# zuDSAxOD=%7VE5L|>({PcF)~!ExaDUE^3xqeb#n^4PmcN= z!^)*ej;eDw$OwDD6s0nlmbw%0k-K6Ma~%7UTY*ajwUlCn`~wr&T-op>bv88Yjmw@u zHdo4IbKz)ky5MO90wa}`qp~*_uv9s&;)An3==TKQ7wc5?aBgTKw8K`y?AN(JU!cMj zT1p1~U?DkNMAa%k64C@UB&4F5(aCbA?$spZ)foO@7{LnYbYC^Rq8KlyqnVnIOT-Ow zObxbzr&nZOi@EP-rWxFUG}Pu8xv!R?7eXO6!EFHy>IKS3e?x^mQsRfcVNf6)Nr5O6 z!7@{mVf`Z_N!#0v*ia-ny4;4pR7GED+t+Zu+{oDpTPe0#CKO>!;t8>rd_nYYF)%17rJkC<0 z{c(KuQ23ka=RUyew0-1MTm5VF_t(+SLx4B?dp-U94dz?yRX8UpGxxwZkN@z@*LyFq zS92f6clR<6Sl^wB?|zE!B<8+6fhple+yv@bO2}X$pLh0t$NmC!|0L%A1o?a#KK~dK zCNb{}lh0f5`B(J!2gbIn4SU8VCJ>`?CNwO2r*W#UVyex9*5n|K8IYgdbi>+$Z-{d zHa%rVf^KB_AaRHwU#-;q1o=W?6uYt|WlUwC?9Xy!nB6}2o$E)&+vVa=GMif8*WJ<` zJ^#o3H;!!Tuhe4UYBaZIVr+K!JOuI3Et3JgwxI6@v#L9V+RM1HgyV5Z3`gqE2;0g; zT*HV$XiFeRXsk*TD8p>xQz;k9l?JJZiJUJvVwdi%jnbGL!H=GtUVtb&J<=KlbYI`(igvX!n#d$JbvA8a&+CjUygbpK-lls;y#9e^t2)qMD^#NXYAD@B z&yYVrelPnC1RKKST&NFcLg3vZXF@Q9J4u}d&=89He#{H1U+cOa*295dAmEpA;bf&0 zDUFs!L&JgL5;$PIM5c({J2U=|&ri+Vh!^$~(_Nj8VP!-$WN{)75%mV)mjkK9OMwaIk3qr*O3dsvF+iDvWCGz%uYrJPmd(;j z8Hb34M%)1N^nyPTyb>wNV`E1*>^Xa9c~vAflO0|)J{{~JmElKEX?mHBZ|H8^Wq3wG zo1r!?=?s2v_)ZQ-4>_#o7ulVd&lIYl;KnS4#jz={6X-(@g@I@YJ@>Rp9r6 zy+ZscnRKa;u4n4yYPl?f!^THwVrVQv+z^sH?2^K!h&CPC(NeIzyX(#CvHa9bisOAF z&8f{hW@gTMPkpGbuRR{^duH&0?H!&QkcamS#d4cxHtyaxJTlfdJeEz5l_*V7%nxh! z>sa%pE)oXKm4v~t%%!C00dCU(n8Zkd0J(Q_I8;LYa{i3Z{e~U>4Ew-v-`s^@OtS7z z!MI7b#sD+Y9qeRr9Vk}MHW+N==pwCq!P~Quh@)g>HC{@TaPSRP60nW5LUcB;!>vNZt-&D!9f$gymAH8R*sz(@FiI4A%HfXM1GV4u++kT(+3lFX zHJ+lLpc5!~N1IcdcFwFJ6WHqOj7MAW=aCuY2b7^bL($x(nN7Pd9Kj3@jpR~eWyp}* z;e`1)Zo+Zy-38sq<{bNcARg!-mFv!6+>44+UclbkJI4Hi{S?-(^dy*bs5LH+baCs#Ajs;*iw|8?@K*L6*hgwhjP{PPlXxz{;FhH*hRtpZphS(pY#}8y zaHt4hxvDEyTSWX?ZAW7!4d8x(){y1iH!7&!pgycA*D3S9i!0mQW!5c7un;@ptKtgJBawQrtq&`FIuQ&bSQp|_@YaL7x zcJLe36;c4n&0(*vX!sL-zH%dxHdY*l13vnAck)VJ*lcBiHXg!Qe_dVXSEpVu?ej!qY( zYPNo6VlXn63`Vq0c||xKrS)mXfX4(n^%Q+fzN8tB}DdI8AVk<7>~lSTSCiJtPMv9H;JgQDpAIUl@QLt6YUW~0k(5-QH!ry zJ?fKn;-!usdT@^@2M2IyN2r{C=>=O5+CqOi^sWop&)}@T^rT2?w$l6DutsPVSYCeE z-#?ZfF02jvo9*yWq}|G^!t?|0ULD@~amL!B10*Qf#=z6Ay8Wd_rA+u* zBzb|RSfLXH_Kr~r2?0PfDaqp%a+#qu>5*a!B2M&ppKax1aVeQg<@t$2Ln@Bbt*DNj zJ~(yT2s@U|%9GtTXf*r%T zDo0z&l@z^CdKgz)L#B|_pcrMJ;UPjFTImOJoG>)mbhF$e16Xa41UT~)DQbFOH5w8X zzgh`u)5!4fgm*PvP=$mnpng`Z6Er5g`6q|5l2isboDB0KDt zLuy|%t(T2@CX|G~F|zv9YG!vnH<(7fqq-3(Z|fIVtVeqEnsVn1sHq$?44kGh$D}@J zf42`ai*2#h%xNktHFDE6bYoIih$xsWL#iePe0Ei2)5A;>L_tnVSs_P(iA2Tr0hY72 zlF{+oN`0l_xX0Jrwx)$U5M?PbjH`ze<-TY$9SjH6+Q`(vzCE~8xNz#9tZ%LJ;iBQQ z!jV{|bGVz{y0cQqMSUu?QkGfM>#=RjKWbTZ%OEajm`ucQ`#8O{2A^GmzDy}dWA4^W zle96Svd}%7O!c8oa-vwATr=*^dX;oMmC;cEvJhK$E*qRWyX=|n@9!e&q(rhtrme?9 zjn1vWVG4+hU>+kxZu*HXg3Hvgq_N?U^}HAI0%~Pt1>I&y$Ph6aGs-g_q1-h!d}@EQ zwPiqvbmH0ZP-G%o*=nr4=ic?H>DzB?lgN2T5G1rk?nV0@Yv7gx;)8ybHj6PavubL5 zX64GS7MqPysnINc`pk^iUu)5^oIk4fx7N)cfgsRW3Mw>&eVCDl-3U77IkTeyc5Ji~g=xa;G5j&GZeG59G92MIJn)tOekZ%WQJVWx7=v_; z>lpL@X8Xi(WYl1v+&8eYkuLVH8J}9+$)+l+I_YvPovf4(V!Np|OG>fCUfpgLn%+RW z(it1+RQkQXzGAaoNhLB_L(iuZsTWeEQaV-g2)+uY4s(~p+>v$vd)p_KmusJBboNqe z*qOb7NzC3xI(wDAVCma6OdrQrrTSXj-7-14VrFNv*(%kW@n|bU=5If9koi3Gg{Ah% zyAV~ZAP?uDFJx4;1k&x9OM7$77ua(lBNy2x{1=*ntb5hE2geq8QJ&m&=FX&1xpe=j zpbH8M`@fw(M6F7pJH1T3c<@QU6y)k^edbDd;sI+aqg`CLv-@f(M0GxhpRZFpldS`0MT zt!oB~QL?sB0~$E=K#Kqq?S^oh1+yg}BiSBWBXeDng@HzF2Gar|1_&2>o(D>mohc=; zezhM7M*<(7y>T6~YHAvaW>+q{WH$=wzySmuKh5r&w-*-K9yc46zGx#k<~NFEe?3qx zCS`u$hMR`{-Fw!w_nuSsS7y&X6;4XCt>2=pbLZPyWP5zbW3}Zepe=s|ZL#N?joppL z&aMRP#yHeZJI4H$J?GWg9#HI;*dB;yBlbdVkMHO@pNQ2*VDJ@7UO`Ze_ZO4Fuoy{1 zl6-r%p)~85o7Vc;z@^RK#^YjVga|nBBmTa`o(=tCygL#iC&6-QRN&;Rc|`tsltVwB z@v)*@PCCX1uHrC_5AeqFwnsGjx|*a1jE1ZUfmEQJ4f+JhBWL{@j*KYLPFhPKyM*&> zyLf+Hj|?O@HW-Z;L^vLBtXUQmsfcPE?97b>=Q94VSo=9*bNHyf3{KsW3+c<*94%Tq z90gEKn*-<(?Um?zlyF3ChNgcyA5=vDP5*VJz#%7Y0!Rc^b z3uIPS#QyPkI5L{9Y=H4Y_J}(E9>cswKuyQqXw8xc=9Q^IP(>6u((pt@si%;QPUym`Dhj$*o@H%~(h^%EMokUJ(wMD8+NpGVq%Vf!ETMd~8jU7H z<6Br?XG>Zis8olQs#NyIOKQ+pC|^qZ@5kR?WA4I4{`pKh!@I=J7+z{;SY}3lW2Rat zPF00KB{WzJ^u@E&>e$uSjz@9PgRONXQaT8SC#663Gm#MOcFl?EV)X~2L@=)&u=0(PD-&cowqK>|Z5HRRw-re}H)8^scZ_PGY zr_J6m+OOciT;2Qml73b68|S~}>hV9BPntI|eUb*7oxYxR+IfiCwxq8Ypqq5L?{HNH zV`OT;{Q2G(E3t>4n?sFJa%asLnxhw8%~|dC{)RlmuUPWT)z2(^hI_i_Jo8ifjO09X zxc7b9&!Ojjvbdk0*!a_X9Ql=Wj0Xr#z|gq^5aVMg?$=mtVg|nd7-&yg9=F1NY$GK#9FRMq*=Dtyqj4t-qXFvDV#^6dMNz&BVd#eGE~5n{qV%#%(~TWg2~6 zO?amFLyGrd=b5WPQThxBMyO5kGVi1%EfTy)9)VT67DS-^p2l{W?M@qqd;g0D>f6=cw*L$32=v*(t)xIxKLVZ}XAHp9OIM3X~@QZQneGRL_&vyX>y#Vvn z{@UwIPJA5(13XCP;3xaPqQ8VKdpAYJoi23S9V{Rkquuq=}AiV z{EmMc>H{jLkTDGZe2Y1TE#?@^-{0l@KCG z|9f%5{C#5n_hB0^I_s^uUz@nk{o2AE6;+v!xnX+mSPX;M@>9$U3t)bF>>RV5W9OLd zFq?XR#ZJ3nn2}>wS}^qLKg=1Cu}_fuH|d<<=H}FP6W{F)zRXLmHtuIGnX_)NnxnT| zf#3dZ5x)2LzO@*JS&Le(Uvb(&z1aSPWH-w6f`5xIDEj&<7PfZC9;K^^%!}^J)iU$6F1j0 z7H-Zn4Bhd%?`#4Cd3UxOhTG@Blx-M(G0evum@)SJ-py7!H!o=CS_h`Y zzGrD)b01xRa~C-O$ZF?DPCJ**;|!WooH4#j*=u@Nk=@0@{Rtj#NO^4V2py+u47KIP zF!$L7eXnIt!#V*iFwTE-`UbtYtDG^^?REqgE#^M4pq+K>Z12aU9l-q9X{WIOW(^zd zec$SfR*R-MWBzQMF-|&v7^g{pQv)FL_ylK71bJrB7-p6m1BJ2X9PLsVv}?{i+O@{} zgkTYp;?i6|^HpR_cqU2b(4;vT+Y>z062dqXm!B>e<0kedlMVoxrvstqO%6Rf?A0dU z0LI~)xrOa)T(%uTtKDWh7?Vw_277U@Y-t=OtX&*>;b!-4fRN6Y@$ce%Gkh#!f*#2m?ZP_-nT87Z@b&!{?dVIFi-U!vS1!s0P`~k zrewE+vAEm0jN(H)nc!RI*<+`ZcDSdHo$hYOh4XG3XSA~r=kGavm6;ElIAacv*Vi8A zIdklQakt~f=QLD+r}V*kUjRet1Dr9xv>o7{WPXi4&G{v;PY56?>3kWQ=4|{Xr?JIjvY48H8pYm7PC`;|%e-mhGAceir}^Pgrr$Kw^yg7Ooc zM?!zl!h(H*vj%F>;xg`+=zAgkIvDS-7SoR)f-fqI(T&PdLVgAM>(?hl58L{&u{I$Xz@BRH%U7bh9`Ccfsxp;ohi~2 zcQj?Yx!;3ux^uq=g_+_0)qxpn zJYmuEiAC*v!hz|mq%de_AHb5|lJJr6U@tnBq!81>QK zIKZ4wVBj0A;I7epNF#TR#&9<;$+I%!=U$%rUg5j7ZvPcB7hm2mvZ>A0LSyZY9j7aa zlva@PvP^l%Mer4F|$%r5OmfOR`TNne;Eg z5jy(z|EG+VWMWEnbh3|A_lJoweP$7zb45yL%nCH6IyrfDHHfjgN8itk69HcBR6EQ9YGEf|@F7qW*+M3j4G?fInb zeA0m_GI{oV0wX-lp1%O*w$B9(Jx?t{Hs2o$J!i`fozZvqoFVTk#JLw!4;l3f?%gUAn4l7`tz@OZ!H%HBjz)} zg&^fTLu9#$OWhtLxPibDIc&B|c*=w+(Xjzv%;Z9t_c|~|?)|7xZqCuR1?{}mff?m~ z(3>VOKxcXZ46&)rzWTX8?>%b4JnDk^1xNfoRdfI0zuWsDU^uk%K{pJ0oddH9^YUK= z1~~th8-^oxo!L&AzsGC`Bwf&s-4|$@Bz<8XrupgKe}krWpVT(^Z!}Ej?rXv?Tz#+Q zeghv7`bN9IwE7PB{)_uDwS$}H7}%dJ8N*^2oX=Z=-(r}Y4TEvoV+i*q7xXpFR)D)n zFM=|ee|?3;J>4g1*L_gd~M>IWda z`EM4zi9g{Z{It_f-EK#4wbN@ZXlE67Ewz8i`f}{wMSaaI+ZV=Z^3>4&EDWLZBDbN^nPKjgkO+ZamI5!UB9d7Nb+2~ z_ZV55pf!oK(0+mA_1rVS(S)(FBYQQ)&f=RJTrm5&PvW#BZHN7?-41yFdVbVw2bZ}F zkn@!Gv(EQ5^LtR$ZUghasEzkqZOp^w*?&P^3uFOzRHlQY?RH%K&BLx@J%~MEzA?t1 z*|@>?HjmppY!_7fcMJC4ZCE;v&o05C!u~b$O@ae|^qccIkTH)qv`ccknD+n%p5pf` zgxTZ3R5+Hoguw8oD`mMkn6Wry@f>`}H3tW{_aTO1>Hu>KaQ}?cSBGmbpEL3K+(LXl zX2M{e2JLwwG;+0X(}wWMJZ<9Kqe2qTuA}_aVL!{JO&r*?gF~FVk^0?Dm?ZnJOZ@-P zi?|O6oYPL3eawX6smI^sn`1xcZlr6+Y-cU=p*2w{(g?!p=skl7Tnx}?sm_2+ATAmH}S>X&EtFavhA*7{-hUQ)NU{CZkN~| zpo43S-*Q*; z#P4w~T9ny-=DW!HQiuzec#`P2>aY**!YZpW`zTBUnSom^jJ6UWjJ@7Vuy?V$0CORQ z*^lhVE!N1kEDIyCmyJGeweq|(u7&ML>?!fjSTLV)!4Q1@$ihcrJJ*s37oiW0aJg`P zn}hQZ^T)k6T2OCv@FDHY!#o6?ba|L}JAL&Zx381~lRJy}+Q^*F``SzzzR7_pn=s(? z#V~d|L=HUCqb*@%6pGTB;C#9>u3@Iw3t8g|Ef^Px?VIy8vKR)rK18|7>HVL_QldvHk{l8V+Z|1$3kzs$I&);B=^rpJIx~{IOuI|^>NlBbmqemTz z^e^j=bndxvM~*l9 zgsz9$yGuOfSc|F;O<}S7ZClv9T^%=ea`rVa9dwP4R4EtS;@6_|=MDSDXDX&Oc1RbZ3G&`6t_0 zYlQ!ukH~)BAydz^V0tP3!6q}k5`4t^py);c7c-RjX{E8HO+dR5JWFXq%ro2I)IH0I zrOkl0A^w7`4H*6cx0#R64{3kUUPhY|$QE+Zzf8|XSHs`Cp7)=i8-QsXzEkn;PMRU} zPQqM=eh9`bx?mMY71XGrgM^{{i-hUU0He*XNY(%$?il*J>>qpk`T^X7P>+LNzt`>% zE~?>xt=7cjcQ6vtJ{W!C?*t>h#xSq}nPBuAO&G+m(oRSl=`s7`*w?g`l%qOZ_N%|m z{Zh@IWo))7<@#prHe{CSVv$_f6fuq}LijXfLiVFBQ=h|WIvio{kG52^HR&wv8-gL> zgfLDu(wV9VAFaas5_Q;-;JW6v9pkMV#=M7)hJ4d3UxwARZ^bCSlEfu}0{h zQ~mx#_4{M)cVAk+fHme+zb5RTx!)x0yYvgb0~T^heE*>GafluqTT}IwlBYTSCdS_~ zPR6!0UP{|c^HFVS-Tp-CsGlHqNPmLncmD$8TH7W@wYELQxb%kVJO7{OYi*ktS<$NM z(g`B>(i+=5NA$4>6r%$YJ=cGo~67GT3CdPQL0mEcuye`*&FEOl^5wOIr>1 zwoNLzIbX@mc|2}0+p5^1zo(8WolP2dzAO{Y!bLcz$THc03^>|g2{+;&YB=>Sh0~P6 zX$#A8QCh#qKN|ZL-L93e`ZbJy8PiSH49FOQ<&)|}Xu})hEYKD>p=}EOIq~C0ff@&y zEwTrCJL807d?~R1C%VM9XQE+(cSG726S0cKUoCbD|9#>>6XQ^#joThK^2Kf3R?=-ypTsVVOH0zt z9Ea%kWd-{N(|AyyXV|1XC163f46qsLCULUU?J|AVGM|us(fl{l>4qlXB)U;;goSPy z+9v6y+L&~^%tkl+I0?F0$Fb0@QQ;`*7Sbd0-%QfYG7i!0Wd$qg7Sdm2SVcE0Ea;X2 zHl1z>obZE+jY$qJ(_gUAEt;8bh)kGnPAlDx8;z>1oKMs~AJvBFCg&r^k@Jb#=VOKC z`J}<7&nJPCZ9a~FX8j48`eSUy(E+Ld_WTI+-!Uaf2ZFjO$0r$<9%OpeGQI4$q|VC@%QThqavo%zu=iosZBZmnDxh$47O_33Wtv-PrQ_N8`_pJiW8Y7-wClU`7wK&iv^3jDe2)Q^WJKbH z44G{lQ{T-tJPu7(+DYdPY8=G}$GP+OC-`8tF~$vSX@G{=F zaNmwS;`5k8$9v%Maa<@UVeR7`bL`PCPxL|KjbJ~*E-!MupxD|jyXaH3HRbUS^KT~T zK)FCO9)~@01of|BoF2xN#;L$4_HmN?aVA?84KuaHIJEZL_XOW<9LZPvI2IW;o>4fO z^TA4^=3^fRcF%FHf|YhJV4TOWWXDrr3fjfV-729(uUZdo3fP2KGA$HlX&R=&ib~B;}4?QPZSX+LxnK`mK2Mp8?fJXR#WBNn ze0|2bm`j=|vsPHkTpTlObM1YY{GvWAx`=D}$Ud)6or~iM?8MUZl)2cRr_P1KC@Wv~ z*?h`?{x6w)O5nr3kvTajxA3p6{wTLqwn(nm)JD-E#TU1aW3Dk|Clli&*BB#TjbqxP z16H3>wV`o{zh73cL=&>DC03u(3QN9tI&6wAOM5SIvio3nivI@fr!vZb-DV$mtWdD2 zdi5vuJe7ae=c)XoV+L0KUBI&FV?2;$)X$iI2XjdAx6pO89D)afpB4Q?QbML;5oen~Vv#l2qECOt9(vOW=eb zWK4iM!ZO3Ba!I&}l;UFXNzNtFL3b&;Dz0A$7UKX`xI|#;7L{v|Sqg}lEP7-eISv`) zT$q}Bqdv~#B>hw~e?WG2uYx7JOtJd^a6SZgOki!4dz(@o6C-Vtei*fVuXzfx+ZkZf z>4u5OaZ>1p8du8gNxBV+-*Bv(q??L$72O(BVKdN8M!IQqW1Cl`=r*X|vT*$E*em`- z0HE0Yi9J@OXr^9BXTT1NCwCEDQ!Z^i>ZXxln z^W(xrGDPwQ604d<35)uK6xdX{sTQo!HhFtRj-%)n(hu=CO5T#=kgarF&U7nQbPE|p z^W*7svyNn;+cj#OH&|9fMk$Yz)YC+bCgd#xY&zW%1G{==#~zfPPfE3 z3Az!Exb^ff=+>>|I$&Hs|9#<7IZlepzOUA1*n>>4Y2%n}_?(;MPlj+ptSHf z44XcVYLjrDVMpOR88N`fy?&x!s_54o1uo~N$4 zw6jf9mTTd0;}#xsB(-nHkb0idx9$0<`W~Bq%eaI4n6S;0@;IMldpKboQ`%V8L(?7y z#H&`j0zO;y!^d{T1`GP9u4B}Kqg$JPd|8g8^dq2uz^Wg~aacd>_BdcPWwI-(4Uc5e zkHcylsUHEO-D+2?zg5}##1BWaJ7pOL{HGi)l|(0Rhc zveE4o=3B9%+mJrJu2pyV(y;|# zfg_CrpKJ0~+x`w%d@D7TZQnAg6pqUkjxpZzpu4i9Zxew|AhE2{r2|HpORk4!9DWsptabn8cAu1dzITGh$n3oyaCg-l& zn0(Iwo67g(C@FkjtZDCx#;Mo;EMe8$L*jP~tLAQleal#^_5ftRQG35w zf6X`-`r)`=?1pXyP2L$}oNp%?x98gl_8{AapssjjbdH}xM9IQMjTsJUY%%iYQDea z2rAeVx;nP1Ji4N*F~B)^J6+9uda8^DRa=EC_F*XppOjHE2T!_JsB+fkI7PtNj&l)} zKGPYS1p7={WoV0Wq>fw9$v*|c#rXV;?l5=D4RQ> za{aN8nS)T&-tBo_^4k_3N8)VFs3w@tw0g^^w5{I({g`$AR=7!8!FRE(-vPbNx_;YW zM;te)JZmN%n5J3w`<7#qg0=H^$T*$X7$5V1a+q`Mo2S?%_zfxwu+T4K+a&!|8#z8; znb(=dNzgAjim5zIzagVT;VA9JkY2|zfUHpw;b5vuK>^JFuqF=_h zNcB62IErAC{U%}GGX9I>!eZ5LNWXw%$Rr(9Obi{ZH}03Ps^5^_!LUg>Bw^97%Ew4L zMD6`rayQ(sgjM}U?fu$d-*WyN(;=Fov-QqPdCpPpBWY6ruU`p!AmMS6+t@w-{v_Sd z_xlsE1p1J$CLTsP!>ak&VBaxj*oF|ux`jL_bdqxI*5|2xSo2EUhw@*j58AuBhy53oQw1$d|AqTa zht=%;TWb}d{Fa>ESCzkn8>*< z;TS2&KlXH_TsWQSuqoq6{!yF6K-6}=%r&55k`$WQo~P0zMP6M4DmI`#AZyNQ_Uk3C z*4O|v$po8*tJ%hyAI82v>ZZt6$+{`BRdnM;+WusM_Ym@A&0?mug7?rVk7JRscN-_M zKWQE3MGzMBL~hktq;Qn`%TZ$k%d*-tNsdFgK*wnkR_)_N^@kZ&Z7e8T})skkjtk2&?2An~07kJa2)`#`^yY`{) zPxL`H>%sXKz@wxby`a`2(yd@RY(_bvHdZ+zncpX-F-|lUSKIScTvP0;YajDY_Cd0E z3Y(P)HVs!(22*X;&1|y*skl0=&))@C8!XwZkDkJ2S^HJ6DY!0dvuwFDTjI)Baw6+4|4%k6 zQ(Lis&B_D|9A)jsyVkng#(#QY29HEgh!yn#!wZd>~>^9CgO*1XFI zU}a44|G=9}Z5QHACfN7Pn*p`v5pR;Tddq2jPB!cmIZEeKGH>Ac8v8vV$K%L@)Z>P6 z$#2|jmB_)vX2NYw-OnKV@TiO#oi5pja}fKR)CbuMQ-2fZSkMQ>J6v&~d>hh*KPf!^ zuJHJ+!XrILVZp;`%_F~cs=0vv$#^%pKXJ-7TgOf4w~4tcA?WPu9dlpAIva32&vYMjUa9He-30Lt4S!?;=<78~(B40|< z-Tf2kPdZp;AaCii!uJ?hanIO>e1u+>bF!cI{UhL!^)qREoI4qJ(>JjDAjoqk#(_>` zt!KJMC0!*wp`)ND3# z#WTd;56d=+zX8`DR2%7&1@tS}mJ*-ky?)d-u}dfQBCDMHXjJ?TxaL(KB&(_RIO(q{ zDlv_YxxXRT#RYW_cWq%Vi{uz-y7!h#*2*t(WYQCouYm0Tl(x)z)&aC}{YJ)?k~UK2 zF`q&Gw`_NaCU#pzZ5~r?7SLzcPMKrNj3?~pPTAgur)fXAe;I#DeMQb=$vmCGW4i$! zOMN67TVhuCPT5BGi8lYG+CVN+bV~A=V^>#Fm&mT4q7R#Fnd1_!-JVmz$trOXYQb$*2V z?W&E`*^uK^)yAr`6q{Sy{07 z{UnbqRS(i`IImT>%k|XsU#&c~w;@~`)i}({^ffxA&$Kn#^}O86w9(5>rz{(Num-qp z;Wc`_vON+{(|3n$Q9jSaDfw#klfbu(cqqO>pQKM?-&-BYhzHtO{C&GU;&C{BT5p># z@#VW2>SoNHSaUs9evj)G)yJv$WVTg)p6mIH;7CQwT|DBATK=o)ye%m=Chb%Dg-;9In0_iycp_yTeh+;rddj%f+E)TEo^P?5 zZz^63%r}Xb^llS~srkZ3aJ+lJ3O5;-E8G%sIq~9IMKdXDA?LPJ$(o6iokpkXLyeDo zn`&#)#1`v{LFa#{_!v5#!XwfX8E#npMe>n8Aa+A;f~Fj~8n_Ru|D6(Jqs=MCRk)_L zL99fzG*LN@^zThMP0TNm9h0({y;8<`wl;Q|&Q7F|6-`bVliAyXpJw!9@h*eS%{XjE+8Kx zOy3Fj2~*u8+hSVxGx?fUA1+heW#@5r(!xtVxHrg}l2g*qM4f0z{`!w>In#i%)sizM zc}V88C2PCBNII^zRj1k=f{q_?w9jjC|9&V3}E$j?0Gu! z=WkZ>AoI`x=T@~w%6%)2LBLbWX)frWWaY7*1`>9+G--EJWGZR9t$cx8+WSb@)J%D& z1#L{mI3Tl*TfAPoopRfCEP#9)#mt#~Ewb?(Guw3={wmq6%>ImBrlpRtA7c_k=~$Dj zRn3^Ept#~ADvx202^N+g=%D*Gm5a>en-dRCv96`?QLYv2c3JR#H~W&A7qGXcolyZ- zLeYs9@!z=~;h3}0n1mD}#?@=_>C#Sr zIM*4U227sgQtA_LHa<(c1ivrUf5Bsz=i97jlUa5zk@35HRSDS?#2Q&&m-sp9c1McA zm+)u1^_(5m++@ALa8>Sq;_qu6H}mH{{qy*A(Y@d3uWEkCwVUBO9M|yoBgS_AEEd0} zPt=X(a~;|i{FcpSJ*NK+T-<-a+_LXmOsx$MJ5Z}BDBoFCTygWs9s<#`W=Y;OW17TS1%zcSZ{A)ItSZ(?{#IU!m{Y{_6U8)l2 z(4W3ZyWg;a9C6Ej80LipOjN?a!?eK&k5QI@>3#dxs-Iu;vM=fLj`j!rb->8EN5(Ra zC31DTLtMf%I2i`>vW=zvhXv-=lraceLBz4U@ z^R@U(HkcLK)rfWh1L`K~kK^A?!l3_^X8*wLHGcmREFk)iiYn|%$hOIP-QOEC`V!#Z z$S?sthTQ;}$Ge|6BWa~yz<2vEk}zUTCK&OGemVN-kTAGsUfm0ws38Td#0h>MOpk)O z4Yq@f7+E7?yk-ZgiI>gNnwKL-*p5$(eCvGAU~ z2N`GT$0b%o((IDNnctZ!$7(R~fd(h=iGRb^&&v7?whMf@mgi3NapNBM4H6&a8<=yy zR$l>}B@Et;F7M|CO!7>?kZr6XITko$vy(O$y6aU^Ir%;T_*nX5Y%8oe{B&FK7~2Yh zk@-~g18nTJBGC`sOMQWoxA-%*{$sfBG?T4Jv?cl>uZ7u4G#g zWqK}TEB=#hMT4Zdp4nEs&9(xksfaI`Z3TMS55BZZ80}lw;gUAzg`~XcHTq$|bSs!g z;8{x;a}S4XZw*dUOBmRSLkt1QW(YZ7hM~LOsUMeUkT8(8R2XwC*bE5+8L-2&vfUU= z^xKM4Ty+|LN58efX=yj;g@4k{=(xXMes8xMcusZ$?KIOKWV9QW_9k4K-I)J3=)o4_ zYV?=QbXqk3Z`w0*je3SiNqUgOFn>-sD~;N&_>cE@IgYcP zkT6jb2Hds7(7W#>Ot09T>7C~2=lldrMBJS|R{Rkg&N1L@^G-lRy#Lt-vqB7IdiOZS zIwvt!gNZYwErE0VFeUzmiE zwVM*=#biH*;C{-vpM~DTY#nQX_b}V~S>PSZHkeHBSjPKw^$~tAOC{qI&~DB2&RuoxFZnZ92ttc+*8AXdL2k=uRz3 zy<1pqpf?KU7YUf&V5axIQa|)wN!d@dg!_S%CiTceMlcuY z^ghUXNt+DsgT#9hjdNH>q1S9%znJ#EMUtIWY+KtTjP@UCwv}}2eQaC1)L5=G+bUtm zwn~_&{tXFZT36Bu?uTrvglRBgOzX;e^GEoXKEU+xSRwH+>nQm??@Q{sfmjzX6bakk z0rvvG+l`)^?|{qjZa3f|*w!usoLj!vOyMWk*3$EO`5bi1_zpAZWwMVXtoiOO!q@Wd zEhCTL0kuieFVj1qaNk=Vzt2g+WPG0!)}}Ik$K4k616khB@3ynNUk=|J{h*8dzVX}~ z=`H7gaF`#x%OFR;2;c8xJm{T44>R-svwPH(K1i6G68*?`6-cNL zDjuc6$UFKo!Q7b`s}1iS$UGLkzk_hbnmuy`!9c36F!he~#VF?Y(8xW!jPIerU5drp zL zu~53r0gTOGkIkmTu>IrrXiK{km9`6WxB8Lq@K`R2x4|2VKPV1xkq?XC2A9jr1ZG9Ko&|9xu;?6z%o?uAH?N*xO{W3oQ46;(( z-*tGG3tL2fg$s=>`9d&~=67o8FdD@%h7&M|W4NDxLQCYGFq#?b;BGDPS=_M6K8sc_ z&ea~#76TTaurZuBTP&xbJTUYyhTcApHwz#dHyb;Y0i*Ji5 z=sr~7(lsYuoPtpCV6Mo{4z6kRe(y*%G()gt}hi(?1yKlvd z__N}^QO@OM_nca+&7ToLA!|G@4UDT@0hrm!^9j4kPHAMOyI!YxH*;<@0)(KVZ$+opuC za816~c-GsztaD^4=PdmOM^jBhb7B1FuI6xEJ@g1@cFg}3()1ECbA{kpt=6V}bnWBi zm~BhGa21Fwrz`6;fQf9UIGiUS2Xnbbj-hEa>$L1_eaRlVRG_*hzew-Xa28PrG5m7 zZbsJSqF7;Fz-0(WkEg)xz(sx#6Czy1&17=P8Pki1VCrbzICm<4)FCZ3sZhHlahy(i z3z`u&KpcNfwa~gcs+((?ecqzN+#D?=LRlb$;OV;ky}f;1E|=RMsdu>|Ocsb0h$BgW zuOT@Gi&0l+Z=Y!2uybw%iw>7w?!6UV38j&3@5(WcSrzP%fpdg`J}y4pAPE?qMe z+Y!=#G`V`&w&<5alWnmx8*kd&)O}!f-Oh&S#=bQh$6D&6OKZbR8=|f8Z&xqx?OWai zGY)e>HbkRy>4sJwE2Ss9@LDD^A51;NV<@vhCPP{a*{)%Jk0?O9e9apxLOHf0d!OZ zh3ZE@t0GdFx=KrhV|0Aus?pJrP1MnauWy@u{pj(Z&u;$(nl*}E@ejOtszB?C73JsU z=45BV@;RXS`P%BWk2h}E_7s#_tJEffpQ9g+L9>BzfmWasbKHG?x8GgwuJ5bw^Vf^z zB~KT8;rP}q>!#0ts^F_W{q3iFcNE^+_Sj=>_ZIHxeH!C+YG>(LxU(Ra-em=4)16Ze zp~3Q!fh4hkY&iAxYzosw>^UtBr*-S3WmkLWP=KN2jDM$3bdU)J??25VZHs@l8DJtMw~*~vxAda5d#8Y?}w)mBy2 zg4RF=cn!=yXsiZTm!Y=$;+VHoWb30@x{=)`oLakpG{ag|!CKX22?!dNW4t5Iskxl4 z{lK2>b5L4%khm%v1}lnAvkg7_@WKrouvrCbSJ&3p*48!D)<$z^&F0rM*40N$3q`Z{ zyIm!0xmg{FEYgto_W2kLHf%@-T&a`Hdv71C8o?N=eJ!z;)*hF$c%)~1Q}?dnmMvvD zqP9HL>|cAurgQI^y!*DLXEiq;89w%@>47uv*?Z{zopElKyVy1P^Pm1y|IW!S-I>+2C@-|c^AkuH zG*90QJ{Dnh4r?o8!!F^(s91Nigwvs8?-Vc3+(nBG_~mejT@XVc5C{}|gcb+~YAS(^coa^V*J;o37RpviGP2|z>=x!ayoR&XKt7yeluh zxNUPo!8`2q)cDkY;*arj#Le;N zMI`=H(F5wfK7l}otu6PPtwmz+Dj{L^ei?DDtr=j=~xFD;qy zl-+stmMd20xL;Cp)vsV0*2JnXQvm~i5EdPtBgG)#kcD=bsRyjVzg_wjGw~BMGvdq{ zu`d2u$l?z~EA`iaf10?z>R2VM+_Y|IVLs#d3(j+wz9MogYHE7=JoQ)~+#bJZipMu^ z7W(GR-Cli3Nq2Y2$!{X?6!V{%U#>lc{))7QSS_tR2CP3UKddH3+fQ0gBAlO>g~(JC zIUvApIM;CPp-a6zK(@r?D2o;pMN7&TRm>dts@pa0aFui}(*Nz`@WDN%9O*|eMu7&L z3$hH|frC2Z$BVuL%{39j%_P8*($^*QBd48QJu_1^6#Dw%_^-s4Sp2sj z#eLzG;h)5xHT%YH+H^!9|LeHRAY7&B8{D)8YZV^M={ac6O*mTvajyJBH zcIuAb>*t0JFOILrIG@*7pX?Q%?>kxRM<`TOkXMpl z0^vo)gvfO#Y&Mywh{sQMitMzgy5X{(_aE6f^W^ri1N*k=&z-q%$-!9sH~Mqy;=f+L ze$`5z?^d2~iB_#O#~QHK8&Cx>_i8A*sq%^lP;e(G{ew{1d zm5~Zpd?E6gkBKxwfWVR#yI9`+WRL*Hh>(@=Ir2 zD-Z?AFv#WhMhrV$>aM!9X*l}K;YV+pdHTSSsSQ{__a9h1z9>Kbw5X52qCYng|J$k+ zBTGPruKB-;zrsE?YOS&6Anap>8%q_|Q><4I4wFJqLnR!wEw#L0)MCL%C_0!?SD(o` z`m1#D*Xzz7JP?i^S-fGgYW48;odX9~EZNggQ9ISPVrA9Jfn`&RzP9VM;-alZfuWvA zd!xT*_ol^@Ev3clib@x^MceC;Bv`|`0l!|a0o~kMFjhvip}+t(0$)lysWihAfl-Ty z6*I5v{@3)2*RRJsXCU`fOI&QWMKhgQK@#tccv<2xDVi0VEiz!FR+kN4yz8p#XLioc zO-=t}&GA*Y9v3gfJEnFYoD|>1$mkn?bV#3;r@@A2fpjolSZfpJ7T@P-dD6-Vq>Q?} z$aTPgdG&4cGxM__D=KxnON$UWQXCTRZCMwIOe{hyNqy(RWw;%EhsXv6i{0?I#W-0X zi*W>CNDGyD%HeE5YcYcKv#diU#x!pK>h|liigI+}Ex2a;zs#PM=W#kq@-NwWML~(v zS)6w!p&?$X99Wc{zoj+a28_mAx7O9~Xc3RaCt9~hz&G((#tohy_Hk(ar?n;vmMzU( z6&DwqcH4+}F>`O1p^qH+m#25V_1eD29^ds-(HnnG92HyRZ;G<`r_rsVCq-mg6ot9q zQfg#&mFkXbk3zJjN7O)kL=Cbg9&thZL*kYA2gL_g>b)CQo_vn++kiN`9sRmh zln57PZ^G@-X~E_>J7K4g#_AI7Z@h8cb@v*F#!g~&23{1O(t5{v0=b^xxm-*ry~ue2 z#HYJO?o|A*QY@%l`TQR3e-qC-L zb6{6nVawjoYp}A_A?7P6M_5uO42KKho}rRca3>KrxD0rFl*@Ai;pjTep{f@Xl>i6le5zCO8TQtXR#fsFim zdPMb}*_g)>S+{ca?n|!NKG4%NUK5P$p4hjpuX*JRLZsRFZR0IX>o#tfav0Ssy331} zdxPx z$XUZFLTICA-~A|1^>0|V@4}1rF5BRD7cbj($Cl;8+r~!qW#J{BaO?HgU3Wuk^-BHi z_#1l;I`$quZQt`GQ+?p^*YvQAujsw!{yZdU%AnX-ub|*_5F*SJiakf+7HqrFkUT8N zcAN2)Gu4fk7*XggT1L-Ww)~v2#__UXS*+sTb>l57DuTl~0~hVu`@R8B-sAQ&d=hSkfnanzJr*`8k(j#h z?z`Z^eM9TZ5m;{<&3BY-=-9Gq`Sj@MXbxC#V{1)*SK*IOW{rj0SMQuH+IQrfUAs?H za~I%)U#rqq#A12iL_WL+SXU&=;m=4vW{v`LumhOS0dr(^%fYIOU|Cgpm8UQ#3;QX4 z;a9#)sn1s<I+_&|;HNi40H>mVmLV zADwf>PwGFz5>%+& zxc2cJ2#Oz+l~$#49YyF65{_7)&3U%3Yf`_a-LnN-$9!a{D7ZXq?R6S@?WKJo>n@NB zI_otoBwnZ5z`cstoeH|8a%eIqGml zI`ktaKe84AWy)1PnCitYY<6Kmt^s13vgN^Sfw87lLGzVZ`SDEd+hD!7d^7!&$6H*^ z$$whfknqRWt2a_l8Mj$ zVj0&S#gB&8AcZ>@)Mo_X`vqh4o0%}1{+#rkklgBe6k=+&H@3n?)Mz`Osw%@itGU{g z(*7~L8CMpP6468!HW&-QWvSxbWqg~(X2VR;V$~2@Wsr86J>WsM8~#g;s7d-S36)8x zj-OX_`d9zh_L-?OCRR+HIXQ$y!&$zi{gOIoaHk&Lc?haBed)~B_)qY$Zx+#b+3Kdo znbwvhKgux&ueRaIBBkfFYv^TlMp}+0kWg~J6d9Tv

hrOvjMM!``Sw+bg`J7_=*B z)pSBsOznoIPp>2-13MIYQQ$gNZM$dIWb;UzNB zDl6R}&Z}(Rdm&tcNxw^XY#YmSludMOT%iwd>sS^V)x+0af9*|eAy2pWRU>P4q4x|!tVC^yr| ztCCul*v-MJ6)~`7lJsxBq#G{(WNhxji}x(sQ0B-V+4hl5tHjEwv60c-;&xBCo!p%E z$m)N*E!wuv79Ti#&aPcYz$4aIaTCkx0l5I@LNK)=2i#139Qul42=ljT2~f=D)Icah z3$$DvuocW}g}Hc`Utl^?hLPoh1as=2wuavpEEfR5H4SIYbZn`u3a_c}T^v~2)iqZ4p@|K}MH`BO&C!zT>XMR%{+5+%yq=MQ zl1No)sItV{uoy!F*COD$NxuzMt+Cir>?ESdMnf_XK0H!R4&y3Xe;m75^~ryf;xEXN zFYy+8upI#$qb=j>BHef0)i&gItZ-*n zjtz_crpUP$uZ%y}(I)X(4?o_A_0p@2Kgk6y%HQ&0gNbF5q6wf!A$l<8H=p8n5?&L% z=~zCx^J;KEqB0Sai~(u!;&hVk8_RbDc686}nLa+fN-sP4=4ey>&I8C>Jb2l@S>b2; z&_DkGK82J|LUs@f7L8s7yT~Gi?+dlUpv+n$r-I0s79^xYo_~J!+^pgP!{I3Oy3d_` zK`)CxQ`+0%@pSh3#Nf#{(Gv8>LSwjf+D}9OfkMpA%vjM{A(Lt40S{hqh&(6~dk7ia zW-dt%LT+9;m9p;@7ld1*9XVim$!E2_Jg9WaeZm& z`lk2~fzi*Kw^e#N`$|P?{0HF;mF3%7MHNsJeClK#^UftJob7@uk~(vwlQ3(9mqwSr zugjqIH}cXK_g#1G-YBpvv*UE2+RfChiUCN`gjok=08DS41fcwPnh!f&QeLmMN1SiM?*`yV9k_`VwR zptGwy4`drCLoavz4&!@l(CkMC%qA4GU>2Sr$6Qq3)6?D8!?PhCac)7*#ucfS=$n(- zsyBDs%7~u;;$xW92|20TksbyPog$=Xi~A8L4st%K19~UCF!1_#u@GypPq7-bHFVUo zA@L?qEg)lB-I$CBit=-7f zJrfO-*VdK?qB{o4PZzJLf`~7~(Uj)69d`Fu$o1D~2Vk8Zt~?+i zL=@v=9Wx+FPNd2mjtYoSEKJQQ^k8YH!~*uu{KLcyd?g6zxBBXqP8{U4EfH zw=a9}-0AH{hq5>Ge5ye#zy3q5S{sY4qNSON4@*%NU{HMkSO)wLf@0bnlJZo3=WuWY zU*W`BQdtoU(0T$VG8gMfwWu~D-$d|^A|v?v*sNwvp=zVZ#hZg~);d z68a%G?X&QnIaLRO$WA9PvsHcOQUwW&EY&+DYo34!lHQe{-SO=E?zrQQt@wMjDEO&Wc?HUVAa5((0>mQ9 z9-SvrsP(#ANw;p-kBfiC8+&u1>9wrsw9AV5PvZj$Mq(svDs3(HLMc-sHod*V z>#d-_UkS<|g8D-NzdulpKk5@!jOt4Zp>tm3q?c*?dBhniKpO1O-L9p=;fB)#&j+DD z7a+NvuEUx;2cS>U||b8|d%uY+F>ntYI1asybg?ZZ^4) zxp0U$*&ZhSq6#9ar7h6wJE{7rRi_#vUrCkT$2J2q0naEcl2A=?--fIA_V(_*dc*it z`xnz^?Ch=Wt((qSIdkKN)=lSb+PkN9W_Wm@va@-~@Z#nE#pPw4IRs`lqtBk!E$6Np zK6h)!#TFh2*?4qCY=&vwZ)i7!zY z+Gk0@`;#g7Cx(}|<(2f;we{cI-&9%A=`Rfo_YF2xmUk`Ef6%++)h1uCqjRrIFRcj_ z=62*4eNGPr%l&N)v~W3WyzR#M-!qxJd)tI7ON{3s&xlz#^~;F8*pNs1L(9%`WFO8K zStvBia-sH{*^3Xb7d8Ow?d_>&1`iJOEbU!d-(BC`)!EVB)L>gd*eL!I?44J{q-%HUn%$=GmPU*5ky`si;PLXqn0phxp3 z^|!^}i~(rCyY3Cqew+AP+$H|5zrCDfqfdNHoDZEL9bL>cZ6z9FUm6)QCK*KtoQea< zs9?bB$@Ay?Wu-Kw0(is7Tp2+E%6P-$VOjt2*woh5quaKPv@dFF?PzTkAKS8W<<_k$ zS8iF--PF?E-O|*J(USX93V#<6gPV-67f$PKjn2UE*mI}gmX{R z|A%&^^1R)BTnUW)?&Y{2;BEN0lTTu0LQgUUR7TDOjO=A(ey+p_9Qag{?^J333IUR1 z3QHK(a5=>K>Ce|iR0R|^8tFgwRb_=zjb1x&V4$$xGqj_tZ{M&ezSLOukFSec9M0C$ zx9>g!mXOYIjEPF^8Sx1mO2EdeXvUccO?*K#t1}T;w~#wTk&=dr4dz09aUoU?B=@oM zW3z?o{K;loO8iTsVcMA`s`yp%n6t+y;77WwwDhGlYpRR0s|uoZJ$20kfoRwD?LE#u z-SCzN%A8w!vS@!)+J7BYE{Z(t1JX=rlRO7I4Rnr& z6J@U9aNbD5W&1&Ge@8|2A}VtY6eY@BD;t|CJm4E4eTjiHPtFelBe(NG?L*pUmmdyna8@pgmFF(Po81)$5Ze*(}ZzdpnVD!lkeAA zDg_R&OoeBSGC+8}UaddY17FjH^A`tE2O!*8;$Sv*GI;|Gu9DlWp}3wyRip&epCT%_ zMO7R28bzYWu$B@+J@Fg9W`$Tp6s=ZbN5|X}iezW|o=7x!8w8%`2sH3d#%1`Fw#(%NJO_If+Ya78QD7 zyV5V+-mYp7s=byTxp(TDPke37ns1-^na|9K@J&>WdftV8GL-7Jwmqcr4)asRdw& z&Jv&JwF45y$BKT>1-C3gBIf3oew|M`N)Ia4?W!dkUrG)@WWjX zJ=FE^L*0)+mJP`E&rk>V8(7q8Ub?Sms;aIJ);AV!-k( z6)G@@lnj*P(Nc+^g+fK!SJ>gp?dfW3ZEC1(bHZW5c?bgwWT^}iZQ)@(iPMlF7jNFI zmBrS2PDN8wMSfjv@6f?bZDWDJctc-%W6!eIU}Ix&v_G=6cH6+dWpl^%!{M&_>QHfh zX=QnI?UF4M#YO9j0<8_v&T!+BhEPjQbzQKj&fD62@(cRdiuI8-Bu}ztC=MQf3UhHe zA3O^mY0urx7qzcscun)01waY)X>whdW4FLgtlaH_fXGJNBg3#)~<%Snovc6%AB}l zqeZmPaYSq-rIc(WkKtLuEy@kZ9j31CI%hKW(zg%KcEumk*RSauuWMM>dE-!c$GX*X z7rbv~X;;HKR6_0R7@G72HunsyaK61b{*l?SuBK&MQ0sB}SnuxbT+_Df!%W-sAH5 zU+}zu?g!w0+|K9ccjF6l-|{3ZdW+T@>ncU|ON>`jVvA1eWs5ZwDCkgd(n`~chikyi zmT*gLGpQq`y2*C)nw!|y^uw}C`(95aTc%I_lj`Bh^Xm4loVxnx*jdXiJ-ugrnWJcV z(+BF#_O<#i-n@2v)28tW{bXU(x1x7(>?;@Dbn}uk)}DP?MU}pIciq~)SV1r^{^Ine z)w||)3{8VNvgU(y=1I)Q<+^Aq&zI|}>X|Q3;Q_79CIL)cPp#Ifk3#r z^48JOXUv}&x4!w;^?JP*`^8nrBw_t7E`m7-*96MFSy;c_*f_`f?cr2)a{b1RUy}26 zEscqzKH zHQYTpPTSix{q;l9{jsZViT2kmi|ie$8!i`}<)`mj(-Hr!|19uL?yr%a!G=0SE&D?t&0Vgw{suJ!Pm_LHQRa;_D;GxA9tyqzksk zVEO1URbp>lZ@&<=j%5h{b(C}n?5oH2UbuZ^&Bm43Twk+FYxux({6Rc8h36lHrEt5>*Dja7qs5n%>m2Tz zQtQ+CJFIO#oM?MYyJ&&7zf80}2F2xZ=c9BUP`1_Ft_#Hz{JeEOD*jimQSkJA&X%bLtmRo*SlDy0uuETz#J71t za74xxstxTbP!k(;qF)T--jxmwe=QWMqQbzcrcjf*g#wWpSCv+Z$~-LmDOIJkF(S9! z7pN}XyA0K(y5mPb+BQCp?eDS;ZJVjWbYvvw`VZc49aWeXEnPl6z5L|IsmgT6PS5^B zXYSsOy+6nU=#SW&Wcvms+eb6;4(m0^Hm|>~^LHnBhqfd$vh8v0#|yM2nW46B$jraW zaUsJv+bG+zY=fK3!zjj*w`E)&^VVXwsjg7hQKX|GWsNN;RXfn2xluRjb{xzSIJirD zuxw=XIT#)o8O((;No;9qO;CEOiT#;cP{p~Yxv6zgQ+soJxTLPG7L{5R%}%N+Pz4ZJ zTKXvCBr||E->g74R2l76i#-0Uf{K!!&c^akJy+3=S}JQBT8j3lqS~X2oTA_1>}&}y z^E+fwZKBGyy1hYPEGul2JR<#)bUvCPuVMG8pkL#dE%-||;U-uctkZt zWPkn{ja;r{`>kz>zq0LdF|a^e;xDx&{(e`D8_{ttRJO(Q+r$ZeF6(f~{>ZUDglkNR z@AS_v5yN>ALF3F8Rng@lW`>R(#CeV^cTUzpD#F8kAK5xo&^@7PR9Xl4gNKYXf z#p+UHstgWudXNC0o5RV&GiUH`{De3&enQV3JB~kNC;vj{JH_2+K{Rm50@uh^X}3Iy zQ|MgiPu2i;X<%POAU}WMbK~*5T27AARX16f_5BQ1`-hx8vjb+cV3j zy!AzmO|u8%pBF2rBrmS-SUv-p@IexWSm!zC5 z^cJx;_=mQTl?&B67o3l3PwONHTi2513&B3*wH z{S%+0|4IDD8C=HfY1zfrqt!oz0tm~^*}1s@^Zzp#%5`=@-`lpLe$V$~M+&L{Ga zWIK~_kIQxOCZG%DL|bDY&hlcHMEg!Grv~0bHC>8;CCCS}5J5=8}u}@cLpQlcIptDtcApSFv7ylm- zieFjU5x_y*(Ob{I_~_A#7B@BOKgy;11lBuTID~uFC$_Ku=rz~e<yuwMiVaZE+{5YI^}}SL!|yx8)oowWS>6KUvz6ZIt6)tewc*miZ{hy?7d3V4avj%tz;_ z>$I6zK@oOJDMNy@wW!^drX+~a1eaHs{zZzfTgIJ;{V71?-e%(vHnfPsX?5+5EbKM? zTI-=~oJ?}#xZV%Se5N~F?W=D!PieQbv@{*5ENB04RbB#dDwZld$Kqq$&OE)B`Enl2VQRc6YiLPUzsj^dPx}J5#V^+V0PHKQNo#A* zS0KO9idGm`wx#`s`)FL``Tm~9mpb(CW86N*FfVG~OzXcM-#o^=`#QQL{k@6%mu*Rg zxh;I*Z!OT4{3LD*7^!P1<3gt<7idc~p|;Se06s9UF6fR$f1y(%@jR)&9~ zT!gd1#abiR9aI%$qY6mggrVtIQR`y>EY^xk*$Lqy3_c!=v?aFSxftVQ-l7igP@q2; z>%bvFH?X7c!@)fbR04`s3sI zE$zjFf&TJfe_+Vlu_#ZU_4jmp^F8gsV7n*Z+uehLAvxBh{s2}Vb^aRe4xUeu*P~8M zV_N13aGI4s?V*^AzpOuC-rI$`6!=BIga_8jw26?PrlTYU%agS@0v*9g3m2nELDpkn zy+?3Z540&{#NC#YV{6trdYzrE^^5V+mcccvmo}D`k?5fIPmgKzq7NiucNEKvnmj%qQ5jR!~oyn>Vg)YR}BU10wsgE{R}|opH28%~$60ZQ{yO5BZXT}@`FKMx8D&))E+d&k zg&58pOrinqkqLugVK>U|nWmgUWOm{HKZ@)rmDu1~+*Z}o?(>(sJ6-*46+IoLWo2%C zcFC&Bipt7?5&VP0$4u{=`FuV09Hpcb=ee*;N2UIlDZ~yR5If?nw!Q?l(^Oc`LYJs# zLNN=JiKJZIoYUIt?<}YempE#Y+g>3mSsr`d`W`{{g8 zd!21UitR;?Fg@Rs+V(}2UzBahezM(09m?Z!T#GL)+fojd>^5?!hwzcGy|gCY2ink{ z!#Qandm~*Pk1>raQOi(q8oe5?aOrEi`|uM2#FVjQ)xF~;Wqj9WP_girNp%&A`cv~b}QRe3t{2`nf5qENg{ zS95$8S9WwZdl7EqhRudgExX}{WuFoS&qg14r1n{ct=IZ-IirL{={8{fKDvo>|3eQA z-Ef0=`PuwuYagLBHTv}uX2rnnY2*YP<3s^y^? zT!-@zO>!<5Oht$Yf~vc0h1R;LHW~?6SC#vzu3nY(w2O8b(5V7(y26+)KP>U%2lIdg zuT01j)pcX}pldyGaPxhg+Zt;t*4BTd_J7_H;-^3I%x$b+9}2B)zwp}94P!!#t@qxb z|FE=dtT6Dk;hf@}Y+vs1mYv@GiG1&M$A8tQzHQ=k9yz+?ZYz0@K(U-aR#xwZJuX$;5=sPQT|@TD(4p+zIrRe`dS;{03$`3<5$U0&gL^S#w9$%z9G5u8Yr zH$l+3WC-=4y+_M#TDGUZ`;2YfI~yD4TDGj&x^?Jo@5sQ8&C>%H=r2vJy+iMqi7lJ! z_mpldEnmAbwqp1bb&Urn2c|0mc& ztvS|!5@I+TNNmtmDQHECjNi3tx+R5=!4!%@y28(&Jhs8-?n{%$?f>YuAF{{$G0xME zYd7Fb1tOOCYd{6->HK%#Lw3W5bh(Z{fZyX@eK!6o>+-3be@lK;@N&YyBof-+jVSyscQQn zJUbhHC!*i^)8(_ckH>u-&n`OomV!GSaJR!Z$L{I;O7-ks)IU?24w;2p2)BQR|F{X1 zME3Y*MH$Aqaps_?%sKlED5!`agQCvB3>O_P4TFNB zlAsfp4bN1Qu`!6gs?(4nJdf>yJAM81!r~f5_(Pe7lO+NqH5Be*0M&*Y+-+%v% zo;F{U7X24Ir0dkRk*J5b;j65FfW@}F&F8+0;Ra^!EnHJ?;UAzzxv#1IUY0k2uy=9h z3+7aKutndl3i!@$fxZ`Bfm>+;6VLgBD%QPJu`{Rb4QDyLsru`F=z1F7QT^R51jW%{|)&srapfs`~u;Cyo1m^NOjlM zgj)!Ih4dq&bB^K8BK!*cM^$?nRqfWI_kDPZJikM(gkIV6;ghCM_$`#q&OXXts)oDU z)Np=rvp}bjrM>!5=>y{@pI)Q7^9!2&sh{Grm&*A9{y(Z-&Xf2}{}47ZdKx^5tdT1w z{ssEiu)CcRtndBxUh4RmO6b#(=}+Ic;2H|h+YJ|cV8ZHe{MNw}lyeZMbE0qn z2AUYBkUl8no#VzGOka&sS2=a~W0&`N>B+<~zRpJWmXA{Yqv-ypDsbOWXX<)2$iGT8 z#!&WU`eIe*&QWjrdsWYvpj7qj^o}a%e7Y(qXix=(UsDBsKh-PZAF7vkqs6}&_dH9` z()K~bCe=UR&%Ej7y-a=5)Yotv_5A?%1L{*pn|{kU+^Z_w8r<3R*=*)l%;&fcJm&TZ zs(9JHJHy2I3~c+p4@v331q66X+|h9o?nv5Al4D)`BmolNE5?$MnVjQG7K-eUZ=4tU~^G z=o-TO?hW7%oyhVnKJS;b)bO!Y0nt)#yrq=~Gj$xP7rM;~pDl z!Upqa#=x}8Y;g_9o9CNRIpw%!oXfz}sW)5*SS)2MkV&tA9boR*%%eQhp0O|iE(TLi z!$i0oM#5##1SWnmnDH4Vt{H#RM$?Z*S3fZ63AhtJ4MV|PHTA+# z;5;z%-^>BynlUr-2{8Ii*uM|*>F6VaWic-2l%^0{%&pmaJseaf;Wj9^0A*e)yPMG zZ?(NdxT|`b_P^Ek793P>w|(Ck&IdOD|A(ZFva~&m+e53Yn0@8{7G}TsNtnIo$6@xM{})7iRkL6H7>xanF?-bi9iqLe*{=$E4YoVp zm;Mi2_T~Mfy{p;3euTFBKN|N@IcIR&hi2a!W3Ey5NTb}V)F6ZRdGhf7LtUmu>6_sI zwWl{wMKy`MqDO0*~wFC|BbA zFSDPFNvndOi15?aHU2p4my-WC!eZRBAa+OL7uPT|oNmkLKYOGW`u)2S?!_BtD-Zgg*wp6Wl+a_Uq1Ee_VUs z>b5rweox^c^nl0NL_R?Udh+nELGEJ+SU>aee@{6vODOME$^*Yfh{xKv^qJ4j$zaCS zf8`TkuK5ObhQTeY7oDu%g!AddTF|MG8y?mLL+1iEqlWL{KZP{(NBjssW6}9p?s=IH zompr2JYST4l&r{gIiCED$S_|zeHJ;^&`#{wI_;QtzdPMq({)yTsZdAuh^YR(_M(9DDDTg*ibv5l|?K9(H^qIK8tci?& zz#7;7XfXL08IEjn|1k326N!)n6E z5S@EXB!2#Xfl1GkiOvVj8R0QRYseGC{|LA{h|c%^p64FHe=NmqKjO@?0ERimZLgSf z%Kr}b79D4m#&6Cn4V+cx6P;O_b4xJimC+fcIhX9ob4C`5R-e}GoqjSbrtQ(yX zG6$kF!oT2locHCQ{S7kbe(jjEKQnIhS9EqN{%8-*emXuUjQX9kT5~TF>0PC2^xe@p z+Xd`v%Jm-ZYhL5*t%Q4|67-*`&f@&5!ol`&o_4D7Ln?~n?99DWoy)y~(#uqXUZBpW z?hU#GI>Bzjud4><3Uu{o+o*3Ku8`*g_u(GKJ&yEi#GOst>74tO(4W6oSD7;}_nWFv zU(DMwxVxmhGn|Xm*r*;CMfE+y*}~jCiTPZvO8Jt@1ZOYjiqoNr_r26{c53|B6R&v~ z!F!AK36XySdxQxlp0bVn1S98sjkG^<_BfO4c`;vRH8`b|9_z~xMm{_W;0PzlZ+ z`$T+Cujfnc#^k+V%Kk0X#u}p~i z&h+0gxb>QDO8^k)qH*hYVj!QBn-M12_drRmRn%v>>H`ZFKXpQhg<*Rp2^^f*0HCwgXYWYR)U^+i0FLSD334 zOJ#jO$7#2-hV_e{T|W5Thn%JXo+^y^59cG(IY&3F!M{LV;-1L*l0;`Rs>6w{&)Lc$^*rZVTXCP(FLM6Q z_bKT|cC2ml;HOG`*b~?>wsGgxHVIhG+M0k}$A`Ufo8dutjCNHh`!^mZpZ(O5oiOjz zQBDX8086Ra3F#D|Lu2{fQE-qQ6k%@*R&+Q}j=v8}i@y^(I_<>TLKi4l&T&;gSj=zl z^nrTV!!g+krMeN<9k*x;$7$<1vZBnM(0dR^WW@F91&2A>n#=JZ<@P{u30=9C@MgFr$KWi;VV`G&XjQ+v9IT}N5 z2z4l>oYJKngOva>WmT|*BSPZKDX-!%$7Jg`y4!A#533Jw#M=zG!$!d&rOx$WJ;!y( z58n&a_52k|UC;-}dtr%EBd9}7N~!c}KsIwksc@B2*)f28Epa2M-zf5o+N{(?b%3sm zaYq-y4yDGFKr<|YElOQNxtEalQqnIa&t;@vMjC3=Skf;~0XoL5fdd?KZe!S&!fMFD zR*pLfPgn)ia{@Z9>e!(q#mlu}a`!vUqH zCSU>JZwz4*kf(|IH|{H^4<4gYONl)AkTcEBE>jK$Pp@n+c0aWDC}B-I@$ zK=&QQ-HH62lozpHsU?G8t5SD$hFx%2se919tPWNxbsv4QoIGncDfRRcrG7m_sn^i6 zZ=X^JsNcJbu?oDiH(0BjB3_EtZ^HvVKuCWEwBUjzyUbIrnL}C zU^pyQ&esgithiKzN z2bJ?Mc^+$}fPc#& z8vKw!O-Ek-m7bA0YeUR^|LC1^8dWeW^}4+Z$miY*)^Y zn_;n}E2VmcV-D{A?8LP|ho3VF{476ZxH0%K16< z`Z;O87!HeIk8*x_SUJ0p`xUx>Mc!W{^P5y;Vt7caoZsR99d-XbdiGGyKM?-ILFK%P z+^ZvCF06(vfWAK#LJ5q4W>^mE6`LPe1jz0s|7+xbjkMQDdu<0CP|lxLKn`{)=g*6k zv#(h>uXk3?U#R~Zg-`AvXfyGH4L#P=ia6!iC3unG1m zeJbUix=QK9SlF%ffEj>$+CU(GF|QRW-m3J#@k*bL|MaDRo--x`<(!#-T}lt4{e$)? zeb!1K|JiGl9!#FWi(#|UB`G+l^pFKWpOunUicIMNrO!bI+pEjAvALzpa^x!NU=h%N z6+7XO(v^K+I3QEG7Iwp7rK?JS_^M_gPZfHra4%|XU@}l%1ad>S0`*RI z2J|FDXoMxO7Pi5DrK<~}48}t%tb#4D$8M8Sq@~E0B0jZ4>0t`0pbn_hFv=Q+u3_jp z7hUHfe=fStT?*@ACmd4xJnDMhaF`1#VLKe*hAsgi%z)K^{P`XXg2}J~(0~4ZJ|u(w z3(5ff7c2neFQA{I%}&M+M6VF|1Q>U-fqrAPFFQLqSdunP_=UDFT9Q$wB_^3-gG zJxZq)3L8UM11yw*f7cB+SE+XwB(k>?L zV#>OhvM#2qi#Ne;IHL4u>Nh$B${J0+(d%I+98!ABARylu@{L&xtAM(U+0OwGIxnFv zm&}0Wkb_-tK5}^h`w0MD$EV&qVZ0M9)O@u+`CZRWJjVLk@Pq zVWlS(0cn%!fV4@ZT{QxlVHIqHgGyiB4@Locucpkak0?Dk1+74y$p^T39|VoC0=B?@ zrLP?bV_^}jgMCV0mw+*_6gI(LrP-tEdg@S*{(AJ+qpzV4j0f~JpsxXaQ_wdBeN)gk z1$|SHopMC!se=I7sf&SnPu&K4;E>Xdg)jtVOjGd&>RO!CdFhXt?}cEWyc z^m{=GgfJJ#cRl%TApV9C&N|f1tc0C#MClt-&U4Xs?l(P_h3x@;x7NT$ARyd&a&3%CUH%|s+Z{7w6mA(bNw^YFxXoMB84)!U% zs0fH(v;c@-v<3Fd!7gRoN*TAVg`K>Hs1Q^qRX1KB%h-<_0kX9#m)HEf4N zN-v>aO9sMNSOnx*vL3d9-_iTavN-w31rNdzcP{vYp+)LT_rU2ddu7Mq}574~~9m_^QGc17|>{0qY+HqeQ zOopYf4TxXf8LD6|tcKliQ0e>o!6-n-{q)8C2bBH{x<5nxK12OJvkK7t8FV~=?gv63 z{(<#??g!AZ0^KXnyCdA3v&4V49@fHsr9W2!gg;04bA&&S%;yPzekE*& zBTBC%Zsk~50-Iqs99H@Z&uk&<>4?HR=_T$ztS1T0O7A}1IqqtA0UohB`%-0F8=>>$>AhRZ?^fx-gWLN`~|IOjB z7&ZanZLp>~k)vyiF`z-Z&whHQC5s>!m7N9;`JfJ>XsLvMavxWL>p*~y4 z_Z;OsfO216~UtSKomHx>B*v?UKA3)Cz z;(po-mco9ee^w8~y@K9X4lBKLE$mhL=fh!@(!W60FVOW1^!~C0kozSvzuc$vF4A_* zg>6dzYO~V6UZ?bLh}&HT==|+sz6b!nF9O1Q;17*J{#R$f5vBh~{=K7=eyta5Rr*ip z{}b_lrp!Md;MGw}fPAk{hD}QUB?XI=enSB=Z|qZgKk;vp{^l;F|4N(Qs#E#^djD3X z^xIIKi0!eApW0yU^pQ6Ps;t52mN3KG{RCq{$I%d3;9FH zA42}n4mhavJLr9f`n@w57Q`~?mPp)HrcVRe;2W0dr*aEu%9c~c} zQm%)LR}TwdHEe?2a8S8^XGp+WzMQfgak; z=_U7O%Ird!T`04F^170z5Sc<`3P%Alg-ZaLLSzbe0CLB5h6JQw zGPJ@f$iXf+pxkanFbEDQw>vt!mjODvHv>AmuLJaT->2Ln^cA76C{l+2hTPr*VK|_xH)*{ODEEXC7z3M?dtx6* z!6H}<+kv|F*`wT(76av+O!~>B^(BAb-OBAZ9L56m=!f2Zl-rN=emS7*eny6JPNDo$ zdI5Dfr3z*%rbO_NTfirl|Hh|0e+8`HQ3*oU zuT{5p8JEusQ{zb9Q)Nzp_X#dc zGrgRkKj!dnI{jrvbPWG8d^_Frz8Q&yjF^ePD9UHXY&NncJe$!d{TRs*dDHsoNSK+@ zM0vAR3I0p*HAWhY#tf}*kRE7~^qIUxwt<=^`2K59a>*vs_kIOyl zm{I7^Fc10pQD4r9u0=b}wUNBJQm?=_4Vjyw-ZZ^!C7N-kQo6ZnY(LG9dfCiaGe_)I zeFdXChVW(4*qc%8&^+cS4Q5o2o$qEA8|*6aaplIfX<*JbpwC>XQ)K?#MA+2B^tdUv zhM8det@Lc7y`SPW#H>-KHSub3bo)x_g%-vj?%n@cF3oDqU)yS~;E(wW9#V?WHt?1M zhcAdbz7sf|oX$=cUYXyOcflRU+Z4Ltm(!ac7(CJGr^2aps+^%t(y4Y*&M@a( z=R9Y)bG~zdbD=ZBsd3Uy#tEIQQ|pZ6_bD%OE_O!q7NSe|-P_BYvAl6-96w((-kIQB z=}dI$oJr1A&ehIj=NgCi0Xg+fgEPgM>NGk{&NOGbGsBt58wYN1n)y}0+0GniuJb9U z#hK@{@|KKVd?)Dyb)xFS_jgX_`%nFx`Ob~bP0j*mp>wlyi?hhN)wzvdXIt!i+PTBI z(^=x&<=pMu<1BUVb(T5zIm?~=4+4+X^P3K$ATIbu&L(ap_I_EpiBhGi7_0FTtW6t-SoU_5%=sfOh za-MLWbe`h(r=E76ah`RyIL|rHJ1;m}o$ot8aDM1)b6#|QvY#dBxf3{M`A4^GjzJ?;iQJ^BZTk^IPY4&hMQ)&L5mtoj*Ezo!6W{Ie&Kc zIj=i^ao%wDJ8wFFb>4CgIDd2AcK+@hbpGM|)A^Tk$a%+k*Llx5?EKsLkMq8B#QDJa z&}n0W@TLIX2=4K|h(LGJoq1Dyf$pjcc>`!S-CY;y9=xNrr|zYD>l64@%09eV@?_nY z_hp`<`|DHrVFEgWUKTvzByU8RTWq^{N} zJxrgg&(p*8`T7EVp&p@YbXsS0sI$6OkJO{|MfzerT946}=u7owdaS-&kJDG^@p^*3 zk{3VJ@k>Tm>8tf*eT}|WU#IJJgPx+N>PFq9r|Ic>hMuXf=dGsAdX}E8=jgfmQ@TaZ z)2({GzER(#7wCohW_^oZq;KUN-?#J5w@>Rk^qqQ%zDwV&@6k*3y?U9xPcPT^^B&y? z^a}l1{W;!`w^DyWf04ImuF_xDU(sLHtNCT12ldzW8vPC4;qWcJmUp&3q#xGn^mp_l z`n!6)epElEzsLKtH|UM}alJ`Dp`X-G>CO6S{fvHAZ{f`y&+8ZTR{eeb1N}q2O~0sr zq+in8^^bW=-%s=o-aq~`{R+Qr^K<-sPH4ZUB#ssF0q(g*b4^xOLH`k?-Y{-^$zKBV8#?`l3RrvI(~qu#q=em=94+u7~n7PwvALiaego7>$ja(lSPyFJ}rZg2Ml_e8gk zdy;#y+n0Chp5pd*PjwUS0QWTB6*AC0-95uS(;eiV<(};hc1zqLZmD~YTjrL#6>gpv=efh(^Ibmc>5gz~+_alKXUU!*$pS#?>-~EjHfV;x|tou3l^X^LCocBfdOYSQ7%kEd)uez(3!e%pP>eb`;+e#d>p{jR&-ebjx-{hph1H@F+!$K6ft6Yi7lQ|@N>Y4;iT zS$B*3ocp}{g1goIzWW3Bhwe7_MfXSUOYU~}$L`DSPuv~uPu-unuedwipS!Hf<-%Qk6cK_}E$9>;D;(p+M=(c&vb3D!O-tzMgUf^}|_#sD+ z@4tA3-f>|_3)1OdV0OQ-rfn`iC!P?B=2OeFF%`mir3#e)k}B-ywkj5Z=iR& zcZPSSH^@87JKG!Vm3TwEQtuqE%q#aQyh^Xi8|o#!YA@vt^Un3o^M-rpdlz^YdLz6V zFYRT#(93$Y-binhcae9oH`*KHUE*EpUFMDTF89WHS9s&S3Eq|7M6b@9wV7q zytmT(g7-!5OWrE)%idSKuX?M!uXzu8U-#B{-|)Waealwa% zd(?Z(`<|EcHh3Go$GuJ76W){FQ{HCpY3~{DS#OK?ocFxppo@Auvw?+@Oq-XFca z-fP~Uygz&Uyw|HW()%He4_Wtet z$9vy9;(g$K=(X`eAct24xxVN7e&BcVJNsSy0>7(Y=pW~I^Sk>+eh>e6zo*~J@9m%9 zpXm4TPx4Rp`|^vvr}+K-Q~iWLz(36|_6Pc>`)BxP`h)zl{ImVReu+QCFZIvy%lvY` z!msqJ{Goo*ul7^^F#lZtJb$=yPwD`4{;Y`=k9a{w4mU z{$>7H|8jqve}zBZpWt8VPxR~jN&Z#-)&6Av8vk1VI=|j;@Td4w{YJmZpXN{ZXZSPy z>-`)2W`CAH+n?jl^*`me`1AZ$f4+aCf0Mt!U+CZL-{LRwZ}o5UZ}%7bpZ4$Y@AQ}W zclmew_xMZwd;Mkpeg1O)e*ZK61O5vCv;OD&&-*L=FZf^dzvQp-zwCd-|Ej;*|C;}x z|8;+j{|*0}{i^N->%Zpz$^Wyz&wt(ji~okd-+$BptN)gN z!2g^7w*Pnkp#Km5pZ>r6L;gGdyZ(FrVgKL$fBg6TBmM{ehkhHc-g5XgSC=1u_k)0! zk9H2a1O-9YpfETt=oWMjih>@&@j=g^SI|2+AviJU6Py&B9Q5TK%clhWgHwY zaA7bas0q?RCJ2LUP#cU4MgSDTyRA&K9~?(8B7f7f=R(u z!PUX!;F{pt;JTnbXb7eRQ-enHtBR@`T-`eiaH^vicfI5wDkPlR+ za}shU_p)cHv(;ek>4tD0a87jpUBMlA72i5Y@{RVC8pdw^JT+XMuP#s*suAq+)7%e+ zD$9<4B=>a}@qPBu+=E=gJ>zAZEnLn$_7&XeOyF)~qN?MY@>lWk&B^K-buAywtmppV zTWSfv_VYz`H}5RHkKbfl7Bul4_WL=*`=NSOJ;*P+Jj(axUs8{8r!kdxlQycKaObdH zy{ukQJ9xX}&(xdTMf_6jujhX5O?qr*}-&n() z#izLUXyHD}oO9gBoz`E~0`3eKs$0~pYLVKkzM*bcx2eT^i~a!L!+0i`7EBLj1T%x{ zgB#Qb>cgNpn5Eiy0srh^PB7PTf=>l4!Mva~m>=92+!QQOhtg~83iEy1GT*5J0_ z_F!@F>EI6apWx15NpM$icW_UzG`Kfdrv9Y<9NZTy5AF{>6Fd;C2tFHpPUX~w;Pb)C z;0wVQgD(ZE)cfj)+NWL*z8rid_-e2^_*(E_@bzF#@QvV`!MB37!MB5lf`@~3>e=8s z!6U(UgZ1h!>W$#h;4!sZy%u~={hkxhKd8Oxx9X2UF4z!k3?2_Q1y2M|22b(6!^e3` z&{Ld~ZsN@|FRFWZ7y0w*gN2(|{_4}K8*FxVEn82l)B zDcBzTICxq8-Me(cm@%E^&z@P9tSGl`byei1Qq|(7B)z)O%2k(6n>o9oM}DBRrFrJ` zo*#)RtvD`@n>S-lOY8ARg(5XpcQR{rC$mvxdtg>m<4ju+DJD?bI%DSSqhoHG+1S*c zVdm_at?kJsaCC+!#$=Eth0>(3v_nfpnBP7dI6B!>q&=fiDcWi!vNo?}mE|_Fvb-ZQ zRZ|m1;&wzPD=S5^GKy>ubd*+ES=v!rZSISJaHMlN;`TYQJpVdov(eE zNDRx@J}h5+Se|%|w6eyw(&i=5QFj6zl}SoZCG&bJRg;Ob;o2f^XqXnxX>D$rH?N_k zM}8o$Mpc!0MG)vHqDC4}V;f*cNcue~{Vvt4$sOVGd{zF)hZVNeu%e?sQmL%XnW_{w zTKQ_T{PeXgqD;oLEU!na%A&=<=BNp+WLDaqP1^X(FdLsN6Ibe%tdOvjkhE)UGAa3! zHa~Ax6**aZlcKLy^wo-=Lyr3`;B8eKHTTHYSy=kTm0y8BSa6KbDn=hO+jwvi3BoaT)cCq*KFet#C!!_@p(%tfthk zSd;O^rAV)oMf10!tX$@Ic{FNe$z+YxMtZF*3RhH=M+={EOIuo-XNizZ{<7%esTi7x zl+=c`QFV=LtBbFya9%@mbAw?w znL>QjC%92B<3?@8joOGCwGlVY7tJ&(6}xe~%%s}bjhhqCJXsscWCq(AU6!ns^|v+~ zT|TyNYHj7yXu`gDxHq? z+sdZXwh`%!ZEQLdT}HL(OhrNCoYsaZb8c*EXz3e!uWOyr($qv~-u$_e)}uY`Mu|D8 zBWC988=G2Mn;IphXM0L3!)bN*X}xKVQOLG)=FM|vw>C6O!hlaoC~chCFn!MKhUWgC zC^2sqGv*T%T-Q^Y2)9pi#oO--|AGm#ov%&eNy)I8^=hL%%eZ^x`M{UoU$ofneu(P51e z{)=Iij3>7bN5@=~r2G+=sG|o@BHIT~!ZLVb)Q8q~zEKj2Zj7hCLHsr=1PnGb^!Xf$9!xTp7fFHBhG%>C#2+c z;)%yx8*$d2?Y%gEZXDOUJP=;>T*}&0 z>Lh!Lp|&0=*+Zw|J@wF7J~CxxscdBGsE6pQjni#CQ?+rrt$!+Om(WyJ+L4vLO=$Hc zv(k?CbjhEQeu>*LOzJmG>M_j9C9AEz)G#S8BmI`O{gSG+OL;0IOL^Q68L4j=_gmbL zvh=s_!K8lWaeqpAalMC1dt^LQa!-00vvF31d{my4ktQNQuzVSqS{lq zIWz%#mLP%r8_9hA+<4|?wE88Z4MZ8c+TP=!Z~m-;sQ;`t>cPlsw>HsX$oyH6{cZP0 z{b=Q*p0x7Pmo^~183nr9{*3(HqMJg~qxnI5<}!blq|&STsrC%4J=GpbwFm9tQG1Zq z=jXMDKkcdXaDJ*i?~7AS$4064ptn63b(pOhH~9GqOkd~ch)aud6hvJd?fANw9=1Ny z!I7`4ouiSzAeyA&HN9(PP3Kx!+qc$lyVeRvJsWxLK{OqkA1Xg8RB=?O@~BYNQK6wn zg_1{ws*eh#jtULS57B>7UF@NF9E!TeR+i4pt1Nw)FD}JI;%1$x%~(5z8?7U?8GH7G z8?BGH(fWuRt%tQ4yI$kQVS6i>wRVM5Fq#J=CVOhs*I|$2qZQDUu>`+SVSxW3tDsGS1i3+hZ z6=HX!oTQYKlyZ_%PEyKAN;yd>Cn@Dri~X%0YRjvZ{ZO^s!c`Bo{Zc*DwzpdBVyZ^! zStIqdxBO)(d&`e2b|x+R4tpzKma@0T%aubiePQ?~qMO2#oIc3GY) zB}H$w?E9)^zg3;IdaIMRU#n#ttKJt;e`NjVixrfm7iVWKB1^^$#KwcH}7YNQ`3WxR*keoYRG!!n`c>SXNN`toaecHY_}9mY-CR~eTh{V+`08Sfiqe^o8}sA@T( ztd{GiTIOAKJnt&wdP@Juel%&%tIAR}(odDr&+$IB#LtK|&7+OC7t*UF{r5f=9$GEOxz z4mC3WMNi5eaWT*0e4^i;>roGxKk>5wnGeSQEBSGqC^;9QqeX8YIMoQ9C@pV}tdg66P+L4UMy;kN~ zg^aU|M>4+tYotGA9Y{+3lNp(p@iUqDen{?vlJ@+zjNf67uBYmFU9OPvsF3+zDf8c+ z@H3uLAA3$*mXv*eGQJOs@8je?Cn@(S)nb3D#r{;s_NRT`*?vgbqt>!iyl%>UOtsjH z>ew#D>#OKZir%E?O;)Gu&b*?m{n1x#+TNVvMxBNmO-kITk+{*Kh8s1qHf?Xdaic{C zH=1O)(M5?H4GL~_ao|Rsh8qn&Zq&KBQR8ru9R;#LxiRM zc2h)H+Fv2%S4jKqMMS!^-)=StOZzLNd~3Xkm-4N#tj$zP`>in}EbX^Og|M{W8U?~q zzFqtYOZ)92PgvS-7d^sKzC0$&*o&w(BM(I~@(?8>4^cAm@FXJ-Q!?@pB_j`OGV*XH zBM)OT@^B_24{tK|5DER#etC$Kv4>B@OZ)9+ip{gM-)^P|OZ)9+imY}#;rDEH>0@HZ*~(%So+Ow z0trjM*~N^o^qbwB5te?ln=`^PZ|tUlu*@5~IU_9NW;a{48N1oSm2&N7i?EbyH(P|I zT)WvKEalq66v9%j-7FE7a_y#vu#{^zFSQxFiN=-d)ow-zOTXJq1YzlSYfK5txYtPe zH8Sqjh>bu#CGs zq$4ctwuiX28G8tdE9KflMZ!|9J$xoC<=Vqn!cwk13?(e>7CW2~JDd?aoDn;m5j&g_ zJDd?aoDqAQ5qp~vdz%q^n-P1P5qq17?XBH(bMr6ll!we2yUD5z#U6)Zk3+G?q1fY4 z>~SdeI23yviaid+9*1I&L$Sx9*yB*_aVYjU6nh+sJr2blhhmRIvB#m<<528zDE2rM zdmM^A4#ggaVvj?y$D!EcQ0#Fi_Ba%K9L68x5PKYoJr2blhhmRIvB#m<<528zDE2rM zdmM^A4#ggaVvj?y$D!EcQ0#Fi_Ba%K9Ev>-#U6)Zk3+G?q1fY4>~SdeI23yviaid+ z9*1I&L$P( zPK9zf70TgMD2G#_98QH|H$$}Du-GZecSiroywZiZqvL$RBo*v(MvW+-+u z6uTLU-3-NUhGI8Ev74dT%~0%SD0VXxyBUhz48?ASVmCvvo1xgvQ0!(Xb~6;a8H(Ku z#cqaTH$$?XfO9Uc0GVmCvvo1xgvQ0!(Xb~6;a8H(Ku#a@PDFGI1Hq1ek% z>}4qSG8B6mioFcQUWQ^XL$Q~k*vnAtWhnMC6nhzpy$r=(hGH*6v6rFP%TVlPDE2ZG zdl`zo48>lCVlP9nm!a6pQ0!$W_A(TE8H&9O#a@PDFGI1Hq1ek%>}4qSG8B6mioFcQ zUWQ^XL$Q~k*vnAtWhnMC6nhzpy$r=(hGH*6v6rFP%TVlPDE2axbw8AKKa~5fP}cuY z*8fn}|4`QdP}cvf$Yn(?D{@(p%eKo|dz2MBkrg|Um3>w=zTcDUKP}gJTI^g_=1W%Q zMOv=Yv|R6LxvsNf53@2~vocSzGTv#qzS43XrsaA{%XO2Lamh;mW~HC9vOmbmJ|HdY zKw7T%tl0mwJ=CjB%l;rO^FJ-~I4$!%E%PcX*GpF7v$344KWSOV(y|_=W!*~4`j-{E zpOxz}8{aR-{UFyzR_1wH)|IrZKWSM<(z0G;WjS^v_q&Shoa zla}#J%f2Wr*L_;9-?UukSy`8|vL0n+-ARkx$VxwEWxdGCx{#K2IxXvcTI@<%>_=MG z`Lx)JtgIJVx$e`l&ZcEOPRqKQmi03$h2TGr33T<>XF|I+b1lj}Mw*JoC) zv#jhdv$Bs&iycXey~s*GWo5sZj{8^E&vZQBS&6bf|2cl1=HKT(eYlnJZ37dsPsnhDHd-A|8=@Z%A6_gMWTJCxaO&Bxg3O-S5^mO z8m7)~Z3>zr$Lf{i-SWyzFvixU**ce6+1Yur_7tP6yeb)7YK3M;&T*H`m~TGLHGfuf z!~E9c=H!QhaaK=@b;j9zE!MfhM$YRfgOA2W^{OaO2ji_!YvgpAAZ?s)U4Mf4?2kX+ z{D0hp{1T6wpC9TpA?^$7cAYqNX3Nz1v!*pSE$Dhvo_|uFe_@9|xXRY?=Ey0ys(n}r zZf^HVjiYO!tU^A}Q;``KOynbnOs<=(Gtp|gDRR0@Y@FHD(ll@8ye>DzzF?9STo^fC zc7Ds8qNwrqV~Wu{jLNF4NcPB!YcU@TYceV2vLcmL*d29QrM;uUt?m+OCEJuPQ4NgG zYLk!RR@%EIWTUGHH@XYNt?AM_v$?S;FGqC0SXOE88*q7_xpLatcoV}X|6Lw^FK?WF zx$UjC_wE0DEAMK)y?2p*S@9Aw-G}#Q6zN~Jy^H^}ws+f#6kkj$N#xXoy6mJxV)SO! z?efvN;EJp2a#ekEgC^BaODvmEm(!;=Jl&bE8cv;BJf-h|0Xa1(r!vLiM(UKQPY=mC zCAmcXv>`cNQaqq|z>u6Z7Ar_+egc7+jlU`;dw~nn#&>PDd61jr0b@jv~Oj?2QC5ucmUzPqjUX@um9+V%|&Xek=X33DU&8OHcZMngC|Xr7EDSs z(w^eV<>x?HE?bgpx8nh_vN{g7M`bvHecXxz|gN;+W@O>_I& z>YM*_x;{HK=bt@*#7tsYVi{#^DDzLJhc2(HAKSNK+@!kVNdqP&awD#&BdxFL7pcmS zT&I#;m(1Xeyi_IX_09x~)5T1e;&ek!Pnni;rc#kyr?ZFTx|AeLZM)GdPfbCD`i`id zWJK!2sI~rq?I3%ZeM!phlgo`kDdBCHhjy zxRwlUaYJZ}IPMd&=LV9C%bxMLhm`cTSJu>x)W%Y?%54vqg?)Z|? zOKrz{3Kl8z)n=D+(CUp`m8A<&| z5_|MueZmlKP^xD!ot(+33pP5A6U~R-C29k&S07)OJH9xb$mY5+{f^_pu1_cGpX$@c z;l(jM6<-N3JH=*lX3f(Z0KyBroxd_QV#Om?%-X?t?$EoiyqR+f>ue{Pqo?eF^Gy8tpS~G3_(%K-y>A z(`lb^&!BzAJ(KnscM$C}?pd_YxM$No;+lekSaC{`+q6HbR3eah=+JXw;*d~T(11=4ZhPmTgRek zMzJ!ldp=2~sw6Qqs`*f~Sv}d0E)%RiA6r`!uTJ3iYxoN5> zO&M`{-Q!-uPxO7PYsG|>vI94ee~tZmE{b!R>{lMHx}o7j4F$r zmucvWzkU+y!?Af9s2b~kacx7YZ!sm*(oUSH6!d*eAymXH$pMQ9gW)sbupr0O1dR>G zTA50?{4XnMM_Ejk;c;IisDXcm^hNP-y6OD(q+9`uNg`2OJkk_uM)86;EozMI>6{v0 zSDF}(K{vJ6HWH=nLy-$kCv*`>?RytHh##GK^~Ew;to%cA7sjPUy)VO6Z#Fo`HaQ;R z5v(7jrWb2-CuZu#_Qj?qhEFQpQ0DaFx~(}XbzI-EN2R8ZN{#d8>pM0_rX+Xn;Cz8N zQ(RzJk~?qkGNzxIW6QYGKdEjEXlbsDDrTc5n`wN8?fC{aXldJYGo6dM(n`7VY=de` zHt>tpX*2tyng2hV(jz}^g8iS&V$N_BBz6_Wo^&H6peD2`5S4I&!Z}0#! zW~N`HHSJw+5na&RUiR!xxWapu=8|0H7k@(hXw*5qdgiK;8B>xQhI5JO#Vp;OsKp4! zy>)4cnb5gQ=;q5xHY!z%XDlAacrGv5=tME&@I*0J7@3hIjW;sJGr`Ch&y_~TcqW$c z3T&0ZSBDSVj&D-QO3b+VC*eAgJc$ak%gi+tCaGP8))n=*~3!IWt{ zQ%sr0Gu6n9!qaGEjHk)S7|%2#V?5KNdWHCAMD;ShnNhur@A{}-#&<(hFXL;D>ScVh zqIwzM?5JMGH-|nR-adeHqfl-H37@jw8oVv0(~VdzO=upr7O#*n>oo$4tk($KitGjL`fiItk-pol*N88+UL*c# za$aEd-C?~(;7;o`0!xs+uwCC>Q7F=PxAhwFd#u-pFD2)NR^PqWYXp{AuMxPfWMkK8 zXPXQ9ZuGR9<)#T+HYq(g*SRU@4jj86Ht0hXH-Ov6v7K~2gRQf-d~-LbPU*)7au0B} z@jY=;=kC5Pu>Ju-FE{dcNk1Db_PE7VT}kPhepZd(p9ww9TWnRhVW6}0a&8Eg)|qf) zxM7e9Z|D2L&Bnjs4iM~M(S9d#mv(t80&buEY9X{?i J1}Y4d`d=h$Q+@yd literal 0 HcmV?d00001 diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt index 226f3d39..7cb1569f 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/LicensesTest.kt @@ -14,7 +14,7 @@ class LicensesTest { @Test fun `every desktop only library is on the licences screen`() { val names = DesktopLicenses.notices.map { it.name } - for (library in listOf("PDFBox", "Commons Logging", "JNA", "dbus-java", "SLF4J")) { + 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://") }) @@ -26,6 +26,8 @@ class LicensesTest { "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", diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt index 2168d722..23d886c8 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt @@ -1,5 +1,6 @@ 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 @@ -15,6 +16,7 @@ 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 @@ -37,6 +39,20 @@ class NoteDocumentsPdfTest { 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", tag: String? = null) = @@ -75,20 +91,111 @@ class NoteDocumentsPdfTest { @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 >= 9, "the nine locales: ${files.size}") + assertTrue(files.size >= 10, "the ten locales: ${files.size}") val missing = sortedSetOf() files.forEach { file -> - file.readText().codePoints().filter { Character.isLetter(it) }.forEach { - if (lookup.getGlyphId(it) <= 0) missing += String(Character.toChars(it)) + // 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"))) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2987e30..dc3d7001 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,8 @@ 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" @@ -116,6 +118,7 @@ dbus-java-core = { module = "com.github.hypfvieh:dbus-java-core", version.ref = 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" } 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. From ec2dc3647d1e6148ab138c91254deef22d10e897 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 17:20:11 +0200 Subject: [PATCH 54/75] feat(security): PIN of 6 to 12 digits, a password as a second way in, keep the PIN after a recovery unlock The PIN can be 6 to 12 digits; the length is stored nowhere and the lock screen unlocks on a button instead of at a fixed length. A password of 8 to 128 characters can be set beside the PIN (or instead of it) in its own slot, through the same KDF chain and device-bound factor and the same lockout; the lock screen switches between the two, while the wipe and duress PINs stay PIN-only. After unlocking with the recovery phrase, a PIN that still works can be kept instead of being replaced. Refs: #95 --- CHANGELOG.md | 9 + README.md | 4 +- SECURITY.md | 78 ++- .../core/keystore/DesktopPinFactorTest.kt | 14 + .../commonMain/strings/values-ar/strings.xml | 23 +- .../strings/values-ar/strings_security.xml | 51 +- .../commonMain/strings/values-de/strings.xml | 23 +- .../strings/values-de/strings_security.xml | 51 +- .../commonMain/strings/values-es/strings.xml | 23 +- .../strings/values-es/strings_security.xml | 51 +- .../commonMain/strings/values-fr/strings.xml | 23 +- .../strings/values-fr/strings_security.xml | 51 +- .../commonMain/strings/values-it/strings.xml | 23 +- .../strings/values-it/strings_security.xml | 51 +- .../commonMain/strings/values-nl/strings.xml | 23 +- .../strings/values-nl/strings_security.xml | 51 +- .../commonMain/strings/values-pl/strings.xml | 23 +- .../strings/values-pl/strings_security.xml | 51 +- .../commonMain/strings/values-pt/strings.xml | 23 +- .../strings/values-pt/strings_security.xml | 51 +- .../commonMain/strings/values-uk/strings.xml | 23 +- .../strings/values-uk/strings_security.xml | 51 +- .../src/commonMain/strings/values/strings.xml | 23 +- .../strings/values/strings_security.xml | 51 +- core/security/api/android/security.api | 73 ++- core/security/api/desktop/security.api | 73 ++- core/security/api/security.klib.api | 87 +++- .../core/security/AuthenticationManager.kt | 310 +++++++++++- .../encly/core/security/PasswordSlots.kt | 90 ++++ .../encly/core/security/SecurityManager.kt | 162 +++++- .../encly/core/security/CryptoGoldenTest.kt | 82 +++ .../encly/core/security/GoldenVectors.kt | 24 + .../core/security/SecurityBoundaryTest.kt | 7 +- .../encly/core/security/IosNormalizer.kt | 15 + .../encly/core/security/JvmNormalizer.kt | 18 + .../security/AuthenticationEdgeCasesTest.kt | 2 +- .../encly/core/security/PasswordSlotTest.kt | 472 ++++++++++++++++++ .../encly/core/security/PinLengthTest.kt | 125 +++++ .../encly/core/security/PinLockoutTest.kt | 2 +- .../core/security/SecurityManagerTest.kt | 2 + .../security/UnlockMethodsLifecycleTest.kt | 319 ++++++++++++ .../presentation/viewmodel/BackupFlowsTest.kt | 32 ++ .../viewmodel/BackupViewModelTest.kt | 2 + .../viewmodel/MarkdownExportViewModelTest.kt | 2 + .../viewmodel/OnboardingViewModelTest.kt | 38 +- .../encly/ui/screens/BackupScreenTest.kt | 4 +- .../encly/ui/screens/ComposeScreenTest.kt | 18 + .../pasich/encly/ui/screens/LockScreenTest.kt | 18 +- .../pasich/encly/ui/screens/PinKeypadTest.kt | 12 +- .../screens/ScreenshotAllowanceScreensTest.kt | 2 +- .../encly/ui/screens/SecurityScreensTest.kt | 68 +-- .../com/pasich/encly/ui/screens/TestApp.kt | 5 + .../ui/screens/UnlockMethodsScreensTest.kt | 447 +++++++++++++++++ .../encly/presentation/designsystem/Pin.kt | 27 +- .../presentation/designsystem/TextFields.kt | 3 + .../encly/presentation/navigation/NavHost.kt | 10 + .../presentation/navigation/NavRoutes.kt | 2 + .../encly/presentation/screen/FaqScreen.kt | 6 + .../presentation/screen/LockFormState.kt | 69 ++- .../encly/presentation/screen/LockForms.kt | 334 +++++++++++++ .../encly/presentation/screen/LockScreen.kt | 268 ++-------- .../screen/PinCodeConfigScreen.kt | 121 ++++- .../screen/RecoveryChoiceScreen.kt | 88 ++++ .../screen/backup/BackupDialogs.kt | 77 --- .../screen/backup/ReauthDialog.kt | 182 +++++++ .../screen/onboarding/OnboardingFooter.kt | 4 +- .../screen/pincode/PasswordInput.kt | 74 +++ .../screen/pincode/PasswordStepScaffold.kt | 62 +++ .../presentation/screen/pincode/PinBuffer.kt | 14 +- .../screen/pincode/PinEntryScaffold.kt | 24 +- .../screen/settings/DuressPinScreen.kt | 1 + .../screen/settings/PasswordScreen.kt | 460 +++++++++++++++++ .../screen/settings/PasswordSections.kt | 62 +++ .../presentation/screen/settings/PinFlow.kt | 15 +- .../screen/settings/SecuritySettingsScreen.kt | 15 +- .../screen/settings/WipePinScreen.kt | 1 + .../presentation/viewmodel/BackupFlows.kt | 28 +- .../presentation/viewmodel/BackupViewModel.kt | 2 +- .../presentation/viewmodel/LockViewModel.kt | 35 +- .../viewmodel/MarkdownExportViewModel.kt | 2 +- .../viewmodel/OnboardingViewModel.kt | 11 +- .../viewmodel/SecuritySettingsViewModel.kt | 56 ++- .../kotlin/com/pasich/encly/ios/IosApp.kt | 2 +- .../presentation/screen/LockFormStateTest.kt | 91 ++++ .../viewmodel/LockViewModelTest.kt | 73 ++- .../SecuritySettingsViewModelTest.kt | 112 +++++ 86 files changed, 5073 insertions(+), 614 deletions(-) create mode 100644 core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt create mode 100644 core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt create mode 100644 core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt create mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt create mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLengthTest.kt create mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/ReauthDialog.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f65c04..fb505d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +### 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)). +- Unlock with a password instead of, or next to, the PIN: Settings → Security → Password (8 to 128 characters, any language). The lock screen switches between them, both share the same limit on wrong attempts and the same protection by the phone's secure hardware. The wipe PIN and the duress PIN stay PIN-only. At least one of PIN and password always remains; the PIN can be removed once a password is set ([#95](https://github.com/pasichDev/Encly/issues/95)). + +### Changed + +- 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)). + ## [2.4.0] - 2026-11-07 versionCode 20400. diff --git a/README.md b/README.md index 5f50e926..4477d0f9 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ 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 - Class 3 biometric unlock. +- 🔐 **Mandatory lock** — a PIN of 6 to 12 digits bound to the phone's Android Keystore, an optional + password as another way in, and 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. - 🔑 **Optional BIP39 recovery phrase** — 12 words that unlock the vault if you forget the PIN. diff --git a/SECURITY.md b/SECURITY.md index 4702a505..be72d5cf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,6 +16,7 @@ Encly does **not** derive the SQLCipher passphrase directly from a PIN or seed. ``` DEK (random 256 bit) ── SQLCipher raw key ├─ PIN slot AES-GCM(kek_pin) kek_pin = HKDF(PBKDF2(pin, salt, 600k) ‖ HMAC_keystore(salt ‖ pin)) +├─ password slot AES-GCM(kek_pw) kek_pw = HKDF(PBKDF2(pw, salt', 600k) ‖ HMAC_keystore("encly/pw/hw/v1" ‖ salt' ‖ pw)) ├─ biometric slot AES-GCM(keystore key) auth-per-use, BIOMETRIC_STRONG CryptoObject ├─ recovery slot AES-GCM(kek_rec) kek_rec = HKDF(SHA-256(normalized 12 words), "encly/recovery/kek/v3") ├─ backup-key slot AES-GCM(HKDF(DEK)) holds backupRoot = HKDF(SHA-256(words), "encly/backup/root/v1") @@ -27,7 +28,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; @@ -58,6 +69,58 @@ 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. +- The PIN can be removed (Settings → Security → Password) while a password is set, and only + then: one of PIN and password always remains. Removing it turns the wipe PIN and the duress + PIN off with it (they can only be opened by a PIN run): their slots become random bytes of the + same length, the decoy vault keeps its notes as it does after a PIN key reset. The screen asks + for the password first, which proves the password opens the vault before the PIN is gone. + +### Password slot + +An optional second way to unlock, next to the PIN or instead of it (Settings → Security → +Password; set, change and remove each after re-authentication, with the PIN, or with the password +when there is no PIN). It is the PIN's construction over its own salt and domain: no new primitive. + +- **Rules.** 8 to 128 characters (Unicode code points), any script, **NFC-normalised** before + use so the same word typed on another keyboard or platform is the same bytes. Text that is + plain Latin (every character below U+0300) is already NFC and is never turned into a String; + other text is normalised by the platform (`java.text.Normalizer`, Foundation), which cannot + promise to wipe the intermediate copy. A text field also holds its text as a String; both are + accepted limitations, as for the recovery words. +- **KEK.** `kek_pw = HKDF-SHA256(ikm = PBKDF2-HMAC-SHA256(utf8(pw), salt', 600 000) ‖ hw, + salt = salt', info = "encly/pw/kek/v1")` with `hw = HMAC_device(slot, "encly/pw/hw/v1" ‖ salt' ‖ + utf8(pw))`, the same device-bound factor, the same key slot (A or B) and the same number of + iterations as the PIN. The factor signs any bytes, so no platform needed a change: Android + Keystore, the desktop secret stores and the iOS Enclave factor all take the new input. The + domain tag keeps the input apart from the PIN's `salt ‖ digits`. +- **Slot.** `pin.password.slot` = `version(1) ‖ salt'(16) ‖ IV(12) ‖ AES-GCM(DEK) ‖ tag`, AAD + `"encly/pw/slot/v1" ‖ version ‖ salt'`: the same 77 bytes as the PIN slot, with version 1 (the PIN + slot is 3) and an AAD of its own. A new salt is made every time the password is set. The + golden vectors in `CryptoGoldenTest` pin the slot, the KEK and a full chain with a Unicode password, + computed with an implementation independent of the code under test. +- **Lockout and strikes are shared** with the PIN: the same durable counters, charged before the + KDF runs. Five wrong tries in any mix of PIN and password start the 30 s lockout; a right + password ends the misses like a right PIN. A device key that is gone or a Keystore failure is + reported and not counted, as for the PIN. +- **PIN-only features.** The wipe PIN and the duress PIN stay PIN-only (the UI says so): a + password never opens a decoy vault and never erases. Inside a decoy session a password + re-check is counted and always refused, so a duress session cannot be used to test guesses at + the real password. +- **The device key.** Both slots hang on the same key. A key that had to be made anew (see the + PIN slot) kills the other slot too; whichever is set in that session drops the dead one in the + same atomic write (a new PIN drops the password and Settings tells; a password set then drops + the PIN with its wipe and duress slots). A wipe-PIN erase moves the key to the other alias and + drops the password slot with the rest of the old vault. +- **Old builds.** The slot is one more entry in the slot file, whose format did not change. An + older build never reads the key and ignores it. The entry lives under the `pin.` prefix on purpose: + an older build that wipes its PIN state drops it with the rest, so no password slot is left + behind for a vault that build re-creates. What an older build can still do to a vault with a + password: change the PIN (the password stays valid), or reset the key (the password then + stops working; the PIN works and the password can be set again). A vault whose PIN was removed + has no `pin.slot`: an older build then treats it as a vault with a lost PIN slot, so it + asks for the recovery phrase if there is one, and otherwise shows the damaged-vault screen, + which only wipes after an explicit confirmation. Nothing is lost by opening it with an older build + as long as nobody confirms the wipe; the newer build opens it as before. #### Desktop @@ -147,7 +210,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 @@ -251,7 +314,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 @@ -320,6 +383,13 @@ 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 a PIN slot or a password + 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; + *password* when there is no PIN) or *Set a new PIN*. Only when no slot is left, 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 @@ -690,7 +760,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/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 index 074ed621..9bef2068 100644 --- 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 @@ -53,6 +53,20 @@ class DesktopPinFactorTest { assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, salt + "482914".toByteArray()))) } + @Test + fun theSameKeyAlsoServesThePasswordSlotWhoseInputIsLongerAndNotJustNumbers() { + // The password slot signs `tag ‖ salt ‖ UTF-8 password` under the PIN's key slot: any bytes, any length. + factor.ensureKey(PinKeySlot.A) + val password = "Pässwörd-日本語-🔑".toByteArray(Charsets.UTF_8) + val input = "encly/pw/hw/v1".toByteArray(Charsets.UTF_8) + salt + password + + val hw = factor.mac(PinKeySlot.A, input) + + assertContentEquals(referenceHmac(store.peek(PinKeySlot.A)!!, input), hw) + assertNotEquals(factor.mac(PinKeySlot.A, data).toHex(), hw.toHex()) + assertContentEquals(hw, factor.mac(PinKeySlot.A, input)) + } + @Test fun macOfTheRealRandomSecretMatchesAPlainJavaxCryptoHMAC() { factor.ensureKey(PinKeySlot.A) diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index 92d80924..c3164000 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -24,8 +24,8 @@ الكل - مفتاح الدخول المطلوب. اضغط لتغيير رمز PIN. - رمز PIN من 6 أرقام + من 6 إلى 12 رقمًا. اضغط لتغيير رمز PIN. + رمز PIN المصادقة رجوع @@ -118,7 +118,7 @@ كيف أغيّر رمز PIN؟ ينشئ Encly مفتاحًا عشوائيًا لقاعدة البيانات طوله 256 بت. يحمي رمز PIN خانة فتح منفصلة بتشفير AES-GCM، وتستخدم القياسات الحيوية مفتاحًا في Android Keystore يتطلب مصادقة عند كل استخدام. كيف يُحمى مفتاح قاعدة البيانات؟ - في شاشة القفل اضغط «استعادة الوصول»، وأدخل كلماتك الـ12، ثم عيّن رمز PIN جديدًا. بدون عبارة الاسترداد لا توجد وسيلة أخرى للدخول: لا أحد، ولا حتى المطوّر، يستطيع إعادة تعيينه. + في شاشة القفل اضغط «استعادة الوصول» وأدخل كلماتك الـ12. إن كان رمز PIN أو كلمة المرور يعمل بعدُ فيمكنك الاحتفاظ به أو تعيين رمز PIN جديد، وإلا فتعيّن رمز PIN جديدًا فورًا. بدون عبارة الاسترداد لا توجد وسيلة أخرى للدخول: لا أحد، ولا حتى المطوّر، يستطيع إعادة تعيينها. ماذا لو نسيت رمز PIN؟ افتح الإعدادات ← اللغة واختر إحدى اللغات المتاحة، أو «افتراضي النظام» لاتّباع لغة جهازك. يُطبَّق التغيير فورًا. كيف أغيّر اللغة؟ @@ -169,7 +169,7 @@ استعادة الوصول أدخل عبارة الاسترداد المكونة من 12 كلمة الاسترداد - أدخل رمز PIN المكوّن من 6 أرقام للمتابعة + أدخل رمز PIN للمتابعة افتح قفل ملاحظاتك جارٍ فتح القفل… استعد الوصول بعبارة الاسترداد @@ -215,12 +215,12 @@ %1$d%% أدخل رمز PIN مرة أخرى للتأكيد - أكّد رمز PIN المكوّن من 6 أرقام + أكّد رمز PIN أكّد رمز PIN الحالي قبل تغيير مفتاح الدخول أدخل رمز PIN الحالي - أدخل رمز PIN جديدًا لحماية ملاحظاتك + من 6 إلى 12 رقمًا لحماية ملاحظاتك تم تغيير رمز PIN - أنشئ رمز PIN من 6 أرقام + أنشئ رمز PIN رمز PIN الحالي غير صحيح حذف رقم الرمزان غير متطابقين. حاول مرة أخرى. @@ -427,7 +427,6 @@ معاينة السمة والوضع والخط صحيح - الأرقام المُدخلة: %1$d من %2$d فتح القفل ببصمة الإصبع الخطوة %1$d من %2$d دفتر ملاحظات لا يفتحه غيرك. @@ -440,8 +439,8 @@ خزنتك مقفلة بمفتاح لا يملكه سواك. ابدأ لديّ نسخة احتياطية - اختر رمز PIN من 6 أرقام - يفتح Encly ويحمي مفتاح خزنتك. لا يوجد بريد إلكتروني لإعادة تعيينه. + اختر رمز PIN + من 6 إلى 12 رقمًا. يفتح Encly ويحمي مفتاح خزنتك. لا يوجد بريد إلكتروني لإعادة تعيينه. أدخل رمز PIN مرة أخرى لنتأكد أنه الرمز الذي قصدته. يبقى رمز PIN يعمل كبديل @@ -698,4 +697,8 @@ الكتل المحددة: %1$d قص حذف الكتل المحددة + هل يمكنني الفتح بكلمة مرور بدلًا من رمز PIN؟ + نعم. في الإعدادات ← الأمان ← كلمة المرور يمكنك إضافة كلمة مرور من 8 أحرف على الأقل وبأي لغة. تعمل إلى جانب رمز PIN: تتيح لك شاشة القفل التبديل بينهما ويفتح كل منهما بمفرده. يتشاركان الحد نفسه للمحاولات الخاطئة والحماية نفسها بمفتاح الجهاز. يبقى رمز المسح ورمز الإكراه رمزَي PIN فقط. يمكنك إزالة أحدهما ما دام الآخر مُعيَّنًا. + ما طول رمز PIN المسموح به؟ + من 6 إلى 12 رقمًا بحسب اختيارك. لا تُعرض رموز أقصر: فالقفل المؤقت ومفتاح الجهاز يحدّان من عدد المحاولات، و6 أرقام هي أقصر طول يبقى آمنًا مع هذه الحدود. لا تُظهر شاشة القفل طول رمزك أبدًا: تضغط «فتح القفل» عند الانتهاء، والأمر نفسه ينطبق على رمز المسح ورمز الإكراه. تبقى الرموز التي عيّنتها سابقًا تعمل كما هي. diff --git a/core/resources/src/commonMain/strings/values-ar/strings_security.xml b/core/resources/src/commonMain/strings/values-ar/strings_security.xml index 19ab3d03..f596ef6b 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings_security.xml @@ -48,7 +48,7 @@ %d وسم - لم يعد رمز PIN قادرًا على فتح Encly على هذا الهاتف: أُعيد تعيين مفتاحه العتادي. استخدم عبارة الاسترداد (أو بصمتك)، ثم عيّن رمز PIN جديدًا. + لم يعد رمز PIN وكلمة المرور قادرين على فتح Encly على هذا الهاتف: أُعيد تعيين مفتاحهما العتادي. استخدم عبارة الاسترداد (أو بصمتك)، ثم عيّن رمز PIN جديدًا. محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة @@ -115,7 +115,7 @@ إيقاف رمز المسح أكّد رمز PIN لإعداد رمز المسح أنشئ رمز مسح - 6 أرقام، مختلفة عن رمز PIN + من 6 إلى 12 رقمًا، مختلفة عن رمز PIN أكّد رمز المسح أدخل رمز المسح مرة أخرى يجب أن يختلف رمز المسح عن رمز PIN @@ -137,7 +137,7 @@ إيقاف رمز الإكراه أكّد رمز PIN لإعداد رمز الإكراه أنشئ رمز إكراه - 6 أرقام، مختلفة عن رمز PIN + من 6 إلى 12 رقمًا، مختلفة عن رمز PIN أكّد رمز الإكراه أدخل رمز الإكراه مرة أخرى يجب أن يختلف رمز الإكراه عن رمز PIN @@ -151,4 +151,49 @@ إيقاف إظهار معاينات الملاحظات تعرض قائمة الملاحظات أول أسطر كل ملاحظة. أوقفه لعرض العنوان والوسم والتاريخ فقط؛ ويظل البحث يجد النص الكامل. + الأرقام المُدخلة: %1$d + فتح القفل + استخدام كلمة المرور + استخدام رمز PIN + أدخل كلمة المرور للمتابعة + كلمة المرور + كلمة المرور خاطئة + كلمة المرور + كلمة المرور طريقة أخرى لفتح Encly إلى جانب رمز PIN. تفتح كل واحدة منهما الخزنة بمفردها، وتُحسب المحاولات الخاطئة بأي منهما ضمن القفل المؤقت نفسه. استخدم 8 أحرف على الأقل؛ وتصلح عدة كلمات متتابعة. + يبقى رمز المسح ورمز الإكراه رمزَي PIN فقط: لا يمكن أن تكون كلمة المرور أيًّا منهما. + أُعيد تعيين مفتاح الجهاز، فتوقفت كلمة المرور عن العمل وأُزيلت. عيّنها من جديد إن أردت واحدة. + تعيين كلمة مرور + تغيير كلمة المرور + إزالة كلمة المرور + إزالة كلمة المرور؟ + سيُفتح Encly برمز PIN فقط. + إزالة + أُزيلت كلمة المرور + تم حفظ كلمة المرور + تعذّر حفظ كلمة المرور. لم يتغيّر شيء. + استخدم من 8 إلى 128 حرفًا + كلمتا المرور غير متطابقتين + كلمة مرور جديدة + أعد كتابة كلمة المرور + حفظ كلمة المرور + اختر كلمة مرور + أدخل كلمة المرور + أكّد كلمة المرور قبل تغيير طريقة فتح Encly + كلمة المرور غير صحيحة + إزالة رمز PIN + إزالة رمز PIN؟ + سيُفتح Encly بكلمة المرور فقط. يحتاج رمز المسح ورمز الإكراه إلى رمز PIN، لذا يتوقفان أيضًا؛ وتحتفظ الخزنة الوهمية بملاحظاتها. اكتب كلمة المرور للتأكد من أنها تعمل. + إزالة PIN + أُزيل رمز PIN + تعذّرت إزالة رمز PIN. لم يتغيّر شيء. + مُعيَّنة. اضغط لتغييرها أو إزالتها. + غير مُعيَّنة. أضف طريقة أخرى للفتح. + توقفت عن العمل عند إعادة تعيين مفتاح الجهاز. اضغط لتعيينها من جديد. + لم يُعيَّن رمز PIN. اضغط لإضافته. + تم قبول عبارة الاسترداد + رمز PIN لا يزال يعمل. احتفظ به أو عيّن رمزًا جديدًا. + كلمة المرور لا تزال تعمل. احتفظ بها أو عيّن رمز 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 index a7444264..e7656da6 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -24,8 +24,8 @@ Alle - Erforderlicher Zugangsschlüssel. Tippe, um deine PIN zu ändern. - 6-stellige PIN + 6 bis 12 Ziffern. Tippe, um deine PIN zu ändern. + PIN Anmeldung Zurück @@ -127,7 +127,7 @@ 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. + Tippe auf dem Sperrbildschirm auf „Zugang wiederherstellen“ und gib deine 12 Wörter ein. Funktioniert deine PIN oder dein Passwort 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? @@ -180,7 +180,7 @@ Zugang wiederherstellen Gib deine 12-Wörter-Wiederherstellungsphrase ein Wiederherstellung - Gib deine 6-stellige PIN ein, um fortzufahren + Gib deine PIN ein, um fortzufahren Notizen entsperren Wird entsperrt… Zugang mit der Wiederherstellungsphrase wiederherstellen @@ -228,12 +228,12 @@ %1$d %% Gib die PIN zur Bestätigung erneut ein - 6-stellige PIN bestätigen + 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 + 6 bis 12 Ziffern zum Schutz deiner Notizen PIN geändert - 6-stellige PIN erstellen + PIN erstellen Die aktuelle PIN ist falsch Ziffer löschen Die PINs stimmen nicht überein. Bitte versuche es erneut. @@ -434,7 +434,6 @@ 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. @@ -447,8 +446,8 @@ 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. + 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 @@ -685,4 +684,8 @@ Die Änderungen dieser Version Verstanden Notizen werden nach 28 Tagen im Papierkorb endgültig gelöscht. + Kann ich statt mit einer PIN mit einem Passwort entsperren? + Ja. Unter Einstellungen → Sicherheit → Passwort kannst du ein Passwort mit mindestens 8 Zeichen in jeder Sprache hinzufügen. Es gilt neben deiner PIN: Auf dem Sperrbildschirm wechselst du zwischen beiden, und jedes entsperrt für sich. Beide teilen sich dasselbe Limit für Fehlversuche und denselben Schutz durch den Gerätschlüssel. Lösch-PIN und Zwangs-PIN bleiben reine PINs. Du kannst jedes der beiden entfernen, solange das andere festgelegt ist. + 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. diff --git a/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml index 1e974b02..23ddfde2 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -36,7 +36,7 @@ %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. + Deine PIN und dein Passwort können 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 @@ -95,7 +95,7 @@ Lösch-PIN ausschalten Bestätige deine PIN, um die Lösch-PIN einzurichten Lösch-PIN erstellen - 6 Ziffern, anders als deine PIN + 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 @@ -117,7 +117,7 @@ Zwangs-PIN ausschalten Bestätige deine PIN, um die Zwangs-PIN einzurichten Zwangs-PIN erstellen - 6 Ziffern, anders als deine PIN + 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 @@ -131,4 +131,49 @@ 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 + Passwort verwenden + PIN verwenden + Gib dein Passwort ein, um fortzufahren + Passwort + Falsches Passwort + Passwort + Ein Passwort ist ein weiterer Weg, Encly zu entsperren, neben deiner PIN. Jedes von beiden öffnet den Tresor für sich, und Fehlversuche mit einem von beiden zählen für dieselbe Sperre. Nutze mindestens 8 Zeichen; mehrere aneinandergereihte Wörter eignen sich gut. + Lösch-PIN und Zwangs-PIN bleiben reine PINs: Ein Passwort kann keine von beiden sein. + Dein Gerätschlüssel wurde zurückgesetzt, daher funktionierte das Passwort nicht mehr und wurde entfernt. Lege es neu fest, wenn du eines möchtest. + Passwort festlegen + Passwort ändern + Passwort entfernen + Passwort entfernen? + Encly lässt sich dann nur noch mit deiner PIN entsperren. + Entfernen + Passwort entfernt + Passwort gespeichert + Das Passwort konnte nicht gespeichert werden. Es wurde nichts geändert. + Verwende 8 bis 128 Zeichen + Die Passwörter stimmen nicht überein + Neues Passwort + Passwort wiederholen + Passwort speichern + Wähle ein Passwort + Gib dein Passwort ein + Bestätige dein Passwort, bevor du änderst, wie Encly entsperrt wird + Das Passwort ist falsch + PIN entfernen + PIN entfernen? + Encly lässt sich dann nur noch mit deinem Passwort entsperren. Lösch-PIN und Zwangs-PIN brauchen eine PIN und werden deshalb ebenfalls ausgeschaltet; der Tarn-Tresor behält seine Notizen. Gib dein Passwort ein, um sicherzugehen, dass es funktioniert. + PIN entfernen + PIN entfernt + Die PIN konnte nicht entfernt werden. Es wurde nichts geändert. + Festgelegt. Tippe, um es zu ändern oder zu entfernen. + Nicht festgelegt. Füge einen weiteren Weg zum Entsperren hinzu. + Funktioniert nicht mehr, seit der Gerätschlüssel zurückgesetzt wurde. Tippe, um es neu festzulegen. + Keine PIN festgelegt. Tippe, um eine hinzuzufügen. + Wiederherstellungsphrase akzeptiert + Deine PIN funktioniert noch. Behalte sie oder lege eine neue fest. + Dein Passwort funktioniert noch. Behalte es oder lege eine PIN fest. + Meine PIN behalten + Mein Passwort 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 index 53837bd4..6289e84b 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -24,8 +24,8 @@ Todas - Clave de acceso obligatoria. Toca para cambiar el PIN. - PIN de 6 dígitos + De 6 a 12 dígitos. Toca para cambiar el PIN. + PIN Autenticación Atrás @@ -127,7 +127,7 @@ ¿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. + En la pantalla de bloqueo, toca «Recuperar el acceso» e introduce tus 12 palabras. Si tu PIN o tu contraseña siguen funcionando, puedes conservarlos 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? @@ -180,7 +180,7 @@ Recuperar el acceso Introduce tu frase de recuperación de 12 palabras Recuperación - Introduce tu PIN de 6 dígitos para continuar + Introduce tu PIN para continuar Desbloquea tus notas Desbloqueando… Recuperar el acceso con tu frase de recuperación @@ -228,12 +228,12 @@ %1$d %% Vuelve a introducir el PIN para confirmarlo - Confirma tu PIN de 6 dígitos + Confirma tu PIN Confirma tu PIN actual antes de cambiar tu clave de acceso Introduce tu PIN actual - Introduce un PIN nuevo para proteger tus notas + De 6 a 12 dígitos para proteger tus notas PIN cambiado - Crea un PIN de 6 dígitos + Crea un PIN El PIN actual no es correcto Borrar dígito Los PIN no coinciden. Inténtalo de nuevo. @@ -437,7 +437,6 @@ 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. @@ -450,8 +449,8 @@ 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. + 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 @@ -691,4 +690,8 @@ Los cambios de esta versión Entendido Las notas se eliminan definitivamente tras 28 días en la papelera. + ¿Puedo desbloquear con una contraseña en lugar de un PIN? + Sí. En Ajustes → Seguridad → Contraseña puedes añadir una contraseña de al menos 8 caracteres, en cualquier idioma. Funciona junto a tu PIN: la pantalla de bloqueo te deja cambiar entre ambos y cada uno desbloquea por sí solo. Comparten el mismo límite de intentos fallidos y la misma protección con la clave del dispositivo. El PIN de borrado y el PIN de coacción siguen siendo solo PIN. Puedes quitar cualquiera de los dos mientras el otro esté establecido. + ¿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. diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml index b6b506dc..87f4acc8 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -39,7 +39,7 @@ %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. + Tu PIN y tu contraseña ya no pueden 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 @@ -100,7 +100,7 @@ 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 + 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 @@ -122,7 +122,7 @@ 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 + 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 @@ -136,4 +136,49 @@ 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 + Usar contraseña + Usar PIN + Introduce tu contraseña para continuar + Contraseña + Contraseña incorrecta + Contraseña + Una contraseña es otra forma de desbloquear Encly, además de tu PIN. Cada una abre la bóveda por sí sola, y los intentos fallidos con cualquiera de las dos cuentan para el mismo bloqueo. Usa al menos 8 caracteres; unas cuantas palabras seguidas funcionan bien. + El PIN de borrado y el PIN de coacción siguen siendo solo PIN: una contraseña no puede ser ninguno de los dos. + Se restableció la clave del dispositivo, así que la contraseña dejó de funcionar y se eliminó. Vuelve a configurarla si quieres una. + Establecer una contraseña + Cambiar la contraseña + Quitar la contraseña + ¿Quitar la contraseña? + Encly solo se desbloqueará con tu PIN. + Quitar + Contraseña eliminada + Contraseña guardada + No se pudo guardar la contraseña. No se cambió nada. + Usa de 8 a 128 caracteres + Las contraseñas no coinciden + Contraseña nueva + Repite la contraseña + Guardar la contraseña + Elige una contraseña + Introduce tu contraseña + Confirma tu contraseña antes de cambiar cómo se desbloquea Encly + La contraseña no es correcta + Quitar el PIN + ¿Quitar el PIN? + Encly solo se desbloqueará con tu contraseña. El PIN de borrado y el PIN de coacción necesitan un PIN, así que también se desactivan; la bóveda señuelo conserva sus notas. Escribe tu contraseña para comprobar que funciona. + Quitar el PIN + PIN eliminado + No se pudo quitar el PIN. No se cambió nada. + Establecida. Toca para cambiarla o quitarla. + Sin establecer. Añade otra forma de desbloquear. + Dejó de funcionar al restablecerse la clave del dispositivo. Toca para configurarla de nuevo. + No hay PIN. Toca para añadir uno. + Frase de recuperación aceptada + Tu PIN sigue funcionando. Consérvalo o elige uno nuevo. + Tu contraseña sigue funcionando. Consérvala o establece un PIN. + Conservar mi PIN + Conservar mi contraseña + 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 index c3ae9ea0..eadf4716 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -24,8 +24,8 @@ Toutes - Clé d\'accès obligatoire. Appuyez pour modifier votre code PIN. - Code PIN à 6 chiffres + 6 à 12 chiffres. Appuyez pour modifier votre code PIN. + Code PIN Authentification Retour @@ -127,7 +127,7 @@ 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. + Sur l’écran de verrouillage, touchez « Récupérer l’accès » et saisissez vos 12 mots. Si votre code PIN ou votre mot de passe 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 ? @@ -180,7 +180,7 @@ 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 + 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 @@ -228,12 +228,12 @@ %1$d %% Saisissez à nouveau le code PIN pour confirmer - Confirmez votre code PIN à 6 chiffres + Confirmez votre code PIN 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 + 6 à 12 chiffres pour protéger vos notes Code PIN modifié - Créez un code PIN à 6 chiffres + Créez un code PIN Le code PIN actuel est incorrect Effacer le chiffre Les codes PIN ne correspondent pas. Veuillez réessayer. @@ -437,7 +437,6 @@ 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. @@ -450,8 +449,8 @@ 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. + 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 @@ -691,4 +690,8 @@ Les changements de cette version Compris Les notes sont supprimées définitivement après 28 jours dans la corbeille. + Puis-je déverrouiller avec un mot de passe plutôt qu’un code PIN ? + Oui. Dans Paramètres → Sécurité → Mot de passe, vous pouvez ajouter un mot de passe d’au moins 8 caractères, dans n’importe quelle langue. Il s’ajoute à votre code PIN : l’écran de verrouillage permet de passer de l’un à l’autre, et chacun déverrouille à lui seul. Les deux partagent la même limite d’essais erronés et la même protection par la clé de l’appareil. Le PIN d’effacement et le PIN de contrainte restent des codes PIN. Vous pouvez supprimer l’un des deux tant que l’autre est défini. + 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. diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml index fd49bfed..30b659fe 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -39,7 +39,7 @@ %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. + Votre PIN et votre mot de passe ne peuvent plus déverrouiller Encly sur ce téléphone : leur 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 @@ -100,7 +100,7 @@ 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 + 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 @@ -122,7 +122,7 @@ 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 + 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 @@ -136,4 +136,49 @@ 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 + Utiliser le mot de passe + Utiliser le code PIN + Saisissez votre mot de passe pour continuer + Mot de passe + Mot de passe incorrect + Mot de passe + Un mot de passe est une autre façon de déverrouiller Encly, en plus de votre code PIN. Chacun ouvre le coffre à lui seul, et les échecs de l’un comme de l’autre comptent pour le même blocage. Utilisez au moins 8 caractères ; quelques mots à la suite fonctionnent bien. + Le PIN d\'effacement et le PIN de contrainte restent des codes PIN : un mot de passe ne peut être ni l’un ni l’autre. + La clé de l’appareil a été réinitialisée : le mot de passe ne fonctionnait plus et a été supprimé. Définissez-le à nouveau si vous en voulez un. + Définir un mot de passe + Modifier le mot de passe + Supprimer le mot de passe + Supprimer le mot de passe ? + Encly ne se déverrouillera plus qu’avec votre code PIN. + Supprimer + Mot de passe supprimé + Mot de passe enregistré + Impossible d\'enregistrer le mot de passe. Rien n’a été modifié. + Utilisez de 8 à 128 caractères + Les mots de passe ne correspondent pas + Nouveau mot de passe + Répétez le mot de passe + Enregistrer le mot de passe + Choisissez un mot de passe + Saisissez votre mot de passe + Confirmez votre mot de passe avant de modifier le déverrouillage d’Encly + Le mot de passe est incorrect + Supprimer le code PIN + Supprimer le code PIN ? + Encly ne se déverrouillera plus qu’avec votre mot de passe. Le PIN d’effacement et le PIN de contrainte ont besoin d’un code PIN : ils sont donc désactivés aussi ; le coffre leurre garde ses notes. Saisissez votre mot de passe pour vérifier qu’il fonctionne. + Supprimer le PIN + Code PIN supprimé + Impossible de supprimer le code PIN. Rien n’a été modifié. + Défini. Touchez pour le modifier ou le supprimer. + Non défini. Ajoutez une autre façon de déverrouiller. + A cessé de fonctionner après la réinitialisation de la clé de l’appareil. Touchez pour le redéfinir. + Aucun code PIN défini. Touchez pour en ajouter un. + Phrase de récupération acceptée + Votre code PIN fonctionne toujours. Gardez-le ou définissez-en un nouveau. + Votre mot de passe fonctionne toujours. Gardez-le ou définissez un code PIN. + Garder mon code PIN + Garder mon mot de passe + 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 index dba676c4..06835957 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -24,8 +24,8 @@ Tutte - Chiave di accesso obbligatoria. Tocca per cambiare il PIN. - PIN a 6 cifre + Da 6 a 12 cifre. Tocca per cambiare il PIN. + PIN Autenticazione Indietro @@ -127,7 +127,7 @@ 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. + Nella schermata di blocco tocca «Recupera l’accesso» e inserisci le 12 parole. Se il PIN o la password funzionano ancora, puoi tenerli 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? @@ -180,7 +180,7 @@ Recupera l\'accesso Inserisci la tua frase di recupero di 12 parole Recupero - Inserisci il PIN a 6 cifre per continuare + Inserisci il PIN per continuare Sblocca le tue note Sblocco in corso… Recupera l\'accesso con la frase di recupero @@ -228,12 +228,12 @@ %1$d%% Inserisci di nuovo il PIN per confermare - Conferma il PIN a 6 cifre + Conferma il PIN Conferma il PIN attuale prima di cambiare la chiave di accesso Inserisci il PIN attuale - Inserisci un nuovo PIN per proteggere le tue note + Da 6 a 12 cifre per proteggere le tue note PIN cambiato - Crea un PIN a 6 cifre + Crea un PIN Il PIN attuale non è corretto Cancella cifra I PIN non corrispondono. Riprova. @@ -437,7 +437,6 @@ 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. @@ -450,8 +449,8 @@ 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. + 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 @@ -691,4 +690,8 @@ Le novità di questa versione Ho capito Le note vengono eliminate definitivamente dopo 28 giorni nel cestino. + Posso sbloccare con una password invece del PIN? + Sì. In Impostazioni → Sicurezza → Password puoi aggiungere una password di almeno 8 caratteri, in qualsiasi lingua. Funziona accanto al PIN: la schermata di blocco permette di passare dal PIN alla password e viceversa, e ciascuno sblocca da solo. Condividono lo stesso limite di tentativi errati e la stessa protezione con la chiave del dispositivo. Il PIN di cancellazione e il PIN di coercizione restano solo PIN. Puoi rimuovere uno dei due finché l’altro è impostato. + 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. diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml index 3c0820bc..e161e980 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -39,7 +39,7 @@ %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 tuo PIN e la tua password non possono più sbloccare Encly su questo telefono: la loro 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 @@ -100,7 +100,7 @@ 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 + 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 @@ -122,7 +122,7 @@ 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 + 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 @@ -136,4 +136,49 @@ 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 + Usa la password + Usa il PIN + Inserisci la password per continuare + Password + Password errata + Password + Una password è un altro modo per sbloccare Encly, oltre al PIN. Ciascuno dei due apre la cassaforte da solo e i tentativi errati con uno qualsiasi dei due contano per lo stesso blocco. Usa almeno 8 caratteri; alcune parole di seguito vanno benissimo. + Il PIN di cancellazione e il PIN di coercizione restano solo PIN: una password non può essere nessuno dei due. + La chiave del dispositivo è stata reimpostata, quindi la password ha smesso di funzionare ed è stata rimossa. Impostala di nuovo se ti serve. + Imposta una password + Cambia password + Rimuovi la password + Rimuovere la password? + Encly si sbloccherà solo con il PIN. + Rimuovi + Password rimossa + Password salvata + Impossibile salvare la password. Non è cambiato nulla. + Usa da 8 a 128 caratteri + Le password non corrispondono + Nuova password + Ripeti la password + Salva la password + Scegli una password + Inserisci la password + Conferma la password prima di cambiare il modo in cui si sblocca Encly + La password non è corretta + Rimuovi il PIN + Rimuovere il PIN? + Encly si sbloccherà solo con la password. Il PIN di cancellazione e il PIN di coercizione richiedono un PIN, quindi vengono disattivati anch’essi; la cassaforte civetta conserva le sue note. Scrivi la password per verificare che funzioni. + Rimuovi il PIN + PIN rimosso + Impossibile rimuovere il PIN. Non è cambiato nulla. + Impostata. Tocca per cambiarla o rimuoverla. + Non impostata. Aggiungi un altro modo per sbloccare. + Ha smesso di funzionare quando la chiave del dispositivo è stata reimpostata. Tocca per impostarla di nuovo. + Nessun PIN impostato. Tocca per aggiungerne uno. + Frase di recupero accettata + Il tuo PIN funziona ancora. Tienilo o imposta un nuovo PIN. + La tua password funziona ancora. Tienila o imposta un PIN. + Tieni il mio PIN + Tieni la mia password + 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 index b2b520fb..ee8a5912 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -24,8 +24,8 @@ Alle - Verplichte toegangssleutel. Tik om je pincode te wijzigen. - Pincode van 6 cijfers + 6 tot 12 cijfers. Tik om je pincode te wijzigen. + Pincode Verificatie Terug @@ -127,7 +127,7 @@ 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. + Tik op het vergrendelscherm op ‘Toegang herstellen’ en voer je 12 woorden in. Werkt je pincode of wachtwoord 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? @@ -180,7 +180,7 @@ Toegang herstellen Voer je herstelzin van 12 woorden in Herstel - Voer je pincode van 6 cijfers in om door te gaan + Voer je pincode in om door te gaan Ontgrendel je notities Ontgrendelen… Toegang herstellen met je herstelzin @@ -228,12 +228,12 @@ %1$d%% Voer de pincode nogmaals in om te bevestigen - Bevestig je pincode van 6 cijfers + Bevestig je pincode Bevestig je huidige pincode voordat je je toegangssleutel wijzigt Voer je huidige pincode in - Voer een nieuwe pincode in om je notities te beschermen + 6 tot 12 cijfers om je notities te beschermen Pincode gewijzigd - Maak een pincode van 6 cijfers + Maak een pincode De huidige pincode is onjuist Cijfer wissen De pincodes komen niet overeen. Probeer het opnieuw. @@ -434,7 +434,6 @@ 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. @@ -447,8 +446,8 @@ 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. + 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 @@ -685,4 +684,8 @@ De wijzigingen in deze versie Begrepen Notities worden na 28 dagen in de prullenbak definitief verwijderd. + Kan ik met een wachtwoord ontgrendelen in plaats van met een pincode? + Ja. Onder Instellingen → Beveiliging → Wachtwoord kun je een wachtwoord van minstens 8 tekens toevoegen, in elke taal. Het werkt naast je pincode: op het vergrendelscherm wissel je tussen beide en elk ontgrendelt zelfstandig. Ze delen dezelfde limiet voor foute pogingen en dezelfde bescherming door de sleutel van het apparaat. De wis-pincode en de dwangpincode blijven pincodes. Je kunt een van beide verwijderen zolang de ander is ingesteld. + 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. diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml index 74830ea7..c92224ec 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -36,7 +36,7 @@ %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. + Je pincode en wachtwoord kunnen 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 @@ -95,7 +95,7 @@ Wis-pincode uitzetten Bevestig je pincode om de wis-pincode in te stellen Maak een wis-pincode - 6 cijfers, anders dan je 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 @@ -117,7 +117,7 @@ Dwangpincode uitschakelen Bevestig je pincode om de dwangpincode in te stellen Een dwangpincode maken - 6 cijfers, anders dan je pincode + 6 tot 12 cijfers, anders dan je pincode Bevestig de dwangpincode Voer de dwangpincode nogmaals in De dwangpincode moet anders zijn dan je pincode @@ -131,4 +131,49 @@ 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 + Wachtwoord gebruiken + Pincode gebruiken + Voer je wachtwoord in om door te gaan + Wachtwoord + Onjuist wachtwoord + Wachtwoord + Een wachtwoord is een andere manier om Encly te ontgrendelen, naast je pincode. Elk van beide opent de kluis zelfstandig, en foute pogingen met een van beide tellen mee voor dezelfde blokkade. Gebruik minstens 8 tekens; een paar woorden achter elkaar werkt goed. + De wis-pincode en de dwangpincode blijven pincodes: een wachtwoord kan geen van beide zijn. + De sleutel van het apparaat is gereset, dus het wachtwoord werkte niet meer en is verwijderd. Stel het opnieuw in als je er een wilt. + Wachtwoord instellen + Wachtwoord wijzigen + Wachtwoord verwijderen + Wachtwoord verwijderen? + Encly ontgrendelt dan alleen nog met je pincode. + Verwijderen + Wachtwoord verwijderd + Wachtwoord opgeslagen + Kan het wachtwoord niet opslaan. Er is niets gewijzigd. + Gebruik 8 tot 128 tekens + De wachtwoorden komen niet overeen + Nieuw wachtwoord + Herhaal het wachtwoord + Wachtwoord opslaan + Kies een wachtwoord + Voer je wachtwoord in + Bevestig je wachtwoord voordat je wijzigt hoe Encly ontgrendelt + Het wachtwoord is onjuist + Pincode verwijderen + Pincode verwijderen? + Encly ontgrendelt dan alleen nog met je wachtwoord. De wis-pincode en de dwangpincode hebben een pincode nodig en worden dus ook uitgeschakeld; de schijnkluis behoudt zijn notities. Typ je wachtwoord om te controleren of het werkt. + Pincode verwijderen + Pincode verwijderd + Kan de pincode niet verwijderen. Er is niets gewijzigd. + Ingesteld. Tik om het te wijzigen of te verwijderen. + Niet ingesteld. Voeg een andere manier om te ontgrendelen toe. + Werkt niet meer sinds de sleutel van het apparaat is gereset. Tik om het opnieuw in te stellen. + Geen pincode ingesteld. Tik om er een toe te voegen. + Herstelzin geaccepteerd + Je pincode werkt nog. Behoud hem of stel een nieuwe in. + Je wachtwoord werkt nog. Behoud het of stel een pincode in. + Mijn pincode behouden + Mijn wachtwoord 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 index a6d84fe9..b7ac5c58 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -24,8 +24,8 @@ Wszystkie - Wymagany klucz dostępu. Stuknij, aby zmienić PIN. - 6-cyfrowy PIN + Od 6 do 12 cyfr. Stuknij, aby zmienić PIN. + PIN Uwierzytelnianie Wstecz @@ -127,7 +127,7 @@ 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ć. + Na ekranie blokady stuknij „Odzyskaj dostęp” i wpisz 12 słów. Jeśli twój PIN lub hasło nadal działa, możesz je 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? @@ -180,7 +180,7 @@ Odzyskaj dostęp Wpisz 12-wyrazową frazę odzyskiwania Odzyskiwanie - Wpisz 6-cyfrowy PIN, aby kontynuować + Wpisz PIN, aby kontynuować Odblokuj notatki Odblokowywanie… Odzyskaj dostęp za pomocą frazy odzyskiwania @@ -228,12 +228,12 @@ %1$d%% Wpisz PIN ponownie, aby go potwierdzić - Potwierdź 6-cyfrowy PIN + Potwierdź PIN Potwierdź obecny PIN przed zmianą klucza dostępu Wpisz obecny PIN - Wpisz nowy PIN, aby chronić swoje notatki + Od 6 do 12 cyfr, aby chronić swoje notatki PIN zmieniony - Utwórz 6-cyfrowy PIN + Utwórz PIN Obecny PIN jest nieprawidłowy Usuń cyfrę PIN-y nie są zgodne. Spróbuj ponownie. @@ -440,7 +440,6 @@ 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. @@ -453,8 +452,8 @@ 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. + 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 @@ -697,4 +696,8 @@ Zmiany w tej wersji Rozumiem Notatki są trwale usuwane po 28 dniach w koszu. + Czy mogę odblokowywać hasłem zamiast PIN-em? + Tak. W Ustawienia → Bezpieczeństwo → Hasło możesz dodać hasło z co najmniej 8 znaków, w dowolnym języku. Działa obok PIN-u: na ekranie blokady przełączasz się między nimi, a każde odblokowuje samodzielnie. Oba mają ten sam limit błędnych prób i tę samą ochronę kluczem urządzenia. PIN kasujący i PIN przymusowy pozostają wyłącznie kodami PIN. Możesz usunąć jedno z nich, dopóki drugie jest ustawione. + 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. diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml index 520e940c..9a703b08 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -42,7 +42,7 @@ %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. + Twój PIN i hasło nie mogą już odblokować Encly na tym telefonie: ich 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 @@ -105,7 +105,7 @@ 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 + 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 @@ -127,7 +127,7 @@ Wyłącz PIN przymusowy Potwierdź swój PIN, aby ustawić PIN przymusowy Utwórz PIN przymusowy - 6 cyfr, inny niż twój PIN + 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 @@ -141,4 +141,49 @@ 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 + Użyj hasła + Użyj PIN-u + Wpisz hasło, aby kontynuować + Hasło + Nieprawidłowe hasło + Hasło + Hasło to kolejny sposób odblokowania Encly obok PIN-u. Każde z nich samodzielnie otwiera sejf, a błędne próby z użyciem któregokolwiek wliczają się do tej samej blokady. Użyj co najmniej 8 znaków; dobrze sprawdza się kilka słów z rzędu. + PIN kasujący i PIN przymusowy pozostają wyłącznie kodami PIN: hasło nie może być żadnym z nich. + Klucz urządzenia został zresetowany, więc hasło przestało działać i zostało usunięte. Ustaw je ponownie, jeśli chcesz. + Ustaw hasło + Zmień hasło + Usuń hasło + Usunąć hasło? + Encly będzie odblokowywane tylko za pomocą PIN-u. + Usuń + Hasło usunięte + Hasło zapisane + Nie udało się zapisać hasła. Nic nie zostało zmienione. + Użyj od 8 do 128 znaków + Hasła nie są takie same + Nowe hasło + Powtórz hasło + Zapisz hasło + Wybierz hasło + Wpisz hasło + Potwierdź hasło, zanim zmienisz sposób odblokowywania Encly + Hasło jest nieprawidłowe + Usuń PIN + Usunąć PIN? + Encly będzie odblokowywane tylko hasłem. PIN kasujący i PIN przymusowy wymagają PIN-u, więc również zostaną wyłączone; sejf-przynęta zachowa swoje notatki. Wpisz hasło, aby upewnić się, że działa. + Usuń PIN + PIN usunięty + Nie udało się usunąć PIN-u. Nic nie zostało zmienione. + Ustawione. Stuknij, aby zmienić lub usunąć. + Nie ustawiono. Dodaj kolejny sposób odblokowania. + Przestało działać po zresetowaniu klucza urządzenia. Stuknij, aby ustawić ponownie. + Nie ustawiono PIN-u. Stuknij, aby go dodać. + Fraza odzyskiwania zaakceptowana + Twój PIN nadal działa. Zachowaj go albo ustaw nowy. + Twoje hasło nadal działa. Zachowaj je albo ustaw PIN. + Zachowaj mój PIN + Zachowaj moje hasło + 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 index be186891..78b9696e 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -24,8 +24,8 @@ Todas - Chave de acesso obrigatória. Toque para alterar o PIN. - PIN de 6 dígitos + De 6 a 12 dígitos. Toque para alterar o PIN. + PIN Autenticação Voltar @@ -127,7 +127,7 @@ 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. + Na tela de bloqueio, toque em \"Recuperar acesso\" e digite as 12 palavras. Se o PIN ou a senha ainda funcionarem, você pode mantê-los 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? @@ -180,7 +180,7 @@ Recuperar acesso Digite sua frase de recuperação de 12 palavras Recuperação - Digite seu PIN de 6 dígitos para continuar + Digite seu PIN para continuar Desbloqueie suas notas Desbloqueando… Recuperar o acesso com a frase de recuperação @@ -228,12 +228,12 @@ %1$d%% Digite o PIN novamente para confirmar - Confirme seu PIN de 6 dígitos + Confirme seu PIN Confirme seu PIN atual antes de alterar sua chave de acesso Digite seu PIN atual - Digite um novo PIN para proteger suas notas + De 6 a 12 dígitos para proteger suas notas PIN alterado - Crie um PIN de 6 dígitos + Crie um PIN O PIN atual está incorreto Apagar dígito Os PINs não coincidem. Tente novamente. @@ -437,7 +437,6 @@ 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. @@ -450,8 +449,8 @@ 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. + 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 @@ -691,4 +690,8 @@ As alterações desta versão Entendi As notas são eliminadas definitivamente após 28 dias na lixeira. + Posso desbloquear com uma senha em vez de um PIN? + Sim. Em Configurações → Segurança → Senha você pode adicionar uma senha com pelo menos 8 caracteres, em qualquer idioma. Ela funciona ao lado do PIN: a tela de bloqueio permite alternar entre os dois e cada um desbloqueia sozinho. Os dois compartilham o mesmo limite de tentativas erradas e a mesma proteção pela chave do dispositivo. O PIN de apagamento e o PIN de coação continuam sendo apenas PINs. Você pode remover um dos dois enquanto o outro estiver definido. + 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. diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml index 71cbab85..5e847dfc 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -39,7 +39,7 @@ %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. + Seu PIN e sua senha não podem mais desbloquear o Encly neste telefone: a chave de hardware deles 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 @@ -100,7 +100,7 @@ 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 + 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 @@ -122,7 +122,7 @@ 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 + 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 @@ -136,4 +136,49 @@ 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 + Usar senha + Usar PIN + Digite sua senha para continuar + Senha + Senha incorreta + Senha + Uma senha é outra forma de desbloquear o Encly, além do PIN. Cada uma abre o cofre sozinha, e as tentativas erradas com qualquer uma contam para o mesmo bloqueio. Use pelo menos 8 caracteres; algumas palavras em sequência funcionam bem. + O PIN de apagamento e o PIN de coação continuam sendo apenas PINs: uma senha não pode ser nenhum dos dois. + A chave do dispositivo foi redefinida, então a senha parou de funcionar e foi removida. Defina-a de novo se quiser uma. + Definir uma senha + Alterar senha + Remover senha + Remover a senha? + O Encly será desbloqueado apenas com o PIN. + Remover + Senha removida + Senha salva + Não foi possível salvar a senha. Nada foi alterado. + Use de 8 a 128 caracteres + As senhas não coincidem + Nova senha + Repita a senha + Salvar senha + Escolha uma senha + Digite sua senha + Confirme sua senha antes de mudar como o Encly é desbloqueado + A senha está incorreta + Remover PIN + Remover o PIN? + O Encly será desbloqueado apenas com a senha. O PIN de apagamento e o PIN de coação precisam de um PIN, então também serão desativados; o cofre isca mantém as notas. Digite sua senha para ter certeza de que funciona. + Remover PIN + PIN removido + Não foi possível remover o PIN. Nada foi alterado. + Definida. Toque para alterar ou remover. + Não definida. Adicione outra forma de desbloquear. + Parou de funcionar quando a chave do dispositivo foi redefinida. Toque para definir de novo. + Nenhum PIN definido. Toque para adicionar um. + Frase de recuperação aceita + Seu PIN ainda funciona. Mantenha-o ou defina um novo. + Sua senha ainda funciona. Mantenha-a ou defina um PIN. + Manter meu PIN + Manter minha senha + 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 index 3053a115..f2685392 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -24,8 +24,8 @@ Всі - Обов\'язковий ключ доступу. Натисніть, щоб змінити PIN. - 6-значний PIN + Від 6 до 12 цифр. Натисніть, щоб змінити PIN. + PIN Авторизація Назад @@ -127,7 +127,7 @@ Як змінити PIN? Encly створює випадковий 256-бітний ключ бази даних. PIN захищає окрему зашифровану (AES-GCM) копію цього ключа, а біометрія використовує ключ Android Keystore, який щоразу потребує підтвердження біометрією. Як захищений ключ бази даних? - На екрані блокування натисніть «Відновити доступ», введіть 12 слів і задайте новий PIN. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. + На екрані блокування натисніть «Відновити доступ» і введіть 12 слів. Якщо ваш PIN або пароль ще працює, його можна залишити або задати новий PIN; якщо ні, новий PIN задається одразу. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. Що буде, якщо я забуду PIN? Відкрийте Налаштування → Мова та виберіть одну з доступних мов або «Як у системі», щоб використовувати мову пристрою. Зміна застосовується одразу. Як змінити мову? @@ -180,7 +180,7 @@ Відновити доступ Введіть фразу відновлення з 12 слів Відновлення - Введіть 6-значний PIN для доступу + Введіть PIN для доступу Розблокуйте нотатки Розблокування… Відновити доступ за фразою відновлення @@ -228,12 +228,12 @@ %1$d%% Повторно введіть PIN-код для підтвердження - Підтвердіть 6-значний PIN-код + Підтвердьте PIN-код Підтвердіть поточний PIN перед зміною ключа доступу Введіть поточний PIN - Введіть новий PIN-код для захисту нотаток + Від 6 до 12 цифр для захисту нотаток PIN змінено - Створіть 6-значний PIN-код + Створіть PIN-код Поточний PIN неправильний Стерти PIN-коди не збігаються. Спробуйте ще раз. @@ -440,7 +440,6 @@ Попередній перегляд Тема, режим і шрифт Правильно - Введено цифр: %1$d з %2$d Розблокувати відбитком пальця Крок %1$d з %2$d Записник, який можете відкрити лише ви. @@ -453,8 +452,8 @@ Ваше сховище замкнене ключем, який є лише у вас. Почати У мене є резервна копія - Оберіть 6-значний PIN - Він відмикає Encly і захищає ключ до вашого сховища. Скинути його через пошту неможливо. + Оберіть PIN + Від 6 до 12 цифр. Він відмикає Encly і захищає ключ до вашого сховища. Скинути його через пошту неможливо. Введіть PIN ще раз Щоб переконатися, що це саме той PIN. PIN і далі працюватиме як запасний спосіб @@ -697,4 +696,8 @@ Зміни в цій версії Зрозуміло Нотатки остаточно видаляються через 28 днів у кошику. + Чи можна розблоковувати паролем замість PIN? + Так. У Налаштування → Безпека → Пароль можна додати пароль щонайменше з 8 символів будь-якою мовою. Він працює поряд із PIN: на екрані блокування можна перемикатися між ними, і кожен розблоковує сам по собі. Обидва мають спільний ліміт помилкових спроб і той самий захист ключем пристрою. PIN для стирання та PIN під примусом залишаються лише PIN-кодами. Будь-який із двох можна видалити, поки задано інший. + Якої довжини може бути PIN? + Від 6 до 12 цифр на ваш вибір. Коротших PIN не пропонується: блокування й ключ пристрою обмежують кількість спроб, а 6 цифр — найкоротший PIN, що лишається безпечним за цих обмежень. Екран блокування ніколи не показує довжину вашого PIN: коли закінчите, натисніть «Розблокувати» — так само для PIN для стирання та PIN під примусом. PIN, задані раніше, працюють без змін. diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml index 10600358..566e3290 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -42,7 +42,7 @@ %d тега - Ваш PIN більше не може розблокувати Encly на цьому телефоні: його апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. + Ваші PIN і пароль більше не можуть розблокувати Encly на цьому телефоні: їхній апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. Забагато спроб. Спробуйте через %d хвилину Забагато спроб. Спробуйте через %d хвилини @@ -105,7 +105,7 @@ Вимкнути PIN для стирання Підтвердьте свій PIN, щоб налаштувати PIN для стирання Створіть PIN для стирання - 6 цифр, відмінні від вашого PIN + Від 6 до 12 цифр, відмінних від вашого PIN Підтвердьте PIN для стирання Введіть PIN для стирання ще раз PIN для стирання має відрізнятися від вашого PIN @@ -127,7 +127,7 @@ Вимкнути PIN під примусом Підтвердьте свій PIN, щоб налаштувати PIN під примусом Створіть PIN під примусом - 6 цифр, відмінні від вашого PIN + Від 6 до 12 цифр, відмінних від вашого PIN Підтвердьте PIN під примусом Введіть PIN під примусом ще раз PIN під примусом має відрізнятися від вашого PIN @@ -141,4 +141,49 @@ Вимкнути Показувати попередній перегляд нотаток Список нотаток показує перші рядки кожної нотатки. Вимкніть, щоб бачити лише заголовок, мітку й дату; пошук і далі знаходить увесь текст. + Введено цифр: %1$d + Розблокувати + Використати пароль + Використати PIN + Введіть пароль, щоб продовжити + Пароль + Неправильний пароль + Пароль + Пароль — ще один спосіб розблокувати Encly поряд із PIN. Кожен із них відкриває сховище самостійно, а помилкові спроби з будь-яким із них враховуються в одному й тому самому блокуванні. Використайте щонайменше 8 символів; добре підходить кілька слів поспіль. + PIN для стирання та PIN під примусом залишаються лише PIN-кодами: пароль не може бути жодним із них. + Ключ пристрою було скинуто, тож пароль перестав працювати й був видалений. Задайте його знову, якщо він вам потрібен. + Задати пароль + Змінити пароль + Видалити пароль + Видалити пароль? + Encly розблоковуватиметься лише за допомогою PIN. + Видалити + Пароль видалено + Пароль збережено + Не вдалося зберегти пароль. Нічого не змінено. + Використайте від 8 до 128 символів + Паролі не збігаються + Новий пароль + Повторіть пароль + Зберегти пароль + Оберіть пароль + Введіть свій пароль + Підтвердьте пароль, перш ніж змінювати спосіб розблокування Encly + Пароль неправильний + Видалити PIN + Видалити PIN? + Encly розблоковуватиметься лише паролем. PIN для стирання та PIN під примусом потребують PIN, тому їх також буде вимкнено; підставне сховище збереже свої нотатки. Введіть пароль, щоб переконатися, що він працює. + Видалити PIN + PIN видалено + Не вдалося видалити PIN. Нічого не змінено. + Задано. Натисніть, щоб змінити або видалити. + Не задано. Додайте ще один спосіб розблокування. + Перестав працювати після скидання ключа пристрою. Натисніть, щоб задати знову. + PIN не задано. Натисніть, щоб додати. + Фразу відновлення прийнято + Ваш PIN усе ще працює. Залиште його або задайте новий. + Ваш пароль усе ще працює. Залиште його або задайте PIN. + Залишити мій PIN + Залишити мій пароль + Задати новий PIN diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index 98b2d51a..9b344e01 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -41,8 +41,8 @@ All - Required access key. Tap to change your PIN. - 6-digit PIN + 6 to 12 digits. Tap to change your PIN. + PIN Authorization Back @@ -144,7 +144,7 @@ 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. + On the lock screen, tap \"Recover access\" and enter your 12 words. If your PIN or password 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? @@ -197,7 +197,7 @@ Recover access Enter your 12-word recovery phrase Recovery - Enter your 6-digit PIN to continue + Enter your PIN to continue Unlock your notes Unlocking… Recover access with your recovery phrase @@ -245,12 +245,12 @@ %1$d%% Enter the PIN again to confirm - Confirm your 6-digit PIN + Confirm your PIN Confirm your current PIN before changing your access key Enter your current PIN - Enter a new PIN to protect your notes + 6 to 12 digits to protect your notes PIN changed - Create a 6-digit PIN + Create a PIN Current PIN is incorrect Delete digit PINs don\'t match. Please try again. @@ -451,7 +451,6 @@ Preview Theme, mode and font Correct - Digits entered: %1$d of %2$d Unlock with fingerprint Step %1$d of %2$d Aa @@ -465,8 +464,8 @@ 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. + 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 @@ -703,4 +702,8 @@ The changes in this version Got it Notes are deleted for good after 28 days in the trash. + Can I unlock with a password instead of a PIN? + Yes. In Settings → Security → Password you can add a password of at least 8 characters, any language. It works next to your PIN: the lock screen lets you switch between them, and each one unlocks on its own. Both share the same limit on wrong attempts and the same protection by the device key. The wipe PIN and the duress PIN stay PIN-only. You can remove either one while the other is set. + 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. diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml index 4e2c5e0c..36af40b9 100644 --- a/core/resources/src/commonMain/strings/values/strings_security.xml +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -36,7 +36,7 @@ %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. + Your PIN and password can no longer unlock Encly on this phone: their 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 @@ -95,7 +95,7 @@ Turn off wipe PIN Confirm your PIN to set up the wipe PIN Create a wipe PIN - 6 digits, different from your 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 @@ -117,7 +117,7 @@ Turn off duress PIN Confirm your PIN to set up the duress PIN Create a duress PIN - 6 digits, different from your 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 @@ -131,4 +131,49 @@ 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 + Use password + Use PIN + Enter your password to continue + Password + Wrong password + Password + A password is another way to unlock Encly, next to your PIN. Each one opens the vault on its own, and wrong attempts with either count toward the same lockout. Use at least 8 characters; a few words strung together work well. + The wipe PIN and the duress PIN stay PIN-only: a password can\'t be either of them. + Your device key was reset, so the password stopped working and was removed. Set it again if you want one. + Set a password + Change password + Remove password + Remove the password? + Encly will unlock with your PIN only. + Remove + Password removed + Password saved + Couldn\'t save the password. Nothing was changed. + Use 8 to 128 characters + Passwords don\'t match + New password + Repeat the password + Save password + Choose a password + Enter your password + Confirm your password before changing how Encly unlocks + Password is incorrect + Remove PIN + Remove the PIN? + Encly will unlock with your password only. The wipe PIN and the duress PIN need a PIN, so they are turned off too; the decoy vault keeps its notes. Type your password to make sure it works. + Remove PIN + PIN removed + Couldn\'t remove the PIN. Nothing was changed. + Set. Tap to change or remove it. + Not set. Add another way to unlock. + Stopped working when the device key was reset. Tap to set it again. + No PIN is set. Tap to add one. + Recovery phrase accepted + Your PIN still works. Keep it, or set a new one. + Your password still works. Keep it, or set a PIN. + Keep my PIN + Keep my password + Set a new PIN diff --git a/core/security/api/android/security.api b/core/security/api/android/security.api index 20a0fcf0..cee578a1 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -355,8 +355,8 @@ public final class com/pasich/encly/core/backup/ParsedBackup { } 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 fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)V + public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/pasich/encly/core/security/AuthType; public final fun component2 ()Z public final fun component3 ()Z @@ -364,13 +364,17 @@ public final class com/pasich/encly/core/security/AuthSettings { 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 final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILjava/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 getHasPassword ()Z public final fun getHasPinSlot ()Z + public final fun getPasswordTurnedOff ()Z public final fun getWipePinTurnedOff ()Z public fun hashCode ()I public final fun isBiometricEnabled ()Z @@ -406,6 +410,7 @@ public final class com/pasich/encly/core/security/AuthenticationManager { 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 changePassword ([C[B)Lcom/pasich/encly/core/security/PasswordChange; 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; @@ -418,16 +423,24 @@ public final class com/pasich/encly/core/security/AuthenticationManager { public final fun ensureWipeSlot ()Z public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; public final fun hasDecoyVault ([B)Z + public final fun hasPasswordSlot ()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 passwordTurnedOff ()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 removePassword ()Z + public final fun removePin ([B)Z public final fun removeWipePin ()Z + public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/PinUnlock; public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; + public final fun verifyPasswordAuth ([CZ)Z + public static synthetic fun verifyPasswordAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z 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 @@ -454,7 +467,10 @@ public final class com/pasich/encly/core/security/AuthenticationManager$MetroFac } public final class com/pasich/encly/core/security/AuthenticationManagerKt { - public static final field PIN_LENGTH I + public static final field MAX_PASSWORD_LENGTH I + public static final field MAX_PIN_LENGTH I + public static final field MIN_PASSWORD_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 { @@ -680,6 +696,15 @@ public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; } +public final class com/pasich/encly/core/security/PasswordChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/PasswordChange; + public static final field INVALID Lcom/pasich/encly/core/security/PasswordChange; + public static final field SET Lcom/pasich/encly/core/security/PasswordChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PasswordChange; + public static fun values ()[Lcom/pasich/encly/core/security/PasswordChange; +} + 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; @@ -757,6 +782,23 @@ public final class com/pasich/encly/core/security/PinUnlock$WrongPin : com/pasic 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; @@ -798,6 +840,7 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; public final fun canReauthWithBiometric ()Z public final fun canResetPinWithoutCurrent ()Z + public final fun changePassword ([C)Lcom/pasich/encly/core/security/PasswordChange; public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; public final fun commitInitialSetup ()Z public final fun completePendingWipe ()V @@ -825,20 +868,27 @@ public final class com/pasich/encly/core/security/SecurityManager { 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 removePassword ()Z + public final fun removePin ()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 unlockMethods ()Lcom/pasich/encly/core/security/UnlockMethods; + public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; 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 verifyPassword ([C)Z public final fun verifyPin ([C)Z public final fun verifySeed ([C)Z public final fun wipeAndReset ()Z @@ -940,6 +990,19 @@ public final class com/pasich/encly/core/security/SessionLockManagerKt { public static final field LOCK_FLUSH_TIMEOUT_MS J } +public final class com/pasich/encly/core/security/UnlockMethods { + public fun (ZZ)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun copy (ZZ)Lcom/pasich/encly/core/security/UnlockMethods; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/UnlockMethods;ZZILjava/lang/Object;)Lcom/pasich/encly/core/security/UnlockMethods; + public fun equals (Ljava/lang/Object;)Z + public final fun getPassword ()Z + public final fun getPin ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + 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 diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index 20a0fcf0..cee578a1 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -355,8 +355,8 @@ public final class com/pasich/encly/core/backup/ParsedBackup { } 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 fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)V + public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/pasich/encly/core/security/AuthType; public final fun component2 ()Z public final fun component3 ()Z @@ -364,13 +364,17 @@ public final class com/pasich/encly/core/security/AuthSettings { 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 final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILjava/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 getHasPassword ()Z public final fun getHasPinSlot ()Z + public final fun getPasswordTurnedOff ()Z public final fun getWipePinTurnedOff ()Z public fun hashCode ()I public final fun isBiometricEnabled ()Z @@ -406,6 +410,7 @@ public final class com/pasich/encly/core/security/AuthenticationManager { 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 changePassword ([C[B)Lcom/pasich/encly/core/security/PasswordChange; 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; @@ -418,16 +423,24 @@ public final class com/pasich/encly/core/security/AuthenticationManager { public final fun ensureWipeSlot ()Z public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; public final fun hasDecoyVault ([B)Z + public final fun hasPasswordSlot ()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 passwordTurnedOff ()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 removePassword ()Z + public final fun removePin ([B)Z public final fun removeWipePin ()Z + public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/PinUnlock; public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; + public final fun verifyPasswordAuth ([CZ)Z + public static synthetic fun verifyPasswordAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z 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 @@ -454,7 +467,10 @@ public final class com/pasich/encly/core/security/AuthenticationManager$MetroFac } public final class com/pasich/encly/core/security/AuthenticationManagerKt { - public static final field PIN_LENGTH I + public static final field MAX_PASSWORD_LENGTH I + public static final field MAX_PIN_LENGTH I + public static final field MIN_PASSWORD_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 { @@ -680,6 +696,15 @@ public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; } +public final class com/pasich/encly/core/security/PasswordChange : java/lang/Enum { + public static final field FAILED Lcom/pasich/encly/core/security/PasswordChange; + public static final field INVALID Lcom/pasich/encly/core/security/PasswordChange; + public static final field SET Lcom/pasich/encly/core/security/PasswordChange; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PasswordChange; + public static fun values ()[Lcom/pasich/encly/core/security/PasswordChange; +} + 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; @@ -757,6 +782,23 @@ public final class com/pasich/encly/core/security/PinUnlock$WrongPin : com/pasic 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; @@ -798,6 +840,7 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; public final fun canReauthWithBiometric ()Z public final fun canResetPinWithoutCurrent ()Z + public final fun changePassword ([C)Lcom/pasich/encly/core/security/PasswordChange; public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; public final fun commitInitialSetup ()Z public final fun completePendingWipe ()V @@ -825,20 +868,27 @@ public final class com/pasich/encly/core/security/SecurityManager { 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 removePassword ()Z + public final fun removePin ()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 unlockMethods ()Lcom/pasich/encly/core/security/UnlockMethods; + public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; 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 verifyPassword ([C)Z public final fun verifyPin ([C)Z public final fun verifySeed ([C)Z public final fun wipeAndReset ()Z @@ -940,6 +990,19 @@ public final class com/pasich/encly/core/security/SessionLockManagerKt { public static final field LOCK_FLUSH_TIMEOUT_MS J } +public final class com/pasich/encly/core/security/UnlockMethods { + public fun (ZZ)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun copy (ZZ)Lcom/pasich/encly/core/security/UnlockMethods; + public static synthetic fun copy$default (Lcom/pasich/encly/core/security/UnlockMethods;ZZILjava/lang/Object;)Lcom/pasich/encly/core/security/UnlockMethods; + public fun equals (Ljava/lang/Object;)Z + public final fun getPassword ()Z + public final fun getPin ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + 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 diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index df6d6326..6a65fab4 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -140,6 +140,18 @@ final enum class com.pasich.encly.core.security/InitialStatus : kotlin/Enum // com.pasich.encly.core.security/InitialStatus.values|values#static(){}[0] } +final enum class com.pasich.encly.core.security/PasswordChange : kotlin/Enum { // com.pasich.encly.core.security/PasswordChange|null[0] + enum entry FAILED // com.pasich.encly.core.security/PasswordChange.FAILED|null[0] + enum entry INVALID // com.pasich.encly.core.security/PasswordChange.INVALID|null[0] + enum entry SET // com.pasich.encly.core.security/PasswordChange.SET|null[0] + + final val entries // com.pasich.encly.core.security/PasswordChange.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/PasswordChange.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/PasswordChange.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.pasich.encly.core.security/PasswordChange.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] @@ -165,6 +177,29 @@ final enum class com.pasich.encly.core.security/PinKeySlot : kotlin/Enum // 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] @@ -656,7 +691,7 @@ final class com.pasich.encly.core.backup/ParsedBackup { // com.pasich.encly.core } 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] + constructor (com.pasich.encly.core.security/AuthType, kotlin/Boolean, kotlin/Boolean, 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;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] @@ -664,12 +699,16 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 hasPassword // com.pasich.encly.core.security/AuthSettings.hasPassword|{}hasPassword[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.hasPassword.|(){}[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 passwordTurnedOff // com.pasich.encly.core.security/AuthSettings.passwordTurnedOff|{}passwordTurnedOff[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.passwordTurnedOff.|(){}[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] @@ -680,7 +719,9 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 component8(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component8|component8(){}[0] + final fun component9(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component9|component9(){}[0] + final fun copy(com.pasich.encly.core.security/AuthType = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., 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;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] @@ -689,6 +730,7 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 changePassword(kotlin/CharArray, kotlin/ByteArray): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/AuthenticationManager.changePassword|changePassword(kotlin.CharArray;kotlin.ByteArray){}[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] @@ -701,16 +743,23 @@ final class com.pasich.encly.core.security/AuthenticationManager { // com.pasich 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 hasPasswordSlot(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.hasPasswordSlot|hasPasswordSlot(){}[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 passwordTurnedOff(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.passwordTurnedOff|passwordTurnedOff(){}[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 removePassword(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removePassword|removePassword(){}[0] + final fun removePin(kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removePin|removePin(kotlin.ByteArray){}[0] final fun removeWipePin(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removeWipePin|removeWipePin(){}[0] + final fun unlockWithPassword(kotlin/CharArray): com.pasich.encly.core.security/PinUnlock // com.pasich.encly.core.security/AuthenticationManager.unlockWithPassword|unlockWithPassword(kotlin.CharArray){}[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 verifyPasswordAuth(kotlin/CharArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.verifyPasswordAuth|verifyPasswordAuth(kotlin.CharArray;kotlin.Boolean){}[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] @@ -833,6 +882,7 @@ final class com.pasich.encly.core.security/SecurityManager { // com.pasich.encly 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 changePassword(kotlin/CharArray): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/SecurityManager.changePassword|changePassword(kotlin.CharArray){}[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] @@ -859,18 +909,25 @@ final class com.pasich.encly.core.security/SecurityManager { // com.pasich.encly 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 removePassword(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removePassword|removePassword(){}[0] + final fun removePin(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removePin|removePin(){}[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 unlockMethods(): com.pasich.encly.core.security/UnlockMethods // com.pasich.encly.core.security/SecurityManager.unlockMethods|unlockMethods(){}[0] + final fun unlockWithPassword(kotlin/CharArray): com.pasich.encly.core.security/VaultUnlockResult // com.pasich.encly.core.security/SecurityManager.unlockWithPassword|unlockWithPassword(kotlin.CharArray){}[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 verifyPassword(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.verifyPassword|verifyPassword(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] @@ -953,6 +1010,22 @@ final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecyc } } +final class com.pasich.encly.core.security/UnlockMethods { // com.pasich.encly.core.security/UnlockMethods|null[0] + constructor (kotlin/Boolean, kotlin/Boolean) // com.pasich.encly.core.security/UnlockMethods.|(kotlin.Boolean;kotlin.Boolean){}[0] + + final val password // com.pasich.encly.core.security/UnlockMethods.password|{}password[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.password.|(){}[0] + final val pin // com.pasich.encly.core.security/UnlockMethods.pin|{}pin[0] + final fun (): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.pin.|(){}[0] + + final fun component1(): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.component2|component2(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ...): com.pasich.encly.core.security/UnlockMethods // com.pasich.encly.core.security/UnlockMethods.copy|copy(kotlin.Boolean;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/UnlockMethods.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // com.pasich.encly.core.security/UnlockMethods.toString|toString(){}[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] @@ -1143,5 +1216,11 @@ final object com.pasich.encly.core.security/RecoveryWords { // com.pasich.encly. 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/PIN_LENGTH // com.pasich.encly.core.security/PIN_LENGTH|{}PIN_LENGTH[0] - final fun (): kotlin/Int // com.pasich.encly.core.security/PIN_LENGTH.|(){}[0] +final const val com.pasich.encly.core.security/MAX_PASSWORD_LENGTH // com.pasich.encly.core.security/MAX_PASSWORD_LENGTH|{}MAX_PASSWORD_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/MAX_PASSWORD_LENGTH.|(){}[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_PASSWORD_LENGTH // com.pasich.encly.core.security/MIN_PASSWORD_LENGTH|{}MIN_PASSWORD_LENGTH[0] + final fun (): kotlin/Int // com.pasich.encly.core.security/MIN_PASSWORD_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/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt index 58c8534a..83aec7a0 100644 --- a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt @@ -24,7 +24,17 @@ import dev.zacsweers.metro.SingleIn import kotlinx.atomicfu.locks.SynchronizedObject import kotlinx.atomicfu.locks.synchronized -public const val PIN_LENGTH: Int = 6 +/** The shortest PIN: the lockout and the device-bound key make 6 digits the floor. */ +public const val MIN_PIN_LENGTH: Int = 6 + +/** The longest PIN. */ +public const val MAX_PIN_LENGTH: Int = 12 + +/** The fewest characters a password has (Unicode code points, after NFC normalisation). */ +public const val MIN_PASSWORD_LENGTH: Int = Passwords.MIN_LENGTH + +/** The most characters a password has. */ +public const val MAX_PASSWORD_LENGTH: Int = Passwords.MAX_LENGTH public enum class AuthType { NONE, @@ -87,6 +97,26 @@ public enum class WipePinChange { FAILED, } +/** The outcome of setting the vault's password from Settings. */ +public enum class PasswordChange { + SET, + + /** Shorter than [MIN_PASSWORD_LENGTH] or longer than [MAX_PASSWORD_LENGTH] characters. */ + INVALID, + FAILED, +} + +/** + * What a recovery-phrase unlock asks of the user next (see [AuthenticationManager.recoveryFollowUp]). + */ +public enum class RecoveryFollowUp { + /** The PIN (or password) still opens the vault: the user may keep it or set a new PIN. */ + KEEP_OR_CHANGE, + + /** No PIN or password 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. */ public enum class PinChange { SET, @@ -145,12 +175,22 @@ public enum class WipeStage { * same length, so the store has the same keys and sizes either way and only the wipe PIN itself * can tell. Every attempt runs the KDF once and tries both slots, with no early exit. * + * A password (`pin.password.slot`) is a second way to the same DEK, for a user who prefers one to a + * PIN (or has no PIN). It is the same chain over its own salt and domain, with the same hardware key: + * + * hw = HMAC-SHA256_keystore("encly/pw/hw/v1" ‖ salt ‖ utf8(password)) + * sw = PBKDF2-HMAC-SHA256(password, salt, 600 000) + * kek = HKDF-SHA256(ikm = sw ‖ hw, salt = salt, info = "encly/pw/kek/v1") + * + * It has no wipe or duress slot (those stay PIN-only) and shares the PIN's lockout counters: a guess + * at either spends the same attempts. + * * Failed attempts are counted durably *before* the KDF runs, under one lock, so killing the * 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]. */ @SingleIn(AppScope::class) -@Suppress("TooManyFunctions") // The complete PIN slot, wipe slot and lockout lifecycle in one place. +@Suppress("TooManyFunctions", "LargeClass") // PIN, password, wipe, duress and lockout in one place. @Inject public class AuthenticationManager( private val store: VaultStore, @@ -214,7 +254,9 @@ public class AuthenticationManager( keyReset || keptSalt == null -> WipeSlotChange.TURN_OFF else -> WipeSlotChange.KEEP } - val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange)) + // A password is sealed with the same device key: a key made anew cannot open it either. + val dropPassword = keyReset && hasPasswordSlot() + val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange, dropPassword)) if (written) PinChange.SET else PinChange.FAILED } catch (_: CryptoException) { PinChange.FAILED @@ -267,6 +309,176 @@ public class AuthenticationManager( } } + /** + * One password attempt from the lock screen. [password] stays the caller's to wipe. Counted + * and locked out exactly like a PIN attempt (the same counters), and answers with the same + * results: [PinUnlock.Success] (the caller owns and wipes the DEK), [PinUnlock.WrongPin] for a wrong + * password, [PinUnlock.LockedOut], [PinUnlock.KeyLost] and [PinUnlock.Failed]. Never a decoy or an erase: + * the duress and wipe PINs are PIN-only. + */ + public fun unlockWithPassword(password: CharArray): PinUnlock = attemptPassword(password, Purpose.UNLOCK) + + /** + * Whether [password] is the password of the open session's vault; counts like an unlock attempt. + * [password] stays the caller's. In a decoy session ([decoy]) it is never checked against the real + * vault's slot, so that session can not be used to test guesses: the attempt is counted and refused. + */ + public fun verifyPasswordAuth(password: CharArray, decoy: Boolean = false): Boolean { + val result = attemptPassword(password, if (decoy) Purpose.VERIFY_DECOY else Purpose.VERIFY) + if (result is PinUnlock.Success) SensitiveDataCleaner.clear(result.dek) + return result is PinUnlock.Success + } + + /** One counted password attempt; see [unlockWithPassword] and [verifyPasswordAuth]. */ + @Suppress("ReturnCount") // Fail-closed gates: lockout, missing slot, unrecorded attempt, unusable text. + private fun attemptPassword(password: CharArray, purpose: Purpose): PinUnlock = synchronized(attemptLock) { + if (remainingLockoutMillis() > 0) return PinUnlock.LockedOut + val slot = store.getBytes(PASSWORD_SLOT_KEY) ?: return PinUnlock.WrongPin + try { + val previous = Misses(store.getInt(FAILURES_KEY, 0), store.getInt(STRIKES_KEY, 0)) + // Counted before the KDF, like a PIN attempt. + if (!chargeAttempt(previous.next())) return PinUnlock.Failed + if (purpose == Purpose.VERIFY_DECOY) return PinUnlock.WrongPin + val normalized = Passwords.normalize(password) + try { + if (!Passwords.isPlausible(normalized)) return PinUnlock.WrongPin + val parsed = PasswordSlots.parseSlot(slot) ?: return PinUnlock.WrongPin + try { + openPasswordSlot(normalized, parsed, previous) + } finally { + parsed.wipe() + } + } finally { + SensitiveDataCleaner.clear(normalized) + } + } finally { + SensitiveDataCleaner.clear(slot) + } + } + + @Suppress("ReturnCount") // Fail-closed: a key that never answered, a wrong password, a right one. + private fun openPasswordSlot(normalized: CharArray, parsed: ParsedSlot, previous: Misses): PinUnlock { + val kek = try { + derivePasswordKek(normalized, parsed.salt, activeKeySlot()) + } catch (e: PinFactorException) { + // Not a guess: the hardware never answered. Give the attempt back. + refundAttempt(previous) + return if (e.lost) PinUnlock.KeyLost else PinUnlock.Failed + } + return try { + val dek = PasswordSlots.openSlotOrNull(parsed, kek) ?: return PinUnlock.WrongPin + // The right password ends the lockout and clears the misses, as the right PIN does. + store.edit { + removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) + } + PinUnlock.Success(dek) + } finally { + SensitiveDataCleaner.clear(kek) + } + } + + /** + * Creates (or replaces) the password slot around [dek]; [password] stays the caller's to wipe. + * Refused with [PasswordChange.INVALID] when it is not [MIN_PASSWORD_LENGTH]..[MAX_PASSWORD_LENGTH] + * characters. A new salt every time, the same device key as the PIN. When that key had to be made anew + * (it was lost) a PIN slot sealed under the old one can never open again: it is dropped in the same write, + * with the wipe and duress PINs that hung on it. + */ + @Suppress("ReturnCount", "CyclomaticComplexMethod") // Validation gates fail closed before any key is touched. + public fun changePassword(password: CharArray, dek: ByteArray): PasswordChange = synchronized(attemptLock) { + if (dek.size != DEK_LENGTH) return PasswordChange.FAILED + val normalized = Passwords.normalize(password) + val salt = Crypto.randomBytes(PasswordSlots.SALT_SIZE) + var keyReset = false + var kek: ByteArray? = null + try { + if (!Passwords.isAcceptable(normalized)) return PasswordChange.INVALID + val keySlot = activeKeySlot() + kek = try { + keyReset = factor.ensureKey(keySlot) + try { + derivePasswordKek(normalized, salt, keySlot) + } catch (e: PinFactorException) { + // A key the system invalidated can be replaced: what it served is lost anyway. + if (!e.lost) throw e + factor.reset(keySlot) + keyReset = true + derivePasswordKek(normalized, salt, keySlot) + } + } catch (_: PinFactorException) { + return PasswordChange.FAILED + } + val slot = PasswordSlots.sealSlot(dek, kek, salt) + val retired = if (keyReset && hasPinSlot()) retirePin(dek) else null + val written = try { + store.edit { + putBytes(PASSWORD_SLOT_KEY, slot) + remove(PASSWORD_NOTICE_KEY) + retired?.applyTo(this) + removePrefix(LOCKOUT_PREFIX) + remove(STRIKES_KEY) + } + } finally { + SensitiveDataCleaner.clear(slot) + retired?.wipe() + } + return if (written) PasswordChange.SET else PasswordChange.FAILED + } catch (_: CryptoException) { + return PasswordChange.FAILED + } finally { + SensitiveDataCleaner.clear(normalized) + SensitiveDataCleaner.clear(salt) + kek?.let(SensitiveDataCleaner::clear) + } + } + + /** + * Turns the password off. The last way to unlock stays: refused (false) while there is no PIN slot. + * The caller has checked who is asking (the vault is open and the PIN was just confirmed). + */ + public fun removePassword(): Boolean = synchronized(attemptLock) { + if (!hasPinSlot()) return false + store.edit { + remove(PASSWORD_SLOT_KEY) + remove(PASSWORD_NOTICE_KEY) + } + } + + /** + * Turns the PIN off, leaving the password as the way in: the PIN slot goes, and the wipe slot and the + * duress slot, which only the PIN can open, become random bytes again (the decoy vault keeps its notes and its + * duress PIN can be set again, as after a PIN key reset). Refused (false) while there is no password: + * the last way to unlock stays. [dek] is the open session's own key. + */ + @Suppress("ReturnCount") + public fun removePin(dek: ByteArray): Boolean = synchronized(attemptLock) { + if (dek.size != DEK_LENGTH || !hasPasswordSlot()) return false + if (!hasPinSlot()) return true + val retired = retirePin(dek) + try { + store.edit { retired.applyTo(this) } + } finally { + retired.wipe() + } + } + + /** What a recovery-phrase unlock asks of the user next; see [RecoveryFollowUp]. */ + public fun recoveryFollowUp(): RecoveryFollowUp = synchronized(attemptLock) { + val usable = (hasPinSlot() || hasPasswordSlot()) && + 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) { @@ -514,6 +726,15 @@ public class AuthenticationManager( public fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY) + /** A password slot exists (whether it can still open is only known by trying). */ + public fun hasPasswordSlot(): Boolean = store.contains(PASSWORD_SLOT_KEY) + + /** + * True after the device key had to be reset (see [changePin]): the password stopped working and was + * dropped. Settings says so until the password is set again or removed. + */ + public fun passwordTurnedOff(): Boolean = store.getBoolean(PASSWORD_NOTICE_KEY, false) + public fun getAuthType(): AuthType { val ordinal = store.getInt(AUTH_TYPE_KEY, AuthType.NONE.ordinal) return AuthType.entries.getOrNull(ordinal) ?: AuthType.NONE @@ -525,7 +746,7 @@ public class AuthenticationManager( AuthType.NONE -> AuthStrategy.NONE AuthType.PIN -> when { - !hasPinSlot() -> AuthStrategy.RECOVERY_DATA + !hasPinSlot() && !hasPasswordSlot() -> AuthStrategy.RECOVERY_DATA biometric -> AuthStrategy.PIN_BIOMETRIC else -> AuthStrategy.PIN } @@ -675,6 +896,9 @@ public class AuthenticationManager( putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) remove(WIPE_NOTICE_KEY) + // The password opens the erased vault's DEK: it goes with the rest. + remove(PASSWORD_SLOT_KEY) + remove(PASSWORD_NOTICE_KEY) remove(BIOMETRIC_ENABLED_KEY) removePrefix(LOCKOUT_PREFIX) remove(STRIKES_KEY) @@ -723,7 +947,7 @@ public class AuthenticationManager( keySlot: PinKeySlot, changes: SlotChanges, ): Boolean { - val (change, decoyChange) = changes + val (change, decoyChange, dropPassword) = changes val slot = sealSlot(dek, kek, salt) val decoy = if (change == WipeSlotChange.KEEP) null else decoyWipeSlot() val duressSlot = if (decoyChange == WipeSlotChange.KEEP) null else randomSlot() @@ -740,6 +964,10 @@ public class AuthenticationManager( putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(AUTH_TYPE_KEY, AuthType.PIN.ordinal) if (change == WipeSlotChange.TURN_OFF_AND_TELL) putBoolean(WIPE_NOTICE_KEY, true) + if (dropPassword) { + remove(PASSWORD_SLOT_KEY) + putBoolean(PASSWORD_NOTICE_KEY, true) + } removePrefix(LOCKOUT_PREFIX) remove(STRIKES_KEY) } @@ -855,7 +1083,8 @@ public class AuthenticationManager( } } - 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 @@ -904,6 +1133,56 @@ public class AuthenticationManager( private fun pbkdf2(pin: CharArray, salt: ByteArray): ByteArray = Crypto.pbkdf2HmacSha256(pin, salt, PBKDF2_ITERATIONS, DEK_LENGTH) + /** + * The KEK of the password slot. [PinFactorException] leaves nothing behind; the result is the caller's + * to wipe. [password] is already NFC-normalised. + */ + private fun derivePasswordKek(password: CharArray, salt: ByteArray, keySlot: PinKeySlot): ByteArray { + // The hardware half first, as for the PIN: a lost key fails before the PBKDF2 cost. + val utf8 = Utf8.encode(password) + val macInput = PasswordSlots.HARDWARE_TAG + salt + utf8 + val hardware = try { + factor.mac(keySlot, macInput) + } finally { + SensitiveDataCleaner.clear(utf8) + SensitiveDataCleaner.clear(macInput) + } + val stretched = pbkdf2(password, salt) + val ikm = stretched + hardware + return try { + Hkdf.sha256(ikm = ikm, salt = salt, info = PasswordSlots.KEK_INFO, length = DEK_LENGTH) + } finally { + SensitiveDataCleaner.clear(hardware) + SensitiveDataCleaner.clear(stretched) + SensitiveDataCleaner.clear(ikm) + } + } + + /** + * What drops the PIN slot: the slot itself, and the wipe slot and duress slot (only the PIN opens them) as + * random bytes again, with the decoy vault's key sealed as turned off so a new duress PIN opens the same notes. + * [dek] is the real vault's. + */ + private fun retirePin(dek: ByteArray): RetiredPin = + RetiredPin(wipeSlot = decoyWipeSlot(), duressSlot = randomSlot(), decoyKey = turnedOffDecoyKey(dek)) + + /** The entries a dropped PIN slot is replaced with; [applyTo] writes them into one store edit. */ + private class RetiredPin(val wipeSlot: ByteArray, val duressSlot: ByteArray, val decoyKey: ByteArray) { + fun applyTo(editor: VaultStore.Editor) { + editor.remove(PIN_SLOT_KEY) + editor.putBytes(WIPE_SLOT_KEY, wipeSlot) + editor.putBytes(DECOY_SLOT_KEY, duressSlot) + editor.putBytes(DECOY_KEY_KEY, decoyKey) + editor.remove(WIPE_NOTICE_KEY) + } + + fun wipe() { + SensitiveDataCleaner.clear(wipeSlot) + SensitiveDataCleaner.clear(duressSlot) + SensitiveDataCleaner.clear(decoyKey) + } + } + /** Whether [decoyKek] opens the duress slot; the DEK it holds is wiped at once. */ private fun opensDecoySlot(decoyKek: ByteArray, salt: ByteArray): Boolean { val dek = openDecoySlotOrNull(decoyKek, salt) ?: return false @@ -1006,13 +1285,27 @@ public class AuthenticationManager( private enum class WipeSlotChange { KEEP, TURN_OFF, TURN_OFF_AND_TELL } /** What a new PIN slot does to the wipe slot and to the duress slot. */ - private data class SlotChanges(val wipe: WipeSlotChange, val decoy: WipeSlotChange) + private data class SlotChanges( + val wipe: WipeSlotChange, + val decoy: WipeSlotChange, + /** The password slot is dropped: the device key it was sealed with is gone. */ + val dropPassword: Boolean = false, + ) 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" + + /** + * The password slot. Under `pin.` on purpose: an older build that wipes the PIN state drops it too, so no + * slot is left behind for a vault it re-created. + */ + private const val PASSWORD_SLOT_KEY = "pin.password.slot" + + /** Set when the device key was reset and the password with it; cleared by setting or removing the password. */ + private const val 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" @@ -1044,6 +1337,9 @@ public class AuthenticationManager( private const val PBKDF2_ITERATIONS = 600_000 + /** 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() diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt new file mode 100644 index 00000000..ed2cf575 --- /dev/null +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt @@ -0,0 +1,90 @@ +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.ParsedSlot + +/** + * The byte format of the password slot (`pin.password.slot`): `version ‖ salt ‖ iv ‖ AES-GCM(DEK)`, + * the same shape and size as the PIN slot, with a version, an AAD and a KDF domain of its own so + * that no key or ciphertext of the PIN is ever valid here. The KDF chain is the PIN's (PBKDF2 600 000, + * the device-bound HMAC, HKDF); see [AuthenticationManager]. Pure functions. + * + * An older build never reads this entry: it does not know the key, so it ignores it (and drops it + * with every other `pin.` entry when it wipes the PIN state). + */ +internal object PasswordSlots { + const val SALT_SIZE = 16 + const val SLOT_VERSION: Byte = 1 + private const val SLOT_LENGTH = 1 + SALT_SIZE + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES + + private val SLOT_AAD = "encly/pw/slot/v1".encodeToByteArray() + + /** HKDF info of the password KEK. */ + val KEK_INFO = "encly/pw/kek/v1".encodeToByteArray() + + /** Prefix of the device-bound HMAC input (`tag ‖ salt ‖ UTF-8 password`): the PIN's input has none. */ + val HARDWARE_TAG = "encly/pw/hw/v1".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 + SALT_SIZE) + offset += 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 password). 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 = SLOT_AAD + byteArrayOf(SLOT_VERSION) + salt +} + +/** + * The rules for a password: Unicode, NFC-normalised (so the same word typed on another keyboard or + * platform is the same bytes), at least [MIN_LENGTH] and at most [MAX_LENGTH] characters (code points). + */ +internal object Passwords { + const val MIN_LENGTH = 8 + const val MAX_LENGTH = 128 + + /** Every character below this is its own NFC form and cannot combine with the one before it. */ + private const val FIRST_COMBINING = 0x300 + + /** + * [password] in NFC, in a fresh array; the caller wipes both. Plain Latin text needs no + * normalising and no String is made; other text is normalised by the platform, which cannot + * promise to wipe what it made on the way. + */ + fun normalize(password: CharArray): CharArray = + if (password.all { it.code < FIRST_COMBINING }) password.copyOf() else normalizeNfc(password) + + /** Whether [normalized] has an acceptable number of characters to be set as the password. */ + fun isAcceptable(normalized: CharArray): Boolean = codePoints(normalized) in MIN_LENGTH..MAX_LENGTH + + /** Whether [normalized] is worth a key derivation when unlocking: not empty, not absurdly long. */ + fun isPlausible(normalized: CharArray): Boolean = normalized.isNotEmpty() && normalized.size <= MAX_LENGTH * 2 + + /** Characters as Unicode code points: a surrogate pair is one. */ + fun codePoints(chars: CharArray): Int = chars.count { !it.isLowSurrogate() } +} + +/** [text] in Unicode normalization form C. */ +internal expect fun normalizeNfc(text: CharArray): CharArray 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 index f0261e35..cbbfbd3d 100644 --- 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 @@ -87,11 +87,10 @@ public class SecurityManager( // The vault the last session of this process opened, so a switch moves [eraseEpoch]. private var lastVault: VaultKind? = 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. + // 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 sessionUnlockedWithRecovery = false + 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 @@ -156,7 +155,7 @@ public class SecurityManager( // 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()) { + if (!hasUnlockSlot() && !seedPhraseManager.hasRecoverySeed()) { return InitialStatus.LOSS_CRYPTO } return InitialStatus.AUTH @@ -186,10 +185,13 @@ public class SecurityManager( */ private fun isCommittedWithoutFlag(): Boolean = !seedPhraseManager.hasBootstrapKey() && seedPhraseManager.hasStoredSeed() && - (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) && + (hasUnlockSlot() || 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) + /** A PIN or a password slot exists: something other than the recovery phrase can open the vault. */ + private fun hasUnlockSlot(): Boolean = authenticationManager.hasPinSlot() || authenticationManager.hasPasswordSlot() + /** True when first-run setup may (re)create the vault without destroying user data. */ private fun isSafeToCreateVault(): Boolean = !vaultStore.isUnreadable() && !isOnboardingShown() && @@ -238,16 +240,39 @@ public class SecurityManager( dek?.let(SensitiveDataCleaner::clear) SensitiveDataCleaner.clear(pin) } - if (change == PinChange.SET) sessionUnlockedWithRecovery = false + 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 or the vault locks. + * phrase unlocked (the PIN was forgotten), until a new PIN is set, kept ([keepUnlockMethodsAfterRecovery]) + * or the vault locks. */ public fun canResetPinWithoutCurrent(): Boolean = - sessionUnlockedWithRecovery && sessionDek != null && !isDecoySession() + recoveryStage != RecoveryStage.NONE && sessionDek != null && !isDecoySession() + + /** + * What the session unlocked with the recovery phrase still has to decide: [RecoveryStage.MUST_SET_PIN] + * when no PIN or password can be used any more (missing, lost key, locked out), [RecoveryStage.CHOOSE] when + * they 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 @@ -301,10 +326,102 @@ public class SecurityManager( } finally { SensitiveDataCleaner.clear(dek) } - sessionUnlockedWithRecovery = opened + if (opened) { + // The unlock itself did not touch the PIN, the password 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 } + /** + * One password attempt from the lock screen; [password] is wiped. Counted and locked out together with the + * PIN, and answered like it ([VaultUnlockResult.PIN_KEY_LOST]: the device-bound key is gone, which takes the + * password with it). There is no wipe or duress password: those stay PIN-only. + */ + public fun unlockWithPassword(password: CharArray): VaultUnlockResult { + val attempt = try { + authenticationManager.unlockWithPassword(password) + } finally { + SensitiveDataCleaner.clear(password) + } + return when (attempt) { + is PinUnlock.Success -> try { + if (unlockWithRawKey(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 + + // The password slot never yields these; refused all the same. + is PinUnlock.Erased -> { + SensitiveDataCleaner.clear(attempt.dek) + VaultUnlockResult.DB_ERROR + } + + is PinUnlock.Decoy -> { + SensitiveDataCleaner.clear(attempt.dek) + VaultUnlockResult.DB_ERROR + } + + PinUnlock.Failed -> VaultUnlockResult.DB_ERROR + } + } + + /** + * Re-authentication with the password of the open vault; counts like an unlock attempt. [password] is + * wiped. Never true in a decoy session. + */ + public fun verifyPassword(password: CharArray): Boolean = try { + authenticationManager.verifyPasswordAuth(password, decoy = isDecoySession()) + } finally { + SensitiveDataCleaner.clear(password) + } + + /** + * Sets (or replaces) the password around the live DEK; [password] is wiped. Never in a decoy session: the + * slot holds the real vault's DEK. The caller checked who is asking. + */ + public fun changePassword(password: CharArray): PasswordChange { + val dek = primarySessionKeyCopy() + return try { + if (dek == null) PasswordChange.FAILED else authenticationManager.changePassword(password, dek) + } finally { + dek?.let(SensitiveDataCleaner::clear) + SensitiveDataCleaner.clear(password) + } + } + + /** Turns the password off; false while it is the only way in. Only in a session of the real vault. */ + public fun removePassword(): Boolean = primarySessionOpen() && authenticationManager.removePassword() + + /** + * Turns the PIN off, which also turns off the wipe PIN and the duress PIN; false while it is the only way in. + * Only in a session of the real vault. + */ + public fun removePin(): Boolean { + val dek = primarySessionKeyCopy() ?: return false + return try { + authenticationManager.removePin(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } + } + + /** Which of the PIN and the password the lock screen and re-authentication can ask for. */ + public fun unlockMethods(): UnlockMethods = UnlockMethods( + pin = authenticationManager.hasPinSlot(), + password = authenticationManager.hasPasswordSlot(), + ) + public fun requestBiometricKey(host: BiometricHost, onResult: (ByteArray?) -> Unit) { biometricManager.unlock(host, onResult) } @@ -626,7 +743,7 @@ public class SecurityManager( * background (SessionLockManager.onUnlocked) or fails. */ public fun isLockable(): Boolean = (isOnboardingShown() || isCommittedWithoutFlag()) && - (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) + (hasUnlockSlot() || seedPhraseManager.hasRecoverySeed()) public fun isDatabaseUnlocked(): Boolean = vaultDatabase.isDatabaseUnlocked() @@ -692,7 +809,7 @@ public class SecurityManager( sessionDek?.let(SensitiveDataCleaner::clear) sessionDek = null sessionVault = null - sessionUnlockedWithRecovery = false + recoveryStage = RecoveryStage.NONE } /** @@ -718,6 +835,8 @@ public class SecurityManager( isBiometricEnabled = isBiometricEnabled(), isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), hasPinSlot = authenticationManager.hasPinSlot(), + hasPassword = authenticationManager.hasPasswordSlot(), + passwordTurnedOff = authenticationManager.passwordTurnedOff(), wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), hasDuressPin = hasDuressPin(), duressPinTurnedOff = duressPinTurnedOff(), @@ -733,9 +852,28 @@ public data class AuthSettings( val isBiometricEnabled: Boolean, val isUserCreatedSeedKey: Boolean, val hasPinSlot: Boolean = authType == AuthType.PIN, + /** A password is set as another way in (or the only one). */ + val hasPassword: Boolean = false, + /** The device key was reset and the password with it; setting it again brings it back. */ + val passwordTurnedOff: Boolean = false, 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 (or password) still serves: keep it, or set a new PIN. */ + CHOOSE, + + /** No PIN or password can be used (missing, lost key, locked out): a new PIN is set before going on. */ + MUST_SET_PIN, +} + +/** Which of the two ways to unlock with a secret the vault has set. */ +public data class UnlockMethods(val pin: Boolean, val password: Boolean) 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 index 3d285b06..bf335835 100644 --- 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 @@ -419,6 +419,84 @@ class CryptoGoldenTest { assertEquals(PinUnlock.WrongPin, wrong) } + // --- Password slot --------------------------------------------------------------------- + + @Test + fun theGoldenPasswordSlotOpensWithItsKek() { + val parsed = PasswordSlots.parseSlot(GoldenVectors.passwordSlot)!! + + assertContentEquals(salt, parsed.salt) + assertContentEquals(dek, PasswordSlots.openSlotOrNull(parsed, kek)) + assertNull(PasswordSlots.openSlotOrNull(parsed, wipeKek)) + } + + @Test + fun aSealedPasswordSlotHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { + val slot = PasswordSlots.sealSlot(dek, kek, salt) + + assertEquals(1 + 16 + 12 + 32 + 16, slot.size) + assertEquals(1, slot[0].toInt()) + assertContentEquals(salt, slot.copyOfRange(1, 17)) + val aad = "encly/pw/slot/v1".encodeToByteArray() + byteArrayOf(1) + salt + assertContentEquals(dek, Reference.gcmOpen(kek, slot.copyOfRange(17, 29), aad, slot.copyOfRange(29, slot.size))) + assertFalse(slot.contentEquals(PasswordSlots.sealSlot(dek, kek, salt))) + } + + @Test + fun thePasswordKekIsTheHkdfOfThePbkdf2AndDeviceHalves() { + val derived = Hkdf.sha256( + GoldenVectors.passwordSw + GoldenVectors.passwordHw, + salt, + "encly/pw/kek/v1".encodeToByteArray(), + 32, + ) + + assertContentEquals(GoldenVectors.passwordKek, derived) + val chain = PasswordSlots.parseSlot(GoldenVectors.chainPasswordSlot)!! + assertContentEquals(dek, PasswordSlots.openSlotOrNull(chain, derived)) + } + + @Test + fun aPinSlotAndAPasswordSlotAreNotInterchangeable() { + // Different version byte and size rules: neither parser takes the other's slot. + assertNull(PasswordSlots.parseSlot(GoldenVectors.pinSlot)) + assertNull(PinSlots.parseSlot(GoldenVectors.passwordSlot)) + // Even with the version byte changed the AAD keeps them apart. + val relabelled = GoldenVectors.pinSlot.copyOf().also { it[0] = 1 } + assertNull(PasswordSlots.openSlotOrNull(PasswordSlots.parseSlot(relabelled)!!, kek)) + val relabelledBack = GoldenVectors.passwordSlot.copyOf().also { it[0] = 3 } + assertNull(PinSlots.openSlotOrNull(PinSlots.parseSlot(relabelledBack)!!, kek)) + } + + @Test + fun theGoldenPasswordSlotsUnlockThroughTheFullKdfChain() { + val store = VaultStore(InMemoryVaultFileIo()) + store.edit { putBytes("pin.password.slot", GoldenVectors.chainPasswordSlot) } + val manager = AuthenticationManager(store, FixedKeyFactor(hardwareKey), FakeLockoutClock()) + + val composed = manager.unlockWithPassword(UNICODE_PASSWORD.toCharArray()) + // The same word typed with combining marks is the same password (NFC). + val decomposed = manager.unlockWithPassword(UNICODE_PASSWORD_DECOMPOSED.toCharArray()) + val wrong = manager.unlockWithPassword("P\u00e4ssw\u00f6rd".toCharArray()) + + assertContentEquals(dek, (composed as PinUnlock.Success).dek) + assertContentEquals(dek, (decomposed as PinUnlock.Success).dek) + assertEquals(PinUnlock.WrongPin, wrong) + } + + @Test + fun theGoldenAsciiPasswordSlotUnlocksThroughTheFullKdfChain() { + val store = VaultStore(InMemoryVaultFileIo()) + store.edit { putBytes("pin.password.slot", GoldenVectors.chainAsciiPasswordSlot) } + val manager = AuthenticationManager(store, FixedKeyFactor(hardwareKey), FakeLockoutClock()) + + val opened = manager.unlockWithPassword("correct horse battery".toCharArray()) + val wrong = manager.unlockWithPassword("correct horse batterz".toCharArray()) + + assertContentEquals(dek, (opened as PinUnlock.Success).dek) + assertEquals(PinUnlock.WrongPin, wrong) + } + private class FixedKeyFactor(private val key: ByteArray) : PinHardwareFactor { override fun ensureKey(slot: PinKeySlot) = false @@ -474,5 +552,9 @@ class CryptoGoldenTest { 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" + + // "Pässwörd-日本語-🔑" as typed by a keyboard that composes, and as one that sends combining marks. + const val UNICODE_PASSWORD = "P\u00e4ssw\u00f6rd-\u65e5\u672c\u8a9e-\uD83D\uDD11" + const val UNICODE_PASSWORD_DECOMPOSED = "Pa\u0308sswo\u0308rd-\u65e5\u672c\u8a9e-\uD83D\uDD11" } } 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 index 6e2b18a5..7fa76661 100644 --- 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 @@ -118,6 +118,30 @@ internal object GoldenVectors { "fddb368becdab848194f27ec96", ) + // The password slot (see PasswordSlots): fixed dek, kek, salt and IV as for the PIN slots above, computed with + // an AES-GCM, PBKDF2 and HMAC implementation independent of the code under test (Python hashlib and + // cryptography). + val passwordSlot = hex( + "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b58a9c03c53e5da37e57c6d6bd7b79ab45c8f05" + + "ff14a64ca347f3ca7ad54a0125c34d650c3e67ff1019c1abfb5f0f8f7b", + ) + + val passwordSw = hex("beb69746d4c0f5df40e06d7c6fdb64dfc27229d97f99808b5817a528cbf8034a") + + val passwordHw = hex("d3599244ef93353258585089592976a8f93f022b3cdf5e1c26900fcbf1b05ee1") + + val passwordKek = hex("3926a89e5f1e1cd24745f2365ba2dd04fc30b9aa3d433b3c7e4e7fa3f868d68d") + + val chainPasswordSlot = hex( + "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b306ca670770005af0f1c30434a1ac2e0ecacee" + + "6b45033093ff27067dd954beee55ae71e3431a4f21fccb71deec798d5b", + ) + + val chainAsciiPasswordSlot = hex( + "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b3baaef977c1b97622046cd2a9c37769b46b8c5" + + "55b746be4f57beb35043b440e20b7ba0d974f2ff95605c304986d5aa9b", + ) + val storeFile = hex( "454e434c595653330000000200016100000003010203000262630000000058f24fb12b5c0a411161cd12ebdb9fdbf492" + "19ef0aee2e78afa6e5562e370519", 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 index 9e2a3340..219779fb 100644 --- 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 @@ -11,8 +11,8 @@ import kotlin.test.assertTrue * 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. + * the files in [JVM_FILES], which both JVM targets share and which are the only source files that may + * name `java.io` or `java.text`. The module may depend on `:core:common` and `:core:crypto` only. */ class SecurityBoundaryTest { // Tests run with the module directory as the working directory. @@ -59,7 +59,7 @@ class SecurityBoundaryTest { } @Test - fun onlyTwoJvmFilesTouchTheJvm() { + fun onlyTheListedJvmFilesTouchTheJvm() { 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() @@ -133,6 +133,7 @@ class SecurityBoundaryTest { val JVM_FILES = mapOf( "JvmVaultFileIo.kt" to "java.io and java.nio: the vault file's fsync and atomic replace", "JvmJsonBytes.kt" to "java.io byte-array streams so the plaintext payload can be wiped", + "JvmNormalizer.kt" to "java.text.Normalizer: NFC of a password has no common Kotlin equivalent", ) } } diff --git a/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt b/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt new file mode 100644 index 00000000..14e57c01 --- /dev/null +++ b/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt @@ -0,0 +1,15 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.pasich.encly.core.security + +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSString +import platform.Foundation.precomposedStringWithCanonicalMapping + +/* + * The iOS side of Passwords.normalize: Foundation's canonical composition. It works on a String, which cannot + * be wiped; Passwords.normalize only gets here for text outside plain Latin. + */ + +internal actual fun normalizeNfc(text: CharArray): CharArray = + (text.concatToString() as NSString).precomposedStringWithCanonicalMapping.toCharArray() diff --git a/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt new file mode 100644 index 00000000..8e86cef2 --- /dev/null +++ b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt @@ -0,0 +1,18 @@ +package com.pasich.encly.core.security + +import java.nio.CharBuffer +import java.text.Normalizer + +/* + * The JVM and Android side of Passwords.normalize. Text that is already in NFC is copied as it is, so + * only text that really changes is turned into a String by the normalizer. + */ + +internal actual fun normalizeNfc(text: CharArray): CharArray { + val view = CharBuffer.wrap(text) + return if (Normalizer.isNormalized(view, Normalizer.Form.NFC)) { + text.copyOf() + } else { + Normalizer.normalize(view, Normalizer.Form.NFC).toCharArray() + } +} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/AuthenticationEdgeCasesTest.kt index 1dd3a98c..f7971802 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/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/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt new file mode 100644 index 00000000..a9d24c43 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt @@ -0,0 +1,472 @@ +package com.pasich.encly.core.security + +import com.pasich.encly.core.security.AuthenticationManager.Companion.MAX_ATTEMPTS +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.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.text.Normalizer + +/** + * The password slot: its own salt and KDF domain over the PIN's chain and device key, the same lockout + * counters, and the rule that one of PIN and password always remains. + */ +class PasswordSlotTest { + private val file = tempVaultFile() + private val store = VaultStore(file) + private val factor = FakePinFactor() + private val clock = FakeLockoutClock() + private val manager = AuthenticationManager(store, factor, clock) + private val dek = ByteArray(32) { (it + 3).toByte() } + + // --- setting a password ------------------------------------------------------------------ + + @Test + fun aPasswordUnlocksTheSameDekAsThePin() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + val byPassword = manager.unlockWithPassword(pw()) + val byPin = manager.unlockWithPin(pin()) + + assertArrayEquals(dek, (byPassword as PinUnlock.Success).dek) + assertArrayEquals(dek, (byPin as PinUnlock.Success).dek) + assertTrue(manager.hasPasswordSlot()) + } + + @Test + fun aVaultWithOnlyAPasswordUnlocksWithIt() { + // The vault is made around a PIN; the PIN is removed once the password stands next to it. + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + assertTrue(manager.removePin(dek)) + + assertFalse(manager.hasPinSlot()) + assertArrayEquals(dek, (manager.unlockWithPassword(pw()) as PinUnlock.Success).dek) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin(pin())) + assertEquals(AuthStrategy.PIN, manager.isAuthStrategy()) + } + + @Test + fun aWrongPasswordNeverUnwrapsTheDekAndIsCounted() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword("wrong password".toCharArray())) + + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw(""))) + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + @Test + fun aPasswordOfAnotherVaultOrWithoutASlotOpensNothing() { + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) + assertEquals(0L, manager.remainingLockoutMillis()) + } + + @Test + fun thePinDoesNotOpenThePasswordSlotAndTheOtherWayAround() { + // Eight to twelve digits are a valid password and a valid PIN alike: each opens only its own slot. + val digits = "12345678" + assertTrue(manager.configurePin(digits.toCharArray(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword("87654321".toCharArray(), dek)) + + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(digits.toCharArray())) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("87654321".toCharArray())) + assertTrue(manager.unlockWithPin(digits.toCharArray()) is PinUnlock.Success) + assertTrue(manager.unlockWithPassword("87654321".toCharArray()) is PinUnlock.Success) + } + + @Test + fun theSlotIsUselessWithoutTheDeviceFactor() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val otherDevice = + AuthenticationManager(VaultStore(file), FakePinFactor().apply { reset(PinKeySlot.A) }, FakeLockoutClock()) + + assertEquals(PinUnlock.WrongPin, otherDevice.unlockWithPassword(pw())) + } + + @Test + fun everyGuessRunsThroughTheDeviceFactorOnce() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val before = factor.calls + + repeat(3) { manager.unlockWithPassword("guess number $it".toCharArray()) } + + assertEquals(before + 3, factor.calls) + } + + @Test + fun aNewPasswordGetsANewSaltAndReplacesTheOldOne() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val first = store.getBytes("pin.password.slot")!! + + assertEquals(PasswordChange.SET, manager.changePassword("another password".toCharArray(), dek)) + val second = store.getBytes("pin.password.slot")!! + + assertEquals(first.size, second.size) + assertNotEquals(first.copyOfRange(1, 17).toList(), second.copyOfRange(1, 17).toList()) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) + assertTrue(manager.unlockWithPassword("another password".toCharArray()) is PinUnlock.Success) + } + + @Test + fun theSlotIsAsLongAsAPinSlotAndSaysItsVersion() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + val slot = store.getBytes("pin.password.slot")!! + + assertEquals(store.getBytes("pin.slot")!!.size, slot.size) + assertEquals(1, slot[0].toInt()) + assertEquals(3, store.getBytes("pin.slot")!![0].toInt()) + } + + // --- what a password is ------------------------------------------------------------------- + + @Test + fun aPasswordNeedsEightToOneHundredTwentyEightCharacters() { + assertEquals(PasswordChange.INVALID, manager.changePassword("1234567".toCharArray(), dek)) + assertEquals(PasswordChange.INVALID, manager.changePassword("".toCharArray(), dek)) + assertEquals(PasswordChange.INVALID, manager.changePassword("x".repeat(129).toCharArray(), dek)) + assertFalse(manager.hasPasswordSlot()) + + assertEquals(PasswordChange.SET, manager.changePassword("x".repeat(8).toCharArray(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword("y".repeat(128).toCharArray(), dek)) + assertTrue(manager.unlockWithPassword("y".repeat(128).toCharArray()) is PinUnlock.Success) + } + + @Test + fun charactersAreCodePointsNotUtf16Units() { + // Four emoji are eight UTF-16 units but four characters: with four more letters they make eight. + val emoji = "🔑" + assertEquals(PasswordChange.INVALID, manager.changePassword((emoji + emoji + emoji + "abc").toCharArray(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword((emoji.repeat(4) + "abcd").toCharArray(), dek)) + assertTrue(manager.unlockWithPassword((emoji.repeat(4) + "abcd").toCharArray()) is PinUnlock.Success) + } + + @Test + fun theSameWordTypedComposedOrDecomposedIsTheSamePassword() { + val composed = "Pässwörd-日本語" + val decomposed = Normalizer.normalize(composed, Normalizer.Form.NFD) + assertNotEquals(composed.length, decomposed.length) + assertEquals(PasswordChange.SET, manager.changePassword(decomposed.toCharArray(), dek)) + + assertTrue(manager.unlockWithPassword(composed.toCharArray()) is PinUnlock.Success) + assertTrue(manager.unlockWithPassword(decomposed.toCharArray()) is PinUnlock.Success) + assertTrue(manager.verifyPasswordAuth(composed.toCharArray())) + } + + @Test + fun theCallersPasswordIsLeftForTheCallerToWipe() { + val typed = pw() + assertEquals(PasswordChange.SET, manager.changePassword(typed, dek)) + manager.unlockWithPassword(typed) + + assertArrayEquals(pw(), typed) + } + + @Test + fun aPasswordNeedsAFullLengthKey() { + assertEquals(PasswordChange.FAILED, manager.changePassword(pw(), ByteArray(31))) + assertFalse(manager.hasPasswordSlot()) + } + + // --- lockout, shared with the PIN ----------------------------------------------------------- + + @Test + fun wrongPinsAndWrongPasswordsCountTowardsTheSameLockout() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + repeat(MAX_ATTEMPTS - 2) { manager.unlockWithPin("000000".toCharArray()) } + assertEquals(0L, manager.remainingLockoutMillis()) + repeat(2) { manager.unlockWithPassword("not the password".toCharArray()) } + + assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) + assertEquals(PinUnlock.LockedOut, manager.unlockWithPin(pin())) + assertEquals(PinUnlock.LockedOut, manager.unlockWithPassword(pw())) + assertFalse(manager.verifyPasswordAuth(pw())) + } + + @Test + fun aRunningLockoutRefusesTheRightPasswordWithoutCountingIt() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + repeat(MAX_ATTEMPTS) { manager.unlockWithPassword("wrong wrong".toCharArray()) } + + assertEquals(PinUnlock.LockedOut, manager.unlockWithPassword(pw())) + + assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) + } + + @Test + fun theRightPasswordEndsTheMissesLikeTheRightPin() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + repeat(MAX_ATTEMPTS - 1) { manager.unlockWithPin("000000".toCharArray()) } + + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + + repeat(MAX_ATTEMPTS - 1) { manager.unlockWithPassword("wrong wrong".toCharArray()) } + assertEquals(0L, manager.remainingLockoutMillis()) + } + + @Test + fun aLostDeviceKeyIsReportedAndNotCountedAsAGuess() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + factor.lost = true + + repeat(MAX_ATTEMPTS + 2) { assertEquals(PinUnlock.KeyLost, manager.unlockWithPassword(pw())) } + + assertEquals(0L, manager.remainingLockoutMillis()) + } + + @Test + fun aTransientKeystoreFailureIsNotCountedEither() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + factor.failing = true + + repeat(MAX_ATTEMPTS + 2) { assertEquals(PinUnlock.Failed, manager.unlockWithPassword(pw())) } + + factor.failing = false + assertEquals(0L, manager.remainingLockoutMillis()) + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + // --- re-authentication ---------------------------------------------------------------------- + + @Test + fun verifyPasswordAuthIsTrueOnlyForTheRightPassword() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertTrue(manager.verifyPasswordAuth(pw())) + assertFalse(manager.verifyPasswordAuth("wrong password".toCharArray())) + } + + @Test + fun aDecoySessionNeverTestsTheRealPasswordButTheAttemptIsCounted() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertFalse(manager.verifyPasswordAuth(pw(), decoy = true)) + + repeat(MAX_ATTEMPTS - 1) { manager.verifyPasswordAuth(pw(), decoy = true) } + assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) + } + + // --- one of PIN and password always remains --------------------------------------------------- + + @Test + fun thePasswordCannotBeRemovedWhileItIsTheOnlyWayIn() { + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertFalse(manager.removePassword()) + + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + @Test + fun thePasswordCanBeRemovedWhileThePinStands() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertTrue(manager.removePassword()) + + assertFalse(manager.hasPasswordSlot()) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) + assertTrue(manager.unlockWithPin(pin()) is PinUnlock.Success) + } + + @Test + fun thePinCannotBeRemovedWithoutAPassword() { + assertTrue(manager.configurePin(pin(), dek)) + + assertFalse(manager.removePin(dek)) + + assertTrue(manager.hasPinSlot()) + assertTrue(manager.unlockWithPin(pin()) is PinUnlock.Success) + } + + @Test + fun removingThePinTurnsOffTheWipeAndDuressPinsAndKeepsTheShapeOfTheStore() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) + assertEquals( + DuressPinChange.SET_NEW_VAULT, + manager.configureDuressPin("246810".toCharArray(), dek) { true }, + ) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val before = store.keys() - "pin.password.slot" + val sizes = before.associateWith { store.getBytes(it)!!.size } + + assertTrue(manager.removePin(dek)) + + assertFalse(manager.hasPinSlot()) + assertEquals(before - "pin.slot", store.keys() - "pin.password.slot") + for (key in before - "pin.slot") assertEquals(key, sizes[key], store.getBytes(key)!!.size) + // The decoy vault keeps its notes: its duress PIN is turned off, not forgotten. + assertTrue(manager.hasDecoyVault(dek)) + assertTrue(manager.duressPinTurnedOff(dek)) + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + @Test + fun aPinAddedAgainStartsAsAFirstPinWithoutAWipeOrDuressPin() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + assertTrue(manager.removePin(dek)) + + assertTrue(manager.configurePin("246810".toCharArray(), dek)) + + // The old wipe PIN opens nothing: it was bound to the salt of the PIN slot that is gone. + assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("987654".toCharArray())) + assertTrue(manager.unlockWithPin("246810".toCharArray()) is PinUnlock.Success) + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + // --- the device key ------------------------------------------------------------------------- + + @Test + fun aNewPinAfterTheDeviceKeyWasResetDropsThePasswordWithANotice() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + factor.lost = true + + assertTrue(manager.configurePin("246810".toCharArray(), dek)) + + assertFalse(manager.hasPasswordSlot()) + assertTrue(manager.passwordTurnedOff()) + assertTrue(manager.unlockWithPin("246810".toCharArray()) is PinUnlock.Success) + // Setting the password again clears the notice. + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + assertFalse(manager.passwordTurnedOff()) + } + + @Test + fun aPasswordSetAfterTheDeviceKeyWasResetDropsThePinSlotThatCanNeverOpen() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) + factor.lost = true + + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + assertFalse(manager.hasPinSlot()) + assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) + } + + @Test + fun aWipePinEraseDropsThePasswordWithTheRestOfTheOldVault() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + val erased = manager.unlockWithPin("987654".toCharArray()) + + assertTrue(erased is PinUnlock.Erased) + assertFalse(manager.hasPasswordSlot()) + assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) + } + + @Test + fun wipeDropsThePasswordAndTheStateAnOlderBuildWouldLeaveAlone() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + manager.wipe() + + assertFalse(manager.hasPasswordSlot()) + assertFalse(manager.hasPinSlot()) + } + + @Test + fun theStoreFileWithAPasswordSlotIsStillTheFormatAnOlderBuildReads() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val bytes = file.readBytes() + + // Read it the way the earliest build did: java.io, with the slot entry as one more key it does not know. + val input = DataInputStream(ByteArrayInputStream(bytes, 0, bytes.size - 32)) + val magic = ByteArray(8).also { input.readFully(it) } + val entries = (0 until input.readInt()).associate { + val key = input.readUTF() + key to ByteArray(input.readInt()).also { value -> input.readFully(value) } + } + + assertEquals("ENCLYVS3", String(magic, Charsets.US_ASCII)) + assertEquals(0, input.available()) + assertTrue("pin.password.slot" in entries) + assertArrayEquals(store.getBytes("pin.slot"), entries["pin.slot"]) + } + + @Test + fun anOlderBuildThatDropsThePinStateDropsThePasswordSlotWithIt() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + + // What the earlier AuthenticationManager.wipe() did to the store. + assertTrue(store.edit { removePrefix("pin.") }) + + assertFalse(manager.hasPasswordSlot()) + assertFalse(manager.hasPinSlot()) + } + + @Test + fun theMigrationOfOlderVaultsLeavesThePasswordSlotAlone() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + val slot = store.getBytes("pin.password.slot")!! + + assertTrue(manager.ensureWipeSlot()) + + assertArrayEquals(slot, store.getBytes("pin.password.slot")) + } + + // --- after the recovery phrase ---------------------------------------------------------------- + + @Test + fun afterRecoveryAWorkingPinOrPasswordMayBeKept() { + assertTrue(manager.configurePin(pin(), dek)) + assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) + + assertFalse(manager.removePin(dek)) + assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) + assertTrue(manager.removePin(dek)) + assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) + } + + @Test + fun afterRecoveryANewPinIsRequiredWhenNothingCanBeUsed() { + assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) + } + + @Test + fun afterRecoveryANewPinIsRequiredWhenTheDeviceKeyIsLostOrALockoutRuns() { + assertTrue(manager.configurePin(pin(), dek)) + factor.lost = true + assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) + + factor.lost = false + factor.reset(PinKeySlot.A) + assertTrue(manager.configurePin(pin(), dek)) + repeat(MAX_ATTEMPTS) { manager.unlockWithPin("000000".toCharArray()) } + assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) + } + + @Test + fun aTransientKeystoreFailureIsNotALostPin() { + assertTrue(manager.configurePin(pin(), dek)) + factor.failing = true + + assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) + } + + private fun pin() = "123456".toCharArray() + + private fun pw(value: String = "correct horse battery") = value.toCharArray() +} 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/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLockoutTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PinLockoutTest.kt index 75f10faf..296ff838 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/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/SecurityManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt index 56b32b58..3172f01d 100644 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SecurityManagerTest.kt @@ -242,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()) @@ -257,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() diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt new file mode 100644 index 00000000..de05ae91 --- /dev/null +++ b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt @@ -0,0 +1,319 @@ +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.eqValue +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: the password as a way in next to the PIN, and + * what a recovery-phrase unlock asks of the user afterwards (keep the PIN, or set a new one). + */ +class UnlockMethodsLifecycleTest { + 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) + } + + // --- the password at the lock screen ------------------------------------------------------ + + @Test + fun thePasswordUnlocksTheVaultOnItsOwn() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + manager.lock() + + assertEquals(UnlockMethods(pin = true, password = true), manager.unlockMethods()) + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPassword(PASSWORD.toCharArray())) + assertEquals(InitialStatus.MAIN, manager.securityStatus) + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + manager.lock() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun aWrongPasswordIsAnInvalidCredentialAndALockoutIsReported() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + manager.lock() + + repeat(AuthenticationManager.MAX_ATTEMPTS) { + assertEquals( + VaultUnlockResult.INVALID_CREDENTIAL, + manager.unlockWithPassword("not it at all".toCharArray()), + ) + } + + assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPassword(PASSWORD.toCharArray())) + assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPin(PIN.toCharArray())) + assertTrue(manager.pinLockoutRemainingMillis() > 0L) + } + + @Test + fun aLostDeviceKeyIsReportedForThePasswordToo() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + manager.lock() + factor.lost = true + + assertEquals(VaultUnlockResult.PIN_KEY_LOST, manager.unlockWithPassword(PASSWORD.toCharArray())) + assertEquals(VaultUnlockResult.PIN_KEY_LOST, manager.unlockWithPin(PIN.toCharArray())) + } + + @Test + fun theCallersPasswordIsWipedByTheUnlock() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + manager.lock() + val typed = PASSWORD.toCharArray() + + manager.unlockWithPassword(typed) + + assertTrue(typed.all { it == '\u0000' }) + } + + @Test + fun aVaultWithOnlyAPasswordStillLocksAndRoutesToTheLockScreen() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + assertTrue(manager.removePin()) + manager.lock() + + assertTrue(manager.isLockable()) + assertEquals(UnlockMethods(pin = false, password = true), manager.unlockMethods()) + assertEquals(AuthStrategy.PIN, manager.authStrategy()) + assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPassword(PASSWORD.toCharArray())) + } + + // --- Settings: add, change, remove ------------------------------------------------------------ + + @Test + fun aLockedVaultHasNoPasswordToChangeOrRemove() { + assertEquals(PasswordChange.FAILED, manager.changePassword(PASSWORD.toCharArray())) + assertFalse(manager.removePassword()) + assertFalse(manager.removePin()) + } + + @Test + fun thePasswordIsReportedToSettings() { + openWithPin() + assertFalse(manager.getSettingsAuth().hasPassword) + + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + + val settings = manager.getSettingsAuth() + assertTrue(settings.hasPassword) + assertTrue(settings.hasPinSlot) + assertFalse(settings.passwordTurnedOff) + } + + @Test + fun theVerifiedPasswordConfirmsTheSessionLikeThePin() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + + assertTrue(manager.verifyPassword(PASSWORD.toCharArray())) + assertFalse(manager.verifyPassword("something else".toCharArray())) + assertTrue(manager.verifyPin(PIN.toCharArray())) + } + + @Test + fun theLastWayInCanNotBeRemoved() { + openWithPin() + assertFalse("no password to fall back on", manager.removePin()) + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + assertTrue(manager.removePassword()) + assertFalse(manager.getSettingsAuth().hasPassword) + assertTrue(manager.getSettingsAuth().hasPinSlot) + } + + @Test + fun aShortPasswordIsInvalidAndLeavesNoSlot() { + openWithPin() + + assertEquals(PasswordChange.INVALID, manager.changePassword("short".toCharArray())) + assertFalse(manager.getSettingsAuth().hasPassword) + } + + @Test + fun theCallersNewPasswordIsWiped() { + openWithPin() + val typed = PASSWORD.toCharArray() + + manager.changePassword(typed) + + assertTrue(typed.all { it == '\u0000' }) + } + + // --- a decoy session never touches the real vault's methods ------------------------------------ + + @Test + fun aDecoySessionRefusesEverythingAboutThePasswordAndThePin() { + openWithPin() + `when`(database.wipeIfClosed(VaultKind.DECOY)).thenReturn(true) + `when`(database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + assertEquals(DuressPinChange.SET_NEW_VAULT, manager.configureDuressPin(DURESS.toCharArray())) + manager.lock() + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(DURESS.toCharArray())) + assertTrue(manager.isDecoySession()) + + assertEquals(PasswordChange.FAILED, manager.changePassword("a new password".toCharArray())) + assertFalse(manager.removePassword()) + assertFalse(manager.removePin()) + assertFalse( + "the real password opens nothing from inside a decoy session", + manager.verifyPassword(PASSWORD.toCharArray()), + ) + assertEquals(RecoveryStage.NONE, manager.recoveryStage()) + } + + // --- 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 aWorkingPasswordCountsAsMuchAsAWorkingPin() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + assertTrue(manager.removePin()) + manager.lock() + + assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) + + assertEquals(RecoveryStage.CHOOSE, manager.recoveryStage()) + } + + @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 aLostDeviceKeyForcesANewPinAfterRecoveryAndTakesThePasswordWithIt() { + openWithPin() + assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) + 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())) + val settings = manager.getSettingsAuth() + assertFalse(settings.hasPassword) + assertTrue(settings.passwordTurnedOff) + 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()) + } + + 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 PASSWORD = "correct horse battery" + } +} diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index d16db08d..0761ac24 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -13,6 +13,7 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -71,6 +72,7 @@ class BackupFlowsTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) `when`(security.verifyPin(anyCharArray())).thenAnswer { String(it.getArgument(0)) == PIN } `when`(security.hasRecoverySeed()).thenReturn(true) `when`(security.hasBackupKey()).thenReturn(true) @@ -144,6 +146,35 @@ class BackupFlowsTest { verify(security, never()).copyBackupRootKey() } + @Test + fun aVaultWithOnlyAPasswordIsAskedForItAndAWrongOneNeverSeals() { + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) + `when`(security.verifyPassword(anyCharArray())).thenAnswer { String(it.getArgument(0)) == PASSWORD } + + viewModel.start(BackupAction.EXPORT) + assertEquals(UnlockMethods(pin = false, password = true), (state().step as BackupStep.Reauth).methods) + viewModel.reauthFlow.submitPassword("not the password".toCharArray()) + + waitFor { (it as? BackupStep.Reauth)?.error != null } + verify(security, never()).copyBackupRootKey() + + viewModel.reauthFlow.submitPassword(PASSWORD.toCharArray()) + + waitFor { it == BackupStep.PickExportTarget } + verify(security, never()).verifyPin(anyCharArray()) + } + + @Test + fun aPasswordDuringALockoutIsNotCheckedAndSaysNothingWrong() { + `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) + + viewModel.start(BackupAction.EXPORT) + viewModel.reauthFlow.submitPassword(PASSWORD.toCharArray()) + + verify(security, never()).verifyPassword(anyCharArray()) + assertNull((state().step as BackupStep.Reauth).error) + } + @Test fun aLockedVaultCannotBeExported() { `when`(security.copyBackupRootKey()).thenReturn(null) @@ -485,6 +516,7 @@ class BackupFlowsTest { private companion object { const val PIN = "482915" + const val PASSWORD = "correct horse battery" const val TIMEOUT_MS = 10_000L const val POLL_MS = 10L } 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 index d0b57239..6bce974b 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -8,6 +8,7 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -46,6 +47,7 @@ class BackupViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) sessionLock = SessionLockManager(security, screenshots = screenshots) viewModel = BackupViewModel( backupManager = BackupManager( 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 index a2d0d43a..c15bbd13 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt @@ -13,6 +13,7 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.VaultDataStore import com.pasich.encly.data.backup.VaultSnapshot import com.pasich.encly.data.export.MarkdownDocuments @@ -62,6 +63,7 @@ class MarkdownExportViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) `when`(security.verifyPin(anyCharArray())).thenReturn(true) documents = mock(MarkdownDocuments::class.java) doAnswer { invocation -> diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt index a55d0610..8a96a6ba 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModelTest.kt @@ -177,17 +177,45 @@ class OnboardingViewModelTest { } @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 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 index 3e6e799b..bea356a3 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/BackupScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/BackupScreenTest.kt @@ -67,7 +67,7 @@ class BackupScreenTest : ComposeScreenTest() { 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)) - typePin("123456") + 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)) } @@ -233,7 +233,7 @@ class BackupScreenTest : ComposeScreenTest() { rule.onNodeWithText(str(Res.string.backup_export)).performClick() waitForText(str(Res.string.backup_reauth_title)) - typePin("123456") + enterPin("123456") waitForText(str(Res.string.backup_error_io)) assertEquals(BackupStep.Idle, step()) 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 index af007222..282855d2 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ComposeScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ComposeScreenTest.kt @@ -31,6 +31,8 @@ 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 @@ -215,6 +217,22 @@ abstract class ComposeScreenTest { } } + /** + * [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. 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 index 507e146d..5fc57d62 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LockScreenTest.kt @@ -28,8 +28,9 @@ 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_entered +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 @@ -116,9 +117,9 @@ class LockScreenTest : ComposeScreenTest() { show() typePin("12") - rule.onNodeWithContentDescription(str(Res.string.pin_digits_entered, 2, 6)).assertExists() + 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_entered, 1, 6)).assertExists() + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 1)).assertExists() } @Test @@ -126,10 +127,10 @@ class LockScreenTest : ComposeScreenTest() { `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) show() - typePin("000000") + enterPin("000000") waitForText(str(Res.string.lock_wrong_pin)) - rule.onNodeWithContentDescription(str(Res.string.pin_digits_entered, 0, 6)).assertExists() + rule.onNodeWithContentDescription(str(Res.string.pin_digits_count, 0)).assertExists() assertEquals(NavRoutes.LockRoute.name, currentRoute()) } @@ -138,7 +139,7 @@ class LockScreenTest : ComposeScreenTest() { `when`(app.security.unlockWithPin(anyCharArray())).thenReturn(VaultUnlockResult.SUCCESS) show() - typePin("123456") + enterPin("123456") waitFor { currentRoute() == NavRoutes.HomeRoute.name } } @@ -218,8 +219,9 @@ class LockScreenTest : ComposeScreenTest() { } @Test - fun theRightPhraseAsksForANewPin() { + 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) @@ -277,7 +279,7 @@ class LockScreenTest : ComposeScreenTest() { VaultUnlockResult.INVALID_CREDENTIAL } showOverTheVault() - typePin("000000") + enterPin("000000") assertTrue(checking.await(WAIT_MS, TimeUnit.MILLISECONDS)) pressBack() diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt index 61ad6e37..379c8c9f 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinKeypadTest.kt @@ -7,8 +7,8 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput import com.pasich.encly.core.resources.Res import com.pasich.encly.core.resources.pin_delete_digit -import com.pasich.encly.core.resources.pin_digits_entered -import com.pasich.encly.presentation.designsystem.PIN_LENGTH +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 @@ -43,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 @@ -68,11 +68,11 @@ class PinKeypadTest : ComposeScreenTest() { `when`(app.security.hasRecoverySeed()).thenReturn(true) setNavScreen(viewModels(app.lock()), route = NavRoutes.LockRoute.name) { nav -> LockScreen(nav) } typePin("123") - rule.onNodeWithContentDescription(str(Res.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(Res.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/ScreenshotAllowanceScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt index b3ba486d..108877d4 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt @@ -86,7 +86,7 @@ class ScreenshotAllowanceScreensTest : ComposeScreenTest() { waitForText(str(Res.string.backup_reauth_title)) assertFalse(app.screenshots.allowed.value) - typePin("123456") + enterPin("123456") waitFor(describe = { "allowed=${app.screenshots.allowed.value}" }) { app.screenshots.allowed.value } } 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 index fabe996a..2b1b8e2b 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -152,7 +152,7 @@ class SecurityScreensTest : ComposeScreenTest() { rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() waitForText(str(Res.string.backup_reauth_title)) - typePin("123456") + enterPin("123456") waitFor(describe = { "${vault.uiState.value}" }) { vault.uiState.value.step is BackupStep.ShowNewPhrase } words.split(' ').forEach { word -> waitForText(word) } @@ -170,7 +170,7 @@ class SecurityScreensTest : ComposeScreenTest() { rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() waitForText(str(Res.string.backup_reauth_title)) - typePin("123456") + enterPin("123456") waitForText(str(Res.string.backup_phrase_exists)) } @@ -197,7 +197,7 @@ class SecurityScreensTest : ComposeScreenTest() { `when`(app.security.verifyPin(anyCharArray())).thenReturn(false) showPinChange() - typePin("000000") + enterPin("000000") waitForText(str(Res.string.pin_current_wrong)) rule.onNodeWithText(str(Res.string.pin_change_current_title)).assertIsDisplayed() @@ -215,11 +215,11 @@ class SecurityScreensTest : ComposeScreenTest() { } showPinChange() - typePin("123456") + enterPin("123456") waitForText(str(Res.string.pin_create_title)) - typePin("654321") + enterPin("654321") waitForText(str(Res.string.pin_change_confirm_title)) - typePin("654321") + enterPin("654321") waitFor { rule.onAllNodesWithContentDescription(str(Res.string.pin_changed)).fetchSemanticsNodes().isNotEmpty() @@ -259,11 +259,11 @@ class SecurityScreensTest : ComposeScreenTest() { fun aMismatchedConfirmationStartsTheNewPinAgain() { showPinChange() - typePin("123456") + enterPin("123456") waitForText(str(Res.string.pin_create_title)) - typePin("111111") + enterPin("111111") waitForText(str(Res.string.pin_change_confirm_title)) - typePin("222222") + enterPin("222222") waitForText(str(Res.string.pin_mismatch_retry)) rule.onNodeWithText(str(Res.string.pin_create_title)).assertIsDisplayed() @@ -305,7 +305,7 @@ class SecurityScreensTest : ComposeScreenTest() { showWipePin(pinIsRight = false) rule.onNodeWithText(str(Res.string.wipe_pin_current_subtitle)).assertIsDisplayed() - typePin("000000") + enterPin("000000") waitForText(str(Res.string.pin_current_wrong)) assertEquals(0, countText(str(Res.string.wipe_pin_set))) @@ -324,15 +324,15 @@ class SecurityScreensTest : ComposeScreenTest() { WipePinChange.SET } - typePin("123456") + 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)) - typePin("864200") + enterPin("864200") waitForText(str(Res.string.wipe_pin_confirm_title)) - typePin("864200") + enterPin("864200") waitForText(str(Res.string.wipe_pin_saved)) assertEquals(listOf("864200"), saved) @@ -344,13 +344,13 @@ class SecurityScreensTest : ComposeScreenTest() { showWipePin() `when`(app.security.configureWipePin(anyCharArray())).thenReturn(WipePinChange.SAME_AS_PIN) - typePin("123456") + 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() - typePin("123456") + enterPin("123456") waitForText(str(Res.string.wipe_pin_confirm_title)) - typePin("123456") + enterPin("123456") waitForText(str(Res.string.wipe_pin_same_as_pin)) rule.onNodeWithText(str(Res.string.wipe_pin_new_title)).assertIsDisplayed() @@ -361,7 +361,7 @@ class SecurityScreensTest : ComposeScreenTest() { showWipePin() `when`(app.security.removeWipePin()).thenReturn(true) - typePin("123456") + 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() @@ -385,14 +385,14 @@ class SecurityScreensTest : ComposeScreenTest() { private fun enterDuressPin(result: DuressPinChange) { `when`(app.security.configureDuressPin(anyCharArray())).thenReturn(result) - typePin("123456") + 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)) - typePin("864200") + enterPin("864200") waitForText(str(Res.string.duress_pin_confirm_title)) - typePin("864200") + enterPin("864200") } @Test @@ -411,7 +411,7 @@ class SecurityScreensTest : ComposeScreenTest() { `when`(app.security.verifyPin(anyCharArray())).thenReturn(false) rule.onNodeWithText(str(Res.string.duress_pin_current_subtitle)).assertIsDisplayed() - typePin("000000") + enterPin("000000") waitForText(str(Res.string.pin_current_wrong)) assertEquals(0, countText(str(Res.string.duress_pin_set))) @@ -459,7 +459,7 @@ class SecurityScreensTest : ComposeScreenTest() { showDuressPin(isSet = true) `when`(app.security.removeDuressPin()).thenReturn(true) - typePin("123456") + 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() @@ -475,11 +475,11 @@ class SecurityScreensTest : ComposeScreenTest() { `when`(app.security.changePin(anyCharArray())).thenReturn(PinChange.IN_USE) showPinChange() - typePin("123456") + enterPin("123456") waitForText(str(Res.string.pin_create_title)) - typePin("654321") + enterPin("654321") waitForText(str(Res.string.pin_change_confirm_title)) - typePin("654321") + enterPin("654321") waitForText(str(Res.string.pin_in_use)) } @@ -527,11 +527,11 @@ class SecurityScreensTest : ComposeScreenTest() { app.inDecoySession() showPinChange() - typePin("123456") + enterPin("123456") waitForText(str(Res.string.pin_create_title)) - typePin("654321") + enterPin("654321") waitForText(str(Res.string.pin_change_confirm_title)) - typePin("654321") + enterPin("654321") waitForText(str(Res.string.pin_update_failed)) assertEquals(NavRoutes.PinCodeConfig.name, currentRoute()) @@ -542,13 +542,13 @@ class SecurityScreensTest : ComposeScreenTest() { app.inDecoySession() showWipePin() - typePin("123456") + 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() - typePin("864200") + enterPin("864200") waitForText(str(Res.string.wipe_pin_confirm_title)) - typePin("864200") + enterPin("864200") waitForText(str(Res.string.wipe_pin_failed)) } @@ -558,7 +558,7 @@ class SecurityScreensTest : ComposeScreenTest() { app.inDecoySession() showWipePin() - typePin("123456") + 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() @@ -583,7 +583,7 @@ class SecurityScreensTest : ComposeScreenTest() { rule.runOnIdle { vault.start(BackupAction.ERASE) } waitForText(str(Res.string.backup_reauth_title)) - typePin("123456") + enterPin("123456") waitForText(str(Res.string.security_erase_failed)) assertEquals(false, vault.uiState.value.erased) @@ -598,7 +598,7 @@ class SecurityScreensTest : ComposeScreenTest() { rule.onNodeWithText(str(Res.string.security_recovery_missing)).performClick() waitForText(str(Res.string.backup_reauth_title)) - typePin("123456") + 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 } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 92502c67..78e81a17 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -14,9 +14,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.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.UnlockMethods import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.core.security.bip39.Bip39 import com.pasich.encly.data.backup.BackupDocuments @@ -104,6 +106,9 @@ internal class TestApp { sessionLock.onStart(mock(androidx.lifecycle.LifecycleOwner::class.java)) withAuth(recoveryPhrase = false) `when`(security.wipeAndReset()).thenReturn(true) + // A vault with a PIN and no password, and a session that owes nothing after the recovery phrase. + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) + `when`(security.recoveryStage()).thenReturn(RecoveryStage.NONE) } private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt new file mode 100644 index 00000000..df5b1606 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt @@ -0,0 +1,447 @@ +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.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.lock_password_subtitle +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_password +import com.pasich.encly.core.resources.lock_use_pin +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.resources.lock_wrong_password +import com.pasich.encly.core.resources.password_change +import com.pasich.encly.core.resources.password_current_wrong +import com.pasich.encly.core.resources.password_failed +import com.pasich.encly.core.resources.password_mismatch +import com.pasich.encly.core.resources.password_pin_only_note +import com.pasich.encly.core.resources.password_remove +import com.pasich.encly.core.resources.password_remove_confirm +import com.pasich.encly.core.resources.password_removed +import com.pasich.encly.core.resources.password_save +import com.pasich.encly.core.resources.password_saved +import com.pasich.encly.core.resources.password_set +import com.pasich.encly.core.resources.password_too_short +import com.pasich.encly.core.resources.pin_digits_count +import com.pasich.encly.core.resources.pin_remove +import com.pasich.encly.core.resources.pin_remove_confirm +import com.pasich.encly.core.resources.pin_removed +import com.pasich.encly.core.resources.recovery_choice_body_password +import com.pasich.encly.core.resources.recovery_choice_body_pin +import com.pasich.encly.core.resources.recovery_keep_password +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.AuthSettings +import com.pasich.encly.core.security.AuthType +import com.pasich.encly.core.security.PasswordChange +import com.pasich.encly.core.security.RecoveryStage +import com.pasich.encly.core.security.UnlockMethods +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.presentation.screen.settings.PasswordScreen +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.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +/** + * The PIN of 6 to 12 digits and the password as a second way in: the lock screen, the choice after the recovery + * phrase and Settings → Security → Password. The PIN screens never submit by themselves; a button does. + */ +@OptIn(ExperimentalTestApi::class) +class UnlockMethodsScreensTest : ComposeScreenTest() { + private val app by lazy { TestApp() } + + private fun showLock(methods: UnlockMethods = UnlockMethods(pin = true, password = false)) { + `when`(app.security.unlockMethods()).thenReturn(methods) + `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) + + private fun typePassword(text: String) { + rule.onNode(hasSetTextAction()).performTextInput(text) + } + + // --- 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)) + } + + // --- the password on the lock screen ------------------------------------------------------------- + + @Test + fun withoutAPasswordThereIsNoSwitch() { + showLock() + + assertEquals(0, countText(str(Res.string.lock_use_password))) + } + + @Test + fun aVaultWithBothOffersToSwitchBetweenThem() { + showLock(UnlockMethods(pin = true, password = true)) + rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() + + rule.onNodeWithText(str(Res.string.lock_password_subtitle)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.lock_use_pin)).performClick() + + rule.onNodeWithText(str(Res.string.lock_subtitle)).assertIsDisplayed() + } + + @Test + fun thePasswordUnlocksOnItsOwn() { + var sent = "" + `when`(app.security.unlockWithPassword(anyCharArray())).thenAnswer { + sent = String(it.getArgument(0)) + VaultUnlockResult.SUCCESS + } + showLock(UnlockMethods(pin = true, password = true)) + rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() + + typePassword("correct horse") + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsEnabled().performClick() + + waitFor { currentRoute() == NavRoutes.HomeRoute.name } + assertEquals("correct horse", sent) + verify(app.security, never()).unlockWithPin(anyCharArray()) + } + + @Test + fun aShortPasswordCanNotBeSent() { + showLock(UnlockMethods(pin = true, password = true)) + rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() + + typePassword("1234567") + + rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() + verify(app.security, never()).unlockWithPassword(anyCharArray()) + } + + @Test + fun aWrongPasswordSaysSoAndStays() { + `when`(app.security.unlockWithPassword(anyCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + showLock(UnlockMethods(pin = true, password = true)) + rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() + typePassword("not the password") + + rule.onNodeWithText(str(Res.string.lock_unlock)).performClick() + + waitForText(str(Res.string.lock_wrong_password)) + assertEquals(NavRoutes.LockRoute.name, currentRoute()) + } + + @Test + fun aVaultWithOnlyAPasswordOpensOnTheFormWithoutAPinSwitch() { + showLock(UnlockMethods(pin = false, password = true)) + + rule.onNodeWithText(str(Res.string.lock_password_subtitle)).assertIsDisplayed() + assertEquals(0, countText(str(Res.string.lock_use_pin))) + } + + // --- 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() + } + + @Test + fun aVaultWithOnlyAPasswordKeepsItsPassword() { + `when`(app.security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) + `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) + setNavScreen( + viewModels(app.lock()), + route = NavRoutes.RecoveryChoiceRoute.name, + ) { nav -> RecoveryChoiceScreen(nav) } + + rule.onNodeWithText(str(Res.string.recovery_choice_body_password)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.recovery_keep_password)).assertIsDisplayed() + } + + // --- Settings → Security → Password ---------------------------------------------------------------- + + private fun showPassword(hasPassword: Boolean, hasPin: Boolean = true) { + `when`(app.security.unlockMethods()).thenReturn(UnlockMethods(pin = hasPin, password = hasPassword)) + `when`(app.security.getSettingsAuth()).thenReturn( + AuthSettings(AuthType.PIN, false, true, hasPinSlot = hasPin, hasPassword = hasPassword), + ) + `when`(app.security.verifyPin(anyCharArray())).thenReturn(true) + `when`(app.security.verifyPassword(anyCharArray())).thenReturn(true) + setNavScreen( + viewModels(app.securitySettings()), + route = NavRoutes.PasswordRoute.name, + start = NavRoutes.SecuritySettingsRoute.name, + ) { nav -> PasswordScreen(nav) } + navigateTo(NavRoutes.PasswordRoute.name) + } + + @Test + fun theWipeAndDuressPinsAreSaidToStayPinOnly() { + showPassword(hasPassword = false) + enterPin("123456") + + rule.onNodeWithText(str(Res.string.password_pin_only_note)).assertIsDisplayed() + rule.onNodeWithText(str(Res.string.password_set)).assertIsDisplayed() + } + + @Test + fun theOwnerIsAskedForThePinBeforeAnythingChanges() { + showPassword(hasPassword = false) + + assertEquals(0, countText(str(Res.string.password_set))) + enterPin("123456") + + verify(app.security).verifyPin(anyCharArray()) + rule.onNodeWithText(str(Res.string.password_set)).assertIsDisplayed() + } + + @Test + fun aVaultWithoutAPinIsAskedForItsPassword() { + showPassword(hasPassword = true, hasPin = false) + + typePassword("correct horse") + rule.onNodeWithText(str(Res.string.action_continue)).performClick() + + waitForText(str(Res.string.password_change)) + verify(app.security).verifyPassword(anyCharArray()) + verify(app.security, never()).verifyPin(anyCharArray()) + } + + @Test + fun aNewPasswordIsTypedTwiceAndSaved() { + var saved = "" + `when`(app.security.changePassword(anyCharArray())).thenAnswer { + saved = String(it.getArgument(0)) + PasswordChange.SET + } + showPassword(hasPassword = false) + enterPin("123456") + rule.onNodeWithText(str(Res.string.password_set)).performClick() + + val fields = rule.onAllNodes(hasSetTextAction()) + fields[0].performTextInput("a long enough one") + fields[1].performTextInput("a long enough one") + rule.onNodeWithText(str(Res.string.password_save)).assertIsEnabled().performClick() + + waitForText(str(Res.string.password_saved)) + assertEquals("a long enough one", saved) + } + + @Test + fun passwordsThatDoNotMatchAreNotSaved() { + showPassword(hasPassword = false) + enterPin("123456") + rule.onNodeWithText(str(Res.string.password_set)).performClick() + + val fields = rule.onAllNodes(hasSetTextAction()) + fields[0].performTextInput("a long enough one") + fields[1].performTextInput("a long enough two") + rule.onNodeWithText(str(Res.string.password_save)).performClick() + + waitForText(str(Res.string.password_mismatch)) + verify(app.security, never()).changePassword(anyCharArray()) + } + + @Test + fun aPasswordThatIsTooShortCanNotBeSaved() { + showPassword(hasPassword = false) + enterPin("123456") + rule.onNodeWithText(str(Res.string.password_set)).performClick() + + val fields = rule.onAllNodes(hasSetTextAction()) + fields[0].performTextInput("short") + fields[1].performTextInput("short") + + rule.onNodeWithText(str(Res.string.password_save)).assertIsNotEnabled() + } + + @Test + fun theCoreRefusingAPasswordIsShown() { + `when`(app.security.changePassword(anyCharArray())).thenReturn(PasswordChange.INVALID) + showPassword(hasPassword = false) + enterPin("123456") + rule.onNodeWithText(str(Res.string.password_set)).performClick() + val fields = rule.onAllNodes(hasSetTextAction()) + fields[0].performTextInput("a long enough one") + fields[1].performTextInput("a long enough one") + + rule.onNodeWithText(str(Res.string.password_save)).performClick() + + waitForText(str(Res.string.password_too_short)) + } + + @Test + fun aFailedSaveIsReported() { + `when`(app.security.changePassword(anyCharArray())).thenReturn(PasswordChange.FAILED) + showPassword(hasPassword = false) + enterPin("123456") + rule.onNodeWithText(str(Res.string.password_set)).performClick() + val fields = rule.onAllNodes(hasSetTextAction()) + fields[0].performTextInput("a long enough one") + fields[1].performTextInput("a long enough one") + + rule.onNodeWithText(str(Res.string.password_save)).performClick() + + waitForText(str(Res.string.password_failed)) + } + + @Test + fun thePasswordCanBeRemovedWhileThePinStands() { + `when`(app.security.removePassword()).thenReturn(true) + showPassword(hasPassword = true) + enterPin("123456") + + rule.onNodeWithText(str(Res.string.password_remove)).performClick() + rule.onNodeWithText(str(Res.string.password_remove_confirm)).performClick() + + waitForText(str(Res.string.password_removed)) + verify(app.security, times(1)).removePassword() + } + + @Test + fun thePinCanOnlyBeRemovedAfterTypingThePasswordAgain() { + `when`(app.security.removePin()).thenReturn(true) + showPassword(hasPassword = true) + enterPin("123456") + + rule.onNodeWithText(str(Res.string.pin_remove)).performClick() + rule.onNodeWithText(str(Res.string.pin_remove_confirm)).assertIsNotEnabled() + verify(app.security, never()).removePin() + typePassword("correct horse") + rule.onNodeWithText(str(Res.string.pin_remove_confirm)).performClick() + + waitForText(str(Res.string.pin_removed)) + verify(app.security).verifyPassword(anyCharArray()) + verify(app.security).removePin() + } + + @Test + fun aWrongPasswordKeepsThePin() { + showPassword(hasPassword = true) + enterPin("123456") + rule.onNodeWithText(str(Res.string.pin_remove)).performClick() + `when`(app.security.verifyPassword(anyCharArray())).thenReturn(false) + typePassword("not the password") + + rule.onNodeWithText(str(Res.string.pin_remove_confirm)).performClick() + + waitForText(str(Res.string.password_current_wrong)) + verify(app.security, never()).removePin() + } + + @Test + fun noRemovalIsOfferedWithoutThePinToFallBackOn() { + showPassword(hasPassword = true, hasPin = false) + typePassword("correct horse") + rule.onNodeWithText(str(Res.string.action_continue)).performClick() + waitForText(str(Res.string.password_change)) + + assertEquals(0, countText(str(Res.string.password_remove))) + assertEquals(0, countText(str(Res.string.pin_remove))) + } +} 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 index a298e7ce..421b3618 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Pin.kt @@ -35,14 +35,13 @@ 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_entered +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 -/** 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 @@ -53,19 +52,17 @@ 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. + * 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, - length: Int = PIN_LENGTH, - error: Boolean = false, - shakeKey: Int = 0, -) { +fun PinDots(entered: Int, modifier: Modifier = Modifier, error: Boolean = false, shakeKey: Int = 0) { val colors = MaterialTheme.colorScheme - val description = stringResource(Res.string.pin_digits_entered, entered, length) + 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 @@ -173,7 +170,7 @@ fun PinKeypad( onLongClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) // No clear handler: delete once per digit a PIN can hold. - onClear?.invoke() ?: repeat(PIN_LENGTH) { onBackspace() } + onClear?.invoke() ?: repeat(MAX_PIN_LENGTH) { onBackspace() } }, ) { Icon( diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt index 8d45d17c..43ea8df7 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt @@ -31,6 +31,7 @@ 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.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.pasich.encly.core.resources.Res import com.pasich.encly.core.resources.field_correct @@ -70,6 +71,7 @@ fun EnclyTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val interactionSource = remember { MutableInteractionSource() } val focused by interactionSource.collectIsFocusedAsState() @@ -93,6 +95,7 @@ fun EnclyTextField( cursorBrush = SolidColor(colors.primary), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + visualTransformation = visualTransformation, interactionSource = interactionSource, modifier = fieldModifier.fillMaxWidth(), decorationBox = { inner -> 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 index cfee1380..b7cc4a51 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -29,6 +29,7 @@ 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 @@ -39,6 +40,7 @@ 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.PasswordScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen import com.pasich.encly.presentation.screen.settings.WipePinScreen @@ -191,6 +193,14 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR NavigationPage(navController, NavRoutes.SettingsRoute) { PinCodeConfigScreen(navController) } } + animationScreens(NavRoutes.PasswordRoute.name) { + NavigationPage(navController, NavRoutes.SettingsRoute) { PasswordScreen(navController) } + } + + animationScreens(NavRoutes.RecoveryChoiceRoute.name) { + CenteredContent { RecoveryChoiceScreen(navController) } + } + animationScreens(NavRoutes.WipePinRoute.name) { NavigationPage(navController, NavRoutes.SettingsRoute) { WipePinScreen(navController) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt index 6752cf63..d0a7eb45 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -17,6 +17,8 @@ enum class NavRoutes { LegacyVaultRoute, StorageUnreadableRoute, PinCodeConfig, + PasswordRoute, + RecoveryChoiceRoute, WipePinRoute, DuressPinRoute, LockRoute, 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 index 7dfbdbec..2d731862 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt @@ -35,6 +35,10 @@ 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_password_a +import com.pasich.encly.core.resources.faq_password_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 @@ -99,6 +103,8 @@ private val faqSections = listOf( 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_password_q to Res.string.faq_password_a, Res.string.faq_screenshots_q to Res.string.faq_screenshots_a, Res.string.faq_background_q to Res.string.faq_background_a, ), 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 index dcf71dca..e7d4c20a 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt @@ -9,9 +9,11 @@ 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_pin_key_lost +import com.pasich.encly.core.resources.lock_wrong_password 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.PasswordInput import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.viewmodel.PinUnlockResult @@ -19,16 +21,30 @@ 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 + * What the lock screen's forms show: the PIN typed so far, the password, 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, password and phrase forms. internal class LockFormState { /** The recovery-phrase form is shown instead of the PIN pad. */ var useRecovery by mutableStateOf(false) + /** The password form is shown instead of the PIN pad (the user switched, or the vault has no PIN). */ + var usePassword by mutableStateOf(false) + + /** The password typed so far; a String in the text field, dropped when taken or cleared (see [PasswordInput]). */ + val password = PasswordInput() + + var passwordError by mutableStateOf(null) + private set + + /** 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() @@ -61,12 +77,55 @@ internal class LockFormState { 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() + /** 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() + /** The password, taken out of the form to be checked. The caller wipes the copy. */ + fun takePassword(): CharArray = password.take() + + /** Any edit of the password clears the last error. */ + fun onPasswordEdited() { + passwordError = null + } + + /** The PIN pad and the password form swap places; what was typed in the one is dropped. */ + fun switchTo(usePassword: Boolean) { + clearPin() + password.clear() + pinError = null + passwordError = null + this.usePassword = usePassword + } + + fun onPasswordResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { + when (result) { + PinUnlockResult.WRONG_PIN -> { + passwordError = Res.string.lock_wrong_password + lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) + } + + PinUnlockResult.DB_ERROR -> passwordError = Res.string.error_database_open + + PinUnlockResult.LOCKED_OUT -> lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) + + PinUnlockResult.KEY_LOST -> { + passwordError = Res.string.lock_pin_key_lost + phraseError = Res.string.lock_pin_key_lost + } + + PinUnlockResult.SUCCESS, PinUnlockResult.BACKGROUNDED -> Unit + } + } + fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { when (result) { PinUnlockResult.WRONG_PIN -> { @@ -94,12 +153,12 @@ internal class LockFormState { phraseError = null } - /** System back: from the recovery form to the PIN pad; the PIN pad itself stays. */ + /** System back: from the recovery form to the PIN pad or password form; those themselves stay. */ fun back() { if (useRecovery) leaveRecovery() } - /** Back to the PIN pad; the typed words are dropped. */ + /** Back to the PIN pad (or the password form); the typed words are dropped. */ fun leaveRecovery() { useRecovery = false phrase.clear() 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..8803e09d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt @@ -0,0 +1,334 @@ +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_password_label +import com.pasich.encly.core.resources.lock_password_subtitle +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_password +import com.pasich.encly.core.resources.lock_use_pin +import com.pasich.encly.core.resources.lock_use_recovery_phrase +import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.UnlockMethods +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.PasswordField +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, the recovery phrase, and which of the + * PIN and the password the vault has ([methods]). + */ +internal data class LockCapabilities( + val biometricEnabled: Boolean, + val recoveryAvailable: Boolean, + val methods: UnlockMethods, +) { + /** + * The other way in, as a link under the form on show: null when the vault has one way in only, else whether it + * is the password (true, from the PIN pad) or the PIN (false, from the password form). + */ + fun otherMethod(form: LockFormState): Boolean? = if (methods.pin && methods.password) !form.usePassword else null +} + +/** The three 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 password: (password: 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, the password, 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 if (form.usePassword) { + PasswordLockContent(form, capabilities, busy, actions) + } 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, + ), + ) + OtherMethodLinks(form, capabilities, busy) + } +} + +/** The links under the PIN pad: the password (when the vault has one), and the recovery phrase. */ +@Composable +private fun OtherMethodLinks(form: LockFormState, capabilities: LockCapabilities, busy: Boolean) { + capabilities.otherMethod(form)?.let { toPassword -> + EnclyTextButton( + text = stringResource(if (toPassword) Res.string.lock_use_password else Res.string.lock_use_pin), + onClick = { form.switchTo(usePassword = toPassword) }, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter), + enabled = !busy, + ) + } + if (capabilities.recoveryAvailable) RecoveryLink(onUseRecovery = { form.useRecovery = true }) +} + +/** The password form: a masked field, the Unlock button pinned above the keyboard, and the same links. */ +@Composable +private fun PasswordLockContent( + form: LockFormState, + capabilities: LockCapabilities, + busy: Boolean, + actions: LockActions, +) { + PinLockoutTicker(form.lockedOut, actions.lockoutRemainingMillis) { form.lockoutSeconds = it } + DisposableEffect(form) { onDispose { form.password.clear() } } + ReleaseFocusWhile(busy) + val canSubmit = form.password.canSubmit && !form.lockedOut && !busy + val submit = { + if (canSubmit) { + submitSecret(form.takePassword(), actions.checks.password, actions) { r, ms -> + form.onPasswordResult(r, ms) + } + } + } + val shownError = form.passwordError + PinEntryScaffold( + title = stringResource(Res.string.lock_title), + subtitle = when { + form.lockedOut -> pinLockoutText(form.lockoutSeconds) + shownError != null -> stringResource(shownError) + else -> stringResource(Res.string.lock_password_subtitle) + }, + subtitleIsError = form.lockedOut || shownError != null, + icon = EnclyIcons.Key, + compact = true, + footer = { + EnclyButton(text = stringResource(Res.string.lock_unlock), onClick = submit, enabled = canSubmit) + capabilities.otherMethod(form)?.let { toPassword -> + EnclyTextButton( + text = stringResource(if (toPassword) Res.string.lock_use_password else Res.string.lock_use_pin), + onClick = { form.switchTo(usePassword = toPassword) }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + } + if (capabilities.recoveryAvailable) { + EnclyTextButton( + text = stringResource(Res.string.lock_use_recovery_phrase), + onClick = { form.useRecovery = true }, + modifier = Modifier.fillMaxWidth(), + enabled = !busy, + ) + } + }, + ) { + PasswordField( + input = form.password, + label = stringResource(Res.string.lock_password_label), + enabled = !form.lockedOut && !busy, + onEdit = form::onPasswordEdited, + onDone = submit, + modifier = Modifier + .padding(horizontal = EnclyTheme.spacing.gutter) + .padding(top = EnclyTheme.spacing.l), + ) + } +} + +/** + * Sends [secret] (a PIN or a password) 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 a secret is checked (the phrase, the password) 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 index 8a83a56d..90708cdb 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -1,59 +1,27 @@ 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.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.backhandler.BackHandler -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.lifecycle.Lifecycle import androidx.navigation.NavHostController -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_unlocking -import com.pasich.encly.core.resources.lock_use_recovery_phrase -import com.pasich.encly.core.security.PIN_LENGTH -import com.pasich.encly.core.security.SensitiveDataCleaner +import com.pasich.encly.core.security.RecoveryStage import com.pasich.encly.platform.LocalBiometricHost import com.pasich.encly.platform.LocalSessionSharedAcrossWindows -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.ui.theme.EnclyTheme import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first -import org.jetbrains.compose.resources.stringResource /** The app's lock screen: unlocking opens Home (or the note that was open), see [leaveLockScreen]. */ @Composable @@ -70,12 +38,8 @@ fun LockScreen(navController: NavHostController, modifier: Modifier = Modifier) 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 } - } - }, + // 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 } @@ -104,11 +68,10 @@ private fun FollowUnlockFromOtherWindows( delay(FOLLOW_UNLOCK_GRACE_MS) val ownUnlock = viewModel.busy.value || reveal?.running == true if (!ownUnlock && !viewModel.isSessionLocked()) { - if (viewModel.mustResetPin()) { - // The other window unlocked with the recovery phrase: this one needs the new PIN too. - navController.navigate(NavRoutes.PinCodeConfig.name) { - popUpTo(NavRoutes.LockRoute.name) { inclusive = true } - } + 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) } @@ -119,12 +82,22 @@ private fun FollowUnlockFromOtherWindows( 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() } + val methods = remember { viewModel.unlockMethods() } + // A vault whose PIN was removed opens on the password form. + val form = remember { LockFormState().also { it.usePassword = !methods.pin && methods.password } } // 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. @@ -158,35 +131,28 @@ fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockV } } - 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(Res.string.lock_unlocking)) - } else { - PinLockContent( - form = form, - capabilities = LockCapabilities( - biometricEnabled = biometricEnabled && host != null, - recoveryAvailable = viewModel.recoveryAvailable(), - ), - pinAuth = PinAuth( - busy = busy, - lockoutRemainingMillis = viewModel::lockoutRemainingMillis, - authenticate = viewModel::authenticatePin, - onKeyLoss = ::onPinKeyLoss, - ), - onUnlock = ::goHome, - onPromptBiometric = ::promptBiometric, - ) - } - } + LockForms( + form = form, + busy = busy, + capabilities = LockCapabilities( + biometricEnabled = biometricEnabled && host != null, + recoveryAvailable = viewModel.recoveryAvailable(), + methods = methods, + ), + actions = LockActions( + checks = LockChecks( + pin = viewModel::authenticatePin, + password = viewModel::authenticatePassword, + 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)?) { @@ -197,17 +163,6 @@ private fun onLockBack(busy: Boolean, form: LockFormState, onBack: (() -> Unit)? } } -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 * EnclyApp), once the reveal covers the window so Home composes out of sight. If the @@ -254,146 +209,3 @@ private fun handlePinKeyLoss( 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(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 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(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, - ), - ) -} 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 index 8f13ba67..40356853 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -37,6 +37,11 @@ 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.lock_password_label +import com.pasich.encly.core.resources.password_current_subtitle +import com.pasich.encly.core.resources.password_current_title +import com.pasich.encly.core.resources.password_current_wrong 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 @@ -57,11 +62,16 @@ 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.PasswordInput +import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold +import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit +import com.pasich.encly.presentation.screen.pincode.PasswordStepText 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 @@ -84,9 +94,12 @@ enum class PinAnimationState { * 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) { +private class PinChangeState(val isReset: Boolean, val verifyWithPassword: Boolean = false) { var step by mutableIntStateOf(if (isReset) 1 else 0) + /** The password typed at step 0 of a vault that has no PIN to ask for (see [verifyWithPassword]). */ + val password = PasswordInput() + /** The new PIN typed at step 1, until the confirmation is compared with it. */ private var firstPin: CharArray? = null @@ -99,9 +112,15 @@ private class PinChangeState(val isReset: Boolean) { /** 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. */ + /** Digits are refused while the current PIN (or password) is locked out. */ val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) + /** The password of step 0 is sent to be checked. */ + fun onPasswordSubmit(viewModel: SecuritySettingsViewModel) { + if (!password.canSubmit || !keysEnabled) return + viewModel.verifyCurrentPassword(password.take()) { ok -> onCurrentPinChecked(ok, viewModel) } + } + /** A digit on the keypad; refused during a lockout. */ fun typeDigit(digit: Int) { if (keysEnabled) input.add(digit) @@ -147,6 +166,7 @@ private class PinChangeState(val isReset: Boolean) { /** Typed or kept digits do not outlive the screen. */ fun clear() { input.clear() + password.clear() firstPin?.let(SensitiveDataCleaner::clear) firstPin = null } @@ -164,7 +184,7 @@ private class PinChangeState(val isReset: Boolean) { } else -> { - errorText = Res.string.pin_current_wrong + errorText = if (verifyWithPassword) Res.string.password_current_wrong else Res.string.pin_current_wrong shakeKey++ } } @@ -188,7 +208,13 @@ fun PinCodeConfigScreen( ) { // 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()) } + val pinState = remember { + PinChangeState( + isReset = !securityViewModel.requiresCurrentPin(), + // A vault with no PIN is asked for its password before a PIN is added. + verifyWithPassword = !securityViewModel.hasPinSlot(), + ) + } BackHandler(enabled = pinState.isReset) { } if (pinState.isReset && LocalSessionSharedAcrossWindows.current) { @@ -198,11 +224,6 @@ fun PinCodeConfigScreen( 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) { @@ -231,16 +252,11 @@ fun PinCodeConfigScreen( }, 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, + PinStepContent( + step = currentStep, + pinState = pinState, + onSubmitPin = { pinState.onPinComplete(securityViewModel) }, + onSubmitPassword = { pinState.onPasswordSubmit(securityViewModel) }, ) } } else { @@ -251,6 +267,37 @@ fun PinCodeConfigScreen( } } +/** The page of [step]: the password of a vault with no PIN, or the keypad of the PIN steps. */ +@Composable +private fun PinStepContent( + step: Int, + pinState: PinChangeState, + onSubmitPin: () -> Unit, + onSubmitPassword: () -> Unit, +) { + if (step == 0 && pinState.verifyWithPassword) { + CurrentPasswordContent(pinState, onSubmit = onSubmitPassword) + return + } + 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. */ @@ -309,7 +356,36 @@ private fun pinStepSubtitle(step: Int): StringResource = when (step) { 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) +private class PinEntryState( + val enabled: Boolean, + val shakeKey: Int, + val compact: Boolean, + val canSubmit: Boolean, + val onSubmit: () -> Unit, +) + +/** Step 0 for a vault with no PIN: its password is asked for before a PIN is added. */ +@Composable +private fun CurrentPasswordContent(state: PinChangeState, onSubmit: () -> Unit) { + val lockedOut = state.lockoutSeconds > 0L + val error = state.errorText?.let { stringResource(it) } + PasswordStepScaffold( + text = PasswordStepText( + title = stringResource(Res.string.password_current_title), + subtitle = when { + lockedOut -> pinLockoutText(state.lockoutSeconds) + error != null -> error + else -> stringResource(Res.string.password_current_subtitle) + }, + label = stringResource(Res.string.lock_password_label), + subtitleIsError = lockedOut || error != null, + ), + input = state.password, + submit = PasswordStepSubmit(stringResource(Res.string.action_continue), onSubmit), + enabled = !lockedOut, + onEdit = { state.errorText = null }, + ) +} @Composable private fun MainPinContent( @@ -337,6 +413,11 @@ private fun MainPinContent( onDigit = onInput, onBackspace = onDelete, ), + submit = PinSubmit( + text = stringResource(Res.string.action_continue), + enabled = entry.canSubmit, + onClick = entry.onSubmit, + ), ) } } 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..129e9dec --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt @@ -0,0 +1,88 @@ +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.runtime.remember +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_password +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_password +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 (or password) 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(), +) { + val methods = remember { viewModel.unlockMethods() } + 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( + if (methods.pin) Res.string.recovery_choice_body_pin else Res.string.recovery_choice_body_password, + ), + icon = EnclyIcons.Key, + modifier = modifier, + footer = { + EnclyButton( + text = stringResource( + if (methods.pin) Res.string.recovery_keep_pin else Res.string.recovery_keep_password, + ), + 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/backup/BackupDialogs.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt index a24797ac..1d236a50 100644 --- 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 @@ -16,9 +16,7 @@ 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 @@ -26,8 +24,6 @@ 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.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_confirm_phrase_body @@ -48,8 +44,6 @@ 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_reauth_subtitle -import com.pasich.encly.core.resources.backup_reauth_title 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 @@ -70,7 +64,6 @@ 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.platform.LocalBiometricHost import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.DialogAction import com.pasich.encly.presentation.designsystem.EnclyCallout @@ -82,7 +75,6 @@ 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.ProtectFromCapture import com.pasich.encly.presentation.designsystem.RecoveryPhraseInput import com.pasich.encly.presentation.designsystem.RecoveryPhraseState import com.pasich.encly.presentation.designsystem.StepHeading @@ -91,14 +83,7 @@ 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 import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.pluralStringResource @@ -197,68 +182,6 @@ private fun MessageDialog( ) } -/** - * 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() - 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() } } - 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(Res.string.backup_reauth_title), - subtitle = when { - lockedOut -> pinLockoutText(lockoutSeconds) - error != null -> error - else -> stringResource(Res.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 && host != null) { - { reauth.withBiometric(host) } - } else { - null - }, - ), - ) - EnclyTextButton( - text = stringResource(Res.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. 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..acb32155 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/backup/ReauthDialog.kt @@ -0,0 +1,182 @@ +package com.pasich.encly.presentation.screen.backup + +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.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +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.core.resources.lock_password_label +import com.pasich.encly.core.resources.lock_password_subtitle +import com.pasich.encly.core.resources.lock_use_password +import com.pasich.encly.core.resources.lock_use_pin +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.PasswordInput +import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold +import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit +import com.pasich.encly.presentation.screen.pincode.PasswordStepText +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 or password 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. A vault with both a PIN + * and a password asks for the PIN and offers the password under it; with only one, that one. + */ +@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 usePassword by remember { mutableStateOf(!step.methods.pin && step.methods.password) } + var lockoutSeconds by remember { mutableLongStateOf(0L) } + // Keyed on the step too: a wrong secret (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 + usePassword -> stringResource(Res.string.lock_password_subtitle) + else -> stringResource(Res.string.backup_reauth_subtitle) + } + val entry = ReauthEntry( + subtitle = subtitle, + subtitleIsError = lockedOut || error != null, + hasError = error != null, + lockedOut = lockedOut, + // The other way in, offered when the vault has both. + onSwitch = { usePassword = !usePassword }.takeIf { step.methods.pin && step.methods.password }, + onCancel = onCancel, + ) + Dialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + if (usePassword) ReauthPasswordEntry(reauth, entry) else ReauthPinEntry(reauth, step, entry) + } +} + +/** What the two entries of [ReauthDialog] share: the sub-heading, a running lockout and where the buttons go. */ +private class ReauthEntry( + val subtitle: String, + val subtitleIsError: Boolean, + val hasError: Boolean, + val lockedOut: Boolean, + val onSwitch: (() -> Unit)?, + val onCancel: () -> Unit, +) + +@Composable +private fun ReauthPasswordEntry(reauth: ReauthFlow, entry: ReauthEntry) { + // Never a String once submitted; dropped when the entry goes (see PasswordInput). + val password = remember { PasswordInput() } + DisposableEffect(password) { onDispose { password.clear() } } + PasswordStepScaffold( + text = PasswordStepText( + title = stringResource(Res.string.backup_reauth_title), + subtitle = entry.subtitle, + label = stringResource(Res.string.lock_password_label), + subtitleIsError = entry.subtitleIsError, + ), + input = password, + submit = PasswordStepSubmit(stringResource(Res.string.action_continue)) { + reauth.submitPassword(password.take()) + }, + enabled = !entry.lockedOut, + modifier = Modifier.fillMaxSize(), + footerExtra = { + entry.onSwitch?.let { switch -> + EnclyTextButton( + text = stringResource(Res.string.lock_use_pin), + onClick = { + password.clear() + switch() + }, + modifier = Modifier.fillMaxWidth(), + ) + } + EnclyTextButton( + text = stringResource(Res.string.cancel), + onClick = entry.onCancel, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) +} + +@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()) }, + ), + ) + entry.onSwitch?.let { switch -> + EnclyTextButton( + text = stringResource(Res.string.lock_use_password), + onClick = { + input.clear() + switch() + }, + modifier = Modifier.padding(top = EnclyTheme.spacing.s), + ) + } + 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/onboarding/OnboardingFooter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingFooter.kt index d09444e6..1af3174d 100644 --- 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 @@ -19,7 +19,7 @@ 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.PIN_LENGTH +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 @@ -44,7 +44,7 @@ internal fun footerSpec( ) OnboardingStep.PIN -> - FooterSpec(Res.string.action_continue, actions.pin.onSubmit, state.pinLength == PIN_LENGTH, busy) + FooterSpec(Res.string.action_continue, actions.pin.onSubmit, state.pinLength >= MIN_PIN_LENGTH, busy) OnboardingStep.RECOVERY_INFO -> FooterSpec( Res.string.onboarding_recovery_create, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt new file mode 100644 index 00000000..758349a3 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt @@ -0,0 +1,74 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import com.pasich.encly.core.security.MAX_PASSWORD_LENGTH +import com.pasich.encly.core.security.MIN_PASSWORD_LENGTH +import com.pasich.encly.presentation.designsystem.EnclyTextField +import com.pasich.encly.presentation.designsystem.FieldState + +/** + * A password being typed. A text field keeps its text as a String, which cannot be wiped (the recovery words + * are no different): it is held here only while the form is up, dropped when the password is taken for the + * check, and the check wipes the CharArray it is given. + */ +@Stable +internal class PasswordInput { + var text by mutableStateOf("") + + /** The characters typed, as Unicode code points (a surrogate pair is one). */ + val length: Int get() = text.count { !it.isLowSurrogate() } + + /** Long enough to be a password. Nothing is submitted by itself: the user presses the button. */ + val canSubmit: Boolean get() = length in MIN_PASSWORD_LENGTH..MAX_PASSWORD_LENGTH + + /** The typed password, taken out of the form; the text is dropped. The caller wipes the copy. */ + fun take(): CharArray = text.toCharArray().also { clear() } + + fun clear() { + text = "" + } +} + +/** A masked password field over [input]; [onDone] is the keyboard's Done action. */ +@Composable +internal fun PasswordField( + input: PasswordInput, + label: String, + modifier: Modifier = Modifier, + fieldModifier: Modifier = Modifier, + enabled: Boolean = true, + error: String? = null, + imeAction: ImeAction = ImeAction.Done, + onEdit: () -> Unit = {}, + onDone: () -> Unit = {}, +) { + EnclyTextField( + value = input.text, + onValueChange = { + input.text = it.take(MAX_PASSWORD_LENGTH * 2) + onEdit() + }, + label = label, + modifier = modifier, + fieldModifier = fieldModifier, + enabled = enabled, + state = if (error != null) FieldState.Error(error) else FieldState.Default, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + autoCorrectEnabled = false, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions(onDone = { onDone() }, onGo = { onDone() }), + visualTransformation = PasswordVisualTransformation(), + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt new file mode 100644 index 00000000..c6d1242a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.pasich.encly.presentation.designsystem.EnclyButton +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.ui.theme.EnclyTheme + +/** + * A step that asks for the password of the vault (to confirm it is the owner): the title and sub-heading of every + * PIN screen, a masked field and, pinned above the keyboard, the button that sends it. Nothing is sent by itself. + */ +@Composable +internal fun PasswordStepScaffold( + text: PasswordStepText, + input: PasswordInput, + submit: PasswordStepSubmit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onEdit: () -> Unit = {}, + footerExtra: @Composable ColumnScope.() -> Unit = {}, +) { + PinEntryScaffold( + title = text.title, + subtitle = text.subtitle, + subtitleIsError = text.subtitleIsError, + icon = EnclyIcons.Key, + compact = true, + modifier = modifier, + footer = { + EnclyButton(text = submit.text, onClick = submit.onClick, enabled = enabled && input.canSubmit) + footerExtra() + }, + ) { + PasswordField( + input = input, + label = text.label, + enabled = enabled, + onEdit = onEdit, + onDone = { if (enabled && input.canSubmit) submit.onClick() }, + modifier = Modifier + .padding(horizontal = EnclyTheme.spacing.gutter) + .padding(top = EnclyTheme.spacing.l), + ) + } +} + +/** + * The words of a [PasswordStepScaffold]: its title, the sub-heading (an error or a lockout countdown when + * [subtitleIsError]) and the field's label. + */ +internal class PasswordStepText( + val title: String, + val subtitle: String, + val label: String, + val subtitleIsError: Boolean = false, +) + +/** The button of a [PasswordStepScaffold]. */ +internal class PasswordStepSubmit(val text: String, val onClick: () -> Unit) 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 index f578beec..b4cc5563 100644 --- 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 @@ -4,7 +4,8 @@ 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.MAX_PIN_LENGTH +import com.pasich.encly.core.security.MIN_PIN_LENGTH import com.pasich.encly.core.security.SensitiveDataCleaner /** @@ -13,17 +14,20 @@ import com.pasich.encly.core.security.SensitiveDataCleaner */ @Stable internal class PinBuffer { - private val digits = CharArray(PIN_LENGTH) + private val digits = CharArray(MAX_PIN_LENGTH) /** How many digits are typed. */ var length by mutableIntStateOf(0) private set - val isFull: Boolean get() = length == PIN_LENGTH + val isFull: Boolean get() = length == MAX_PIN_LENGTH - /** Adds [digit] (0-9); ignored once the PIN is complete. Returns whether it was added. */ + /** 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 >= PIN_LENGTH || digit !in 0..MAX_DIGIT) return false + if (length >= MAX_PIN_LENGTH || digit !in 0..MAX_DIGIT) return false digits[length] = '0' + digit length++ return true diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt index f88c3b64..5d37386e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PinEntryScaffold.kt @@ -32,6 +32,7 @@ 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 @@ -191,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( @@ -202,6 +209,7 @@ fun PinEntry( error: Boolean = false, shakeKey: Int = 0, enabled: Boolean = true, + submit: PinSubmit? = null, ) { Column(modifier = modifier.fillMaxWidth()) { PinDots( @@ -221,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/settings/DuressPinScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/DuressPinScreen.kt index 440bf4e5..d6237b5c 100644 --- 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 @@ -160,6 +160,7 @@ private fun DuressPinOptions(info: DuressPinInfo, actions: DuressPinActions, mod style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + PinOnlyNote() Text( text = info.statusText(), style = MaterialTheme.typography.titleSmall, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt new file mode 100644 index 00000000..c8d9b5fe --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt @@ -0,0 +1,460 @@ +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.consumeWindowInsets +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.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.BackHandler +import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.cancel +import com.pasich.encly.core.resources.lock_password_label +import com.pasich.encly.core.resources.password_change +import com.pasich.encly.core.resources.password_confirm_label +import com.pasich.encly.core.resources.password_current_subtitle +import com.pasich.encly.core.resources.password_current_title +import com.pasich.encly.core.resources.password_current_wrong +import com.pasich.encly.core.resources.password_edit_title +import com.pasich.encly.core.resources.password_failed +import com.pasich.encly.core.resources.password_intro +import com.pasich.encly.core.resources.password_mismatch +import com.pasich.encly.core.resources.password_new_label +import com.pasich.encly.core.resources.password_pin_only_note +import com.pasich.encly.core.resources.password_remove +import com.pasich.encly.core.resources.password_remove_confirm +import com.pasich.encly.core.resources.password_remove_confirm_body +import com.pasich.encly.core.resources.password_remove_confirm_title +import com.pasich.encly.core.resources.password_removed +import com.pasich.encly.core.resources.password_save +import com.pasich.encly.core.resources.password_saved +import com.pasich.encly.core.resources.password_set +import com.pasich.encly.core.resources.password_title +import com.pasich.encly.core.resources.password_too_short +import com.pasich.encly.core.resources.password_turned_off +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_current_wrong +import com.pasich.encly.core.resources.pin_remove +import com.pasich.encly.core.resources.pin_remove_confirm +import com.pasich.encly.core.resources.pin_remove_confirm_body +import com.pasich.encly.core.resources.pin_remove_confirm_title +import com.pasich.encly.core.resources.pin_remove_failed +import com.pasich.encly.core.resources.pin_removed +import com.pasich.encly.core.security.PasswordChange +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.EnclySnackbarHost +import com.pasich.encly.presentation.designsystem.EnclyTextButton +import com.pasich.encly.presentation.designsystem.EnclyTopBar +import com.pasich.encly.presentation.screen.pincode.PasswordField +import com.pasich.encly.presentation.screen.pincode.PasswordInput +import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold +import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit +import com.pasich.encly.presentation.screen.pincode.PasswordStepText +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 org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +/** What the password page asks of the Security ViewModel, so the ViewModel itself stays in [PasswordScreen]. */ +private class PasswordActions( + val verifyPin: (CharArray, (Boolean) -> Unit) -> Unit, + val verifyPassword: (CharArray, (Boolean) -> Unit) -> Unit, + val lockoutMillis: () -> Long, + val setPassword: (CharArray, (PasswordChange) -> Unit) -> Unit, + val removePassword: ((Boolean) -> Unit) -> Unit, + val removePin: ((Boolean) -> Unit) -> Unit, +) + +private enum class PasswordStep { VERIFY, OPTIONS, EDIT, REMOVE_PIN } + +/** + * The state of Settings → Security → Password: who is asking is checked first (the PIN, or the password when the + * vault has no PIN), then the options page, then the new password typed twice. The typed secrets are wiped once + * used and do not outlive the screen (see [PinBuffer], [PasswordInput]). + */ +private class PasswordFlowState(val verifyWithPin: Boolean) { + var step by mutableStateOf(PasswordStep.VERIFY) + val pin = PinBuffer() + val current = PasswordInput() + val newPassword = PasswordInput() + val confirm = PasswordInput() + var errorText by mutableStateOf(null) + var lockoutSeconds by mutableLongStateOf(0L) + + /** Changes on every wrong entry, so the dots shake again. */ + var shakeKey by mutableIntStateOf(0) + var message by mutableStateOf(null) + val snackbarHostState = SnackbarHostState() + + val lockedOut: Boolean get() = lockoutSeconds > 0L + + fun clear() { + pin.clear() + current.clear() + newPassword.clear() + confirm.clear() + } + + /** Back from the edit page (or the PIN removal) to the options; false when there is nothing to go back to here. */ + fun back(): Boolean { + if (step != PasswordStep.EDIT && step != PasswordStep.REMOVE_PIN) return false + newPassword.clear() + confirm.clear() + current.clear() + errorText = null + step = PasswordStep.OPTIONS + return true + } + + /** The PIN removal ended: announce [removed] on the options page. */ + fun pinRemoved(removed: Boolean) { + message = if (removed) Res.string.pin_removed else Res.string.pin_remove_failed + errorText = null + step = PasswordStep.OPTIONS + } + + /** The answer of the check of who is asking. */ + fun onVerified(ok: Boolean, lockoutMillis: Long, wrong: StringResource) { + val lockout = lockoutSecondsLeft(lockoutMillis) + errorText = null + when { + ok -> step = PasswordStep.OPTIONS + + // A locked-out secret is refused even when right: say so, not "wrong". + lockout > 0L -> { + lockoutSeconds = lockout + shakeKey++ + } + + else -> { + errorText = wrong + shakeKey++ + } + } + } + + /** The new password was refused: stay on the edit page with [error]. */ + fun refused(error: StringResource) { + errorText = error + confirm.clear() + } + + fun saved() { + newPassword.clear() + confirm.clear() + errorText = null + message = Res.string.password_saved + step = PasswordStep.OPTIONS + } +} + +/** + * Settings → Security → Password: set, change or remove the password, and remove the PIN. The PIN (or, with no PIN, the + * password) is asked for first, as for every security change. At least one of the two always remains. + */ +@Composable +fun PasswordScreen( + navController: NavHostController, + modifier: Modifier = Modifier, + securityViewModel: SecuritySettingsViewModel = metroViewModel(), +) { + val state = remember { PasswordFlowState(verifyWithPin = securityViewModel.hasPinSlot()) } + val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() + val actions = remember(securityViewModel) { + PasswordActions( + verifyPin = securityViewModel::verifyCurrentPin, + verifyPassword = securityViewModel::verifyCurrentPassword, + lockoutMillis = securityViewModel::pinLockoutRemainingMillis, + setPassword = securityViewModel::setPassword, + removePassword = securityViewModel::removePassword, + removePin = securityViewModel::removePin, + ) + } + PinLockoutTicker(state.lockedOut, actions.lockoutMillis) { state.lockoutSeconds = it } + DisposableEffect(state) { onDispose { state.clear() } } + BackHandler(enabled = state.step == PasswordStep.EDIT || state.step == PasswordStep.REMOVE_PIN) { state.back() } + PinFlowMessageEffect(state.message, state.snackbarHostState, onShow = { state.message = null }) + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + EnclyTopBar( + title = stringResource(Res.string.password_title), + onBack = { if (!state.back()) navController.popBackStack() }, + ) + }, + snackbarHost = { EnclySnackbarHost(state.snackbarHostState) }, + modifier = modifier.fillMaxSize(), + ) { paddingValues -> + val content = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues) + when (state.step) { + PasswordStep.VERIFY -> VerifyStep(state, actions, content) + + PasswordStep.OPTIONS -> PasswordOptions( + state = state, + hasPassword = securityState.hasPassword, + hasPin = securityState.hasPinSlot, + turnedOff = securityState.passwordTurnedOff, + actions = actions, + modifier = content, + ) + + PasswordStep.EDIT -> PasswordEdit(state, actions, content) + + PasswordStep.REMOVE_PIN -> RemovePinStep(state, actions, content) + } + } +} + +@Composable +private fun VerifyStep(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { + val error = state.errorText?.let { stringResource(it) } + val lockout = if (state.lockedOut) pinLockoutText(state.lockoutSeconds) else null + if (state.verifyWithPin) { + val submit = { + if (state.pin.canSubmit && !state.lockedOut) { + actions.verifyPin(state.pin.take()) { ok -> + state.onVerified(ok, actions.lockoutMillis(), Res.string.pin_current_wrong) + } + } + } + PinEntryScaffold( + title = stringResource(Res.string.pin_change_current_title), + subtitle = lockout ?: error ?: stringResource(Res.string.pin_change_current_subtitle), + subtitleIsError = lockout != null || error != null, + compact = true, + bodyAtBottom = true, + modifier = modifier, + ) { + PinEntry( + entered = state.pin.length, + error = error != null && state.pin.length == 0, + shakeKey = state.shakeKey, + enabled = !state.lockedOut, + actions = PinEntryActions( + onDigit = { digit -> + state.errorText = null + state.pin.add(digit) + }, + onBackspace = state.pin::deleteLast, + ), + submit = PinSubmit( + text = stringResource(Res.string.action_continue), + enabled = state.pin.canSubmit, + onClick = submit, + ), + ) + } + } else { + val submit = { + actions.verifyPassword(state.current.take()) { ok -> + state.onVerified(ok, actions.lockoutMillis(), Res.string.password_current_wrong) + } + } + PasswordStepScaffold( + text = PasswordStepText( + title = stringResource(Res.string.password_current_title), + subtitle = lockout ?: error ?: stringResource(Res.string.password_current_subtitle), + label = stringResource(Res.string.lock_password_label), + subtitleIsError = lockout != null || error != null, + ), + input = state.current, + submit = PasswordStepSubmit(stringResource(Res.string.action_continue), submit), + enabled = !state.lockedOut, + onEdit = { state.errorText = null }, + modifier = modifier, + ) + } +} + +@Composable +private fun PasswordEdit(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { + val spacing = EnclyTheme.spacing + val canSave = state.newPassword.canSubmit && state.confirm.canSubmit + val save = { + if (canSave) { + if (state.newPassword.text != state.confirm.text) { + state.refused(Res.string.password_mismatch) + } else { + state.confirm.clear() + actions.setPassword(state.newPassword.take()) { result -> + when (result) { + PasswordChange.SET -> state.saved() + PasswordChange.INVALID -> state.refused(Res.string.password_too_short) + PasswordChange.FAILED -> state.refused(Res.string.password_failed) + } + } + } + } + } + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), + ) { + Text( + text = stringResource(Res.string.password_edit_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + PasswordField( + input = state.newPassword, + label = stringResource(Res.string.password_new_label), + imeAction = ImeAction.Next, + onEdit = { state.errorText = null }, + ) + PasswordField( + input = state.confirm, + label = stringResource(Res.string.password_confirm_label), + error = state.errorText?.let { stringResource(it) }, + onEdit = { state.errorText = null }, + onDone = save, + ) + EnclyButton( + text = stringResource(Res.string.password_save), + onClick = save, + enabled = canSave, + modifier = Modifier.padding(top = spacing.s), + ) + } +} + +@Composable +private fun PasswordOptions( + state: PasswordFlowState, + hasPassword: Boolean, + hasPin: Boolean, + turnedOff: Boolean, + actions: PasswordActions, + modifier: Modifier = Modifier, +) { + val spacing = EnclyTheme.spacing + var confirmingRemove by rememberSaveable { mutableStateOf(false) } + if (confirmingRemove) { + RemovePasswordDialog( + onConfirm = { + confirmingRemove = false + actions.removePassword { ok -> + state.message = if (ok) Res.string.password_removed else Res.string.password_failed + } + }, + onDismiss = { confirmingRemove = false }, + ) + } + EnclyScrollColumn( + verticalArrangement = Arrangement.spacedBy(spacing.m), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), + ) { + if (turnedOff && !hasPassword) { + EnclyCallout(text = stringResource(Res.string.password_turned_off), tone = CalloutTone.WARNING) + } + Text( + text = stringResource(Res.string.password_intro), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + EnclyCallout(text = stringResource(Res.string.password_pin_only_note)) + Column( + verticalArrangement = Arrangement.spacedBy(spacing.xxs), + modifier = Modifier.fillMaxWidth().padding(top = spacing.s), + ) { + EnclyButton( + text = stringResource(if (hasPassword) Res.string.password_change else Res.string.password_set), + onClick = { state.step = PasswordStep.EDIT }, + ) + // The last way in stays: each of the two can go only while the other is set. + if (hasPassword && hasPin) { + RemoveButton(Res.string.password_remove) { confirmingRemove = true } + RemoveButton(Res.string.pin_remove) { state.step = PasswordStep.REMOVE_PIN } + } + } + } +} + +@Composable +private fun RemoveButton(text: StringResource, onClick: () -> Unit) { + EnclyTextButton( + text = stringResource(text), + onClick = onClick, + destructive = true, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun RemovePasswordDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + EnclyDialog( + title = stringResource(Res.string.password_remove_confirm_title), + text = stringResource(Res.string.password_remove_confirm_body), + onDismissRequest = onDismiss, + confirm = DialogAction(stringResource(Res.string.password_remove_confirm), onConfirm, destructive = true), + dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), + ) +} + +/** + * Turning the PIN off needs the password typed again: it proves the password opens the vault before the PIN, the + * other way in, is gone. The wipe and duress PINs go with the PIN. + */ +@Composable +private fun RemovePinStep(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { + val error = state.errorText?.let { stringResource(it) } + val lockout = if (state.lockedOut) pinLockoutText(state.lockoutSeconds) else null + val submit = { + actions.verifyPassword(state.current.take()) { ok -> + if (ok) { + actions.removePin(state::pinRemoved) + } else { + state.onVerified(false, actions.lockoutMillis(), Res.string.password_current_wrong) + } + } + } + PasswordStepScaffold( + text = PasswordStepText( + title = stringResource(Res.string.pin_remove_confirm_title), + subtitle = lockout ?: error ?: stringResource(Res.string.pin_remove_confirm_body), + label = stringResource(Res.string.lock_password_label), + subtitleIsError = lockout != null || error != null, + ), + input = state.current, + submit = PasswordStepSubmit(stringResource(Res.string.pin_remove_confirm), submit), + enabled = !state.lockedOut, + onEdit = { state.errorText = null }, + modifier = modifier, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt new file mode 100644 index 00000000..5de85d85 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt @@ -0,0 +1,62 @@ +package com.pasich.encly.presentation.screen.settings + +import androidx.compose.foundation.layout.padding +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.auth_method_pin_desc +import com.pasich.encly.core.resources.auth_method_pin_none +import com.pasich.encly.core.resources.auth_method_pin_title +import com.pasich.encly.core.resources.password_pin_only_note +import com.pasich.encly.core.resources.password_row_off +import com.pasich.encly.core.resources.password_row_on +import com.pasich.encly.core.resources.password_row_turned_off +import com.pasich.encly.core.resources.password_title +import com.pasich.encly.presentation.designsystem.EnclyIcons +import com.pasich.encly.presentation.designsystem.EnclyNavigationRow +import com.pasich.encly.ui.theme.EnclyTheme +import org.jetbrains.compose.resources.stringResource + +/** "PIN": change it, or add one to a vault that has only a password. */ +@Composable +internal fun PinRow(hasPin: Boolean, onClick: () -> Unit) { + EnclyNavigationRow( + title = stringResource(Res.string.auth_method_pin_title), + supporting = stringResource( + if (hasPin) Res.string.auth_method_pin_desc else Res.string.auth_method_pin_none, + ), + icon = EnclyIcons.Key, + onClick = onClick, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** "Password": another way to unlock; after a key reset it says that it was turned off. */ +@Composable +internal fun PasswordRow(hasPassword: Boolean, turnedOff: Boolean, onClick: () -> Unit) { + EnclyNavigationRow( + title = stringResource(Res.string.password_title), + supporting = stringResource( + when { + hasPassword -> Res.string.password_row_on + turnedOff -> Res.string.password_row_turned_off + else -> Res.string.password_row_off + }, + ), + icon = EnclyIcons.Lock, + onClick = onClick, + modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), + ) +} + +/** The wipe PIN and the duress PIN can only be PINs: a password is never one of them. */ +@Composable +internal fun PinOnlyNote() { + Text( + text = stringResource(Res.string.password_pin_only_note), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt index dc37f8b6..8572d414 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue 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.pin_change_current_title import com.pasich.encly.core.resources.pin_current_wrong import com.pasich.encly.core.resources.pin_mismatch_retry @@ -31,6 +32,7 @@ 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 @@ -187,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 }) @@ -205,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), ) } @@ -213,7 +213,7 @@ internal fun PinFlowScaffold( /** Shows [message] once as a snackbar; [onShow] clears it. */ @Composable -private fun PinFlowMessageEffect(message: StringResource?, snackbarHostState: SnackbarHostState, onShow: () -> Unit) { +internal 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. @@ -242,7 +242,7 @@ private fun pinFlowStepText(state: PinFlowState, texts: PinFlowTexts): PinStepTe } @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), @@ -259,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/SecuritySettingsScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/SecuritySettingsScreen.kt index 6212ffc2..dcf41a73 100644 --- 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 @@ -21,8 +21,6 @@ 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 @@ -40,7 +38,6 @@ 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 @@ -199,12 +196,12 @@ private fun SecurityRows( 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), + PinRow(hasPin = securityState.hasPinSlot, onClick = { actions.onOpen(NavRoutes.PinCodeConfig) }) + EnclyGroupDivider() + PasswordRow( + hasPassword = securityState.hasPassword, + turnedOff = securityState.passwordTurnedOff, + onClick = { actions.onOpen(NavRoutes.PasswordRoute) }, ) if (securityState.authType != AuthType.NONE) { EnclyGroupDivider() 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 index 71ae3463..119fd154 100644 --- 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 @@ -152,6 +152,7 @@ private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + PinOnlyNote() Column( verticalArrangement = Arrangement.spacedBy(spacing.xxs), modifier = Modifier.fillMaxWidth().padding(top = spacing.s), diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index 2db29eba..eb4c58c8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -12,10 +12,12 @@ 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_password 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -48,14 +50,16 @@ sealed interface BackupStep { data object Idle : BackupStep /** - * Re-authentication (PIN, or biometric when enabled) before any export or import. [failures] - * counts wrong PINs, so each one shakes the dots, even with the same error. + * Re-authentication (PIN or password, or biometric when enabled) before any export or import. [failures] + * counts wrong secrets, so each one shakes the dots, even with the same error. */ data class Reauth( val action: BackupAction, val biometric: Boolean, val error: StringResource? = null, val failures: Int = 0, + /** Which secrets the vault has: the dialog asks for the PIN, or the password, or lets the user pick. */ + val methods: UnlockMethods = UnlockMethods(pin = true, password = false), ) : BackupStep /** Export needs a recovery phrase and this vault has none: explain and offer to create it. */ @@ -179,7 +183,7 @@ internal fun withNormalizedPhrase(backupManager: BackupManager, input: CharA } /** - * Re-authentication before any export or import: the PIN, or biometric when enabled. + * Re-authentication before any export or import: the PIN or the password, or biometric when enabled. * [onAuthenticated] continues the flow that was started. */ class ReauthFlow( @@ -217,6 +221,24 @@ class ReauthFlow( } } + /** [submitPin] for a password; counted with the PIN's attempts and locked out with them. */ + fun submitPassword(password: CharArray) { + val step = state.step as? BackupStep.Reauth + if (step == null || pinLockoutRemainingMillis() > 0L) { + SensitiveDataCleaner.clear(password) + if (step != null) state.go(step.copy(error = null)) + return + } + state.launchBusy { + val ok = withContext(Dispatchers.Default) { securityManager.verifyPassword(password) } + if (ok) { + onAuthenticated(step.action) + } else { + state.go(step.copy(error = Res.string.lock_wrong_password, failures = step.failures + 1)) + } + } + } + fun withBiometric(host: ScreenBiometricHost) { val step = state.step as? BackupStep.Reauth ?: return securityManager.confirmBiometric(host.biometric) { ok -> diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index e1777ce8..7e61fb0f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -77,7 +77,7 @@ class BackupViewModel( this.action = action reauthGeneration = sessionLockManager.sessionGeneration val biometric = securityManager.canReauthWithBiometric() - state.go(BackupStep.Reauth(action, biometric)) + state.go(BackupStep.Reauth(action, biometric, methods = securityManager.unlockMethods())) } private suspend fun proceed(action: BackupAction) { 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 index 17bb7771..4848a6ab 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -5,9 +5,11 @@ 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.UnlockMethods import com.pasich.encly.core.security.VaultUnlockResult import com.pasich.encly.platform.ScreenBiometricHost import dev.zacsweers.metro.AppScope @@ -28,9 +30,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** + * The result of a PIN or a password attempt (WRONG_PIN: the wrong secret of either kind). * 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. + * KEY_LOST: the device-bound 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 } @@ -72,7 +75,16 @@ class LockViewModel( * 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.canResetPinWithoutCurrent() + 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() + + /** Which of the PIN and the password this vault has; the lock screen offers each that is set. */ + fun unlockMethods(): UnlockMethods = securityManager.unlockMethods() /** Whether the session is closed (again); an unlock reveal then must not open Home. */ fun isSessionLocked(): Boolean = sessionLockManager.locked.value @@ -102,6 +114,25 @@ class LockViewModel( } } + /** Unlocks with [password], which is wiped. Counted and locked out together with the PIN. */ + fun authenticatePassword(password: CharArray, onResult: (PinUnlockResult) -> Unit) { + launchUnlock(onResult) { + val result = withContext(Dispatchers.Default) { + when (securityManager.unlockWithPassword(password)) { + 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 + } + } + publish(result == PinUnlockResult.SUCCESS, result, PinUnlockResult.BACKGROUNDED) + } + } + + /** "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) { 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 index 1a53b93c..e48576ff 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -123,7 +123,7 @@ class MarkdownExportViewModel( 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)) + state.go(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric, methods = securityManager.unlockMethods())) } private fun proceed(action: BackupAction) { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 0c2e4e6b..8b037ebd 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -14,7 +14,8 @@ 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.PIN_LENGTH +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 @@ -110,7 +111,7 @@ class OnboardingViewModel( 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 @@ -172,7 +173,7 @@ class OnboardingViewModel( 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) } } @@ -194,8 +195,8 @@ class OnboardingViewModel( */ 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) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index 93b3d1e3..df75b66f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -15,6 +15,7 @@ import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.KeyboardPrivacy +import com.pasich.encly.core.security.PasswordChange import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager @@ -85,6 +86,8 @@ class SecuritySettingsViewModel( biometricEnable = authSettings.isBiometricEnabled, biometricStatus = securityManager.biometricStatus(), hasPinSlot = authSettings.hasPinSlot, + hasPassword = authSettings.hasPassword, + passwordTurnedOff = authSettings.passwordTurnedOff, wipePinTurnedOff = authSettings.wipePinTurnedOff, hasDuressPin = authSettings.hasDuressPin, duressPinTurnedOff = authSettings.duressPinTurnedOff, @@ -101,6 +104,53 @@ class SecuritySettingsViewModel( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() + /** The vault has a PIN to ask for; without one (only a password) re-authentication asks for the password. */ + fun hasPinSlot(): Boolean = securityManager.unlockMethods().pin + + /** Checks [password] against the vault, counted with the PIN's attempts; [password] is wiped. */ + fun verifyCurrentPassword(password: CharArray, onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = withContext(Dispatchers.Default) { securityManager.verifyPassword(password) } + onResult(ok) + } + } + + /** Makes [password] the vault's password, replacing any earlier one; [password] is wiped. */ + fun setPassword(password: CharArray, onResult: (PasswordChange) -> Unit) { + viewModelScope.launch { + val result = withContext(Dispatchers.Default) { securityManager.changePassword(password) } + if (result == PasswordChange.SET) { + _uiState.value = _uiState.value.copy(hasPassword = true, passwordTurnedOff = false) + } + onResult(result) + } + } + + /** Turns the password off; false while it is the only way in. */ + fun removePassword(onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = withContext(platformIoDispatcher) { securityManager.removePassword() } + if (ok) _uiState.value = _uiState.value.copy(hasPassword = false, passwordTurnedOff = false) + onResult(ok) + } + } + + /** + * Turns the PIN off (and with it the wipe and duress PINs); false while it is the only way in. The password + * was just checked by the screen, which proves it still opens the vault. + */ + fun removePin(onResult: (Boolean) -> Unit) { + viewModelScope.launch { + val ok = withContext(Dispatchers.Default) { securityManager.removePin() } + if (ok) { + _uiState.value = _uiState.value.copy(hasPinSlot = false) + // The wipe and duress PINs went with it; the decoy vault's notes stay for a new duress PIN. + refresh() + } + onResult(ok) + } + } + /** Checks [pin] against the vault; [pin] is wiped. */ fun verifyCurrentPin(pin: CharArray, onResult: (Boolean) -> Unit) { viewModelScope.launch { @@ -129,7 +179,7 @@ class SecuritySettingsViewModel( } } 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 @@ -235,6 +285,10 @@ class SecuritySettingsViewModel( val biometricStatus: BiometricStatus = BiometricStatus.UNAVAILABLE, /** A PIN slot exists, so a wipe PIN can be set next to it. */ val hasPinSlot: Boolean = false, + /** A password is set, as another way in or the only one. */ + val hasPassword: Boolean = false, + /** The device key was reset and the password with it; setting it again brings it back. */ + val passwordTurnedOff: Boolean = false, /** The PIN key was reset and any wipe PIN with it; Settings asks to set it again. */ val wipePinTurnedOff: Boolean = false, /** The real vault has a duress PIN. */ diff --git a/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt index 4ee00535..11260b62 100644 --- a/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt +++ b/shared/src/iosMain/kotlin/com/pasich/encly/ios/IosApp.kt @@ -32,8 +32,8 @@ 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.LocalSensitiveClipboard 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 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 index ae13e931..cb3ec1dd 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt @@ -2,6 +2,7 @@ package com.pasich.encly.presentation.screen import com.pasich.encly.core.resources.Res import com.pasich.encly.core.resources.lock_pin_key_lost +import com.pasich.encly.core.resources.lock_wrong_password 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 @@ -108,4 +109,94 @@ class LockFormStateTest { 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) + } + + @Test + fun aWrongPasswordShowsItsErrorAndKeepsThePinPadClean() { + form.usePassword = true + form.password.text = "not the password" + + form.onPasswordResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) + + assertEquals(Res.string.lock_wrong_password, form.passwordError) + assertNull(form.pinError) + form.onPasswordEdited() + assertNull(form.passwordError) + } + + @Test + fun aPasswordFailureStartsTheSameLockoutCountdown() { + form.onPasswordResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 30_000L) + + assertEquals(30L, form.lockoutSeconds) + assertTrue(form.lockedOut) + } + + @Test + fun aLostKeyExplainsItOnThePasswordFormAndTheRecoveryForm() { + form.onPasswordResult(PinUnlockResult.KEY_LOST, lockoutRemainingMillis = 0L) + + assertEquals(Res.string.lock_pin_key_lost, form.passwordError) + assertEquals(Res.string.lock_pin_key_lost, form.phraseError) + } + + @Test + fun switchingMethodsDropsWhatWasTyped() { + typePin("123456") + form.switchTo(usePassword = true) + + assertTrue(form.usePassword) + assertEquals(0, form.pinLength) + + form.password.text = "correct horse" + form.switchTo(usePassword = false) + + assertFalse(form.usePassword) + assertEquals("", form.password.text) + } + + @Test + fun thePasswordIsTakenOutOfTheFormToBeChecked() { + form.password.text = "correct horse" + assertTrue(form.password.canSubmit) + + val taken = form.takePassword() + + assertEquals("correct horse", String(taken)) + assertEquals("", form.password.text) + assertFalse(form.password.canSubmit) + } + + @Test + fun aPasswordNeedsEightCharactersCountedAsCodePoints() { + form.password.text = "1234567" + assertFalse(form.password.canSubmit) + + form.password.text = "1234567\uD83D\uDD11" + + assertEquals(8, form.password.length) + assertTrue(form.password.canSubmit) + } } 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 index b10b4cee..5e42257c 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -7,8 +7,10 @@ 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.RecoveryStage import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager +import com.pasich.encly.core.security.UnlockMethods import com.pasich.encly.core.security.VaultUnlockResult import com.pasich.encly.testutil.TestScreen import com.pasich.encly.testutil.answerCallback @@ -113,11 +115,71 @@ class LockViewModelTest { @Test fun theRecoveryPhraseResetIsAskedOfTheSecurityLayer() { - `when`(security.canResetPinWithoutCurrent()).thenReturn(true) + `when`(security.recoveryStage()).thenReturn(RecoveryStage.MUST_SET_PIN) assertTrue(viewModel.mustResetPin()) + assertEquals(RecoveryStage.MUST_SET_PIN, viewModel.recoveryStage()) - `when`(security.canResetPinWithoutCurrent()).thenReturn(false) + // 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 theLockScreenAsksWhichMethodsTheVaultHas() { + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = true)) + + assertEquals(UnlockMethods(pin = true, password = true), viewModel.unlockMethods()) + } + + @Test + fun theRightPasswordUnlocks() = runTest { + `when`(security.unlockWithPassword(PASSWORD.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) + + assertEquals(PinUnlockResult.SUCCESS, password()) + assertFalse(viewModel.busy.value) + assertFalse(sessionLock.locked.value) + verify(security, never()).unlockWithPin(anyCharArray()) + } + + @Test + fun aPasswordResultIsToldApartLikeAPinResult() = runTest { + `when`(security.unlockWithPassword(PASSWORD.toCharArray())) + .thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) + .thenReturn(VaultUnlockResult.LOCKED_OUT) + .thenReturn(VaultUnlockResult.PIN_KEY_LOST) + .thenReturn(VaultUnlockResult.DB_ERROR) + + assertEquals(PinUnlockResult.WRONG_PIN, password()) + assertEquals(PinUnlockResult.LOCKED_OUT, password()) + assertEquals(PinUnlockResult.KEY_LOST, password()) + assertEquals(PinUnlockResult.DB_ERROR, password()) + } + + @Test + fun aPasswordUnlockThatLandsInTheBackgroundIsClosedAgain() = runTest { + `when`(security.isLockable()).thenReturn(true) + doAnswer { + sessionLock.lockNow() + VaultUnlockResult.SUCCESS + }.`when`(security).unlockWithPassword(PASSWORD.toCharArray()) + + assertEquals(PinUnlockResult.BACKGROUNDED, password()) + + verify(security).lock() + assertTrue(sessionLock.locked.value) } @Test @@ -396,6 +458,12 @@ class LockViewModelTest { return result.await() } + private suspend fun password(): PinUnlockResult { + val result = CompletableDeferred() + viewModel.authenticatePassword(PASSWORD.toCharArray()) { result.complete(it) } + return result.await() + } + private suspend fun seed(words: CharArray): SeedUnlockResult { val result = CompletableDeferred() viewModel.authenticateSeed(words) { result.complete(it) } @@ -410,6 +478,7 @@ class LockViewModelTest { private companion object { const val PIN = "482915" + const val PASSWORD = "correct horse battery" 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 diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index a1f95a7f..d0bb7dd6 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -12,9 +12,11 @@ import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.KeyboardPrivacy +import com.pasich.encly.core.security.PasswordChange 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.UnlockMethods import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.TestScreen @@ -208,6 +210,116 @@ class SecuritySettingsViewModelTest { assertEquals(LOCKOUT_MS, viewModel.pinLockoutRemainingMillis()) } + @Test + fun thePasswordStateIsReadFromTheVaultAndPasswordOnlyHasNoPinToAskFor() = runTest { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings( + AuthType.PIN, + false, + false, + hasPinSlot = false, + hasPassword = true, + passwordTurnedOff = false, + ), + ) + `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) + + viewModel.refresh() + + val state = viewModel.uiState.first { it.hasPassword } + assertFalse(state.hasPinSlot) + assertFalse(viewModel.hasPinSlot()) + } + + @Test + fun aPasswordTurnedOffByAKeyResetIsReported() = runTest { + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings(AuthType.PIN, false, false, passwordTurnedOff = true), + ) + + viewModel.refresh() + + assertTrue(viewModel.uiState.first { it.passwordTurnedOff }.passwordTurnedOff) + } + + @Test + fun theCurrentPasswordIsCheckedAndWipedLikeAPin() = runTest { + `when`(security.verifyPassword(anyCharArray())).thenReturn(true) + val typed = "correct horse".toCharArray() + val result = CompletableDeferred() + + viewModel.verifyCurrentPassword(typed) { result.complete(it) } + + assertTrue(result.await()) + verify(security).verifyPassword(anyCharArray()) + } + + @Test + fun aNewPasswordIsHandedOnAndMarksThePasswordAsSet() = runTest { + `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.SET) + val result = CompletableDeferred() + + viewModel.setPassword("correct horse".toCharArray()) { result.complete(it) } + + assertEquals(PasswordChange.SET, result.await()) + assertTrue(viewModel.uiState.value.hasPassword) + assertFalse(viewModel.uiState.value.passwordTurnedOff) + } + + @Test + fun aRefusedPasswordLeavesTheStateAlone() = runTest { + `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.INVALID) + val result = CompletableDeferred() + + viewModel.setPassword("short".toCharArray()) { result.complete(it) } + + assertEquals(PasswordChange.INVALID, result.await()) + assertFalse(viewModel.uiState.value.hasPassword) + } + + @Test + fun removingThePasswordUpdatesTheState() = runTest { + `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.SET) + `when`(security.removePassword()).thenReturn(true) + viewModel.setPassword("correct horse".toCharArray()) {} + val result = CompletableDeferred() + + viewModel.removePassword { result.complete(it) } + + assertTrue(result.await()) + assertFalse(viewModel.uiState.value.hasPassword) + } + + @Test + fun theLastWayInIsReportedAsNotRemoved() = runTest { + `when`(security.removePassword()).thenReturn(false) + `when`(security.removePin()).thenReturn(false) + val password = CompletableDeferred() + val pin = CompletableDeferred() + + viewModel.removePassword { password.complete(it) } + viewModel.removePin { pin.complete(it) } + + assertFalse(password.await()) + assertFalse(pin.await()) + } + + @Test + fun removingThePinReloadsWhatItTookWithIt() = runTest { + `when`(security.removePin()).thenReturn(true) + `when`(security.getSettingsAuth()).thenReturn( + AuthSettings(AuthType.PIN, false, false, hasPinSlot = false, hasPassword = true, duressPinTurnedOff = true), + ) + val result = CompletableDeferred() + + viewModel.removePin { result.complete(it) } + + assertTrue(result.await()) + val state = viewModel.uiState.first { it.duressPinTurnedOff } + assertFalse(state.hasPinSlot) + assertFalse(state.hasDuressPin) + } + @Test fun aWipePinIsHandedOnAndClearsTheTurnedOffNotice() = runTest { `when`(security.getSettingsAuth()).thenReturn( From db4bbcc43a7fda16d7cd84fc3f5bcb127a816dc0 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 17:29:31 +0200 Subject: [PATCH 55/75] feat(editor): drag list items into place, nested lists up to three levels List items can be dragged by a handle (a long press on touch), moved with Alt+Shift and the arrow keys, and indented with Tab and Shift+Tab up to three levels, with numbering per level and mixed kinds under one item. Markdown import and export keep the nesting. A flat list is saved byte for byte as before; an older version opens a nested list as a flat one with every item. Refs: #76 --- .../commonMain/strings/values-ar/strings.xml | 5 + .../commonMain/strings/values-de/strings.xml | 5 + .../commonMain/strings/values-es/strings.xml | 5 + .../commonMain/strings/values-fr/strings.xml | 5 + .../commonMain/strings/values-it/strings.xml | 5 + .../commonMain/strings/values-nl/strings.xml | 5 + .../commonMain/strings/values-pl/strings.xml | 5 + .../commonMain/strings/values-pt/strings.xml | 5 + .../commonMain/strings/values-uk/strings.xml | 5 + .../src/commonMain/strings/values/strings.xml | 5 + editor/api/android/editor.api | 45 +- editor/api/desktop/editor.api | 45 +- editor/api/editor.klib.api | 36 +- .../encly/editor/ui/NestedListUiTest.kt | 400 +++++++++++++++ .../com/pasich/encly/editor/EditorStrings.kt | 5 + .../pasich/encly/editor/codec/BlockJson.kt | 66 ++- .../encly/editor/codec/MarkdownBlockMapper.kt | 58 ++- .../encly/editor/model/ItemListBlock.kt | 10 +- .../pasich/encly/editor/model/ListLevels.kt | 84 ++++ .../pasich/encly/editor/model/ListMoves.kt | 74 +++ .../pasich/encly/editor/state/BlockTools.kt | 3 +- .../pasich/encly/editor/state/ListEditing.kt | 54 ++- .../pasich/encly/editor/state/ListNesting.kt | 90 ++++ .../encly/editor/state/SelectedBlocksEdits.kt | 20 +- .../encly/editor/state/TextRangeSelection.kt | 18 +- .../pasich/encly/editor/ui/BlockActions.kt | 6 + .../encly/editor/ui/BlockActionsImpl.kt | 6 + .../com/pasich/encly/editor/ui/BlockChrome.kt | 7 +- .../encly/editor/ui/EditorController.kt | 6 + .../encly/editor/ui/blocks/ListBlock.kt | 128 +++-- .../encly/editor/ui/blocks/ListReorder.kt | 361 ++++++++++++++ .../encly/editor/codec/NestedListCodecTest.kt | 271 +++++++++++ .../encly/editor/model/ListLevelsTest.kt | 308 ++++++++++++ .../encly/editor/state/ListNestingTest.kt | 456 ++++++++++++++++++ .../encly/editor/state/TextRangeNestedTest.kt | 187 +++++++ .../pasich/encly/editor/ui/StateController.kt | 8 + .../encly/editor/ui/blocks/ListReorderTest.kt | 190 ++++++++ .../encly/data/export/MarkdownBlocks.kt | 27 +- .../components/editNote/NoteBottomBar.kt | 20 + .../components/editNote/ToolbarState.kt | 21 + .../presentation/designsystem/EnclyIcons.kt | 1 + .../designsystem/EnclyListIcons.kt | 27 ++ .../editnote/EditNoteViewModelController.kt | 6 + .../screen/editnote/EditorBindings.kt | 10 + .../viewmodel/EditNoteViewModel.kt | 17 + .../pasich/encly/utils/NotesTextFormatter.kt | 42 +- .../data/export/NestedListMarkdownTest.kt | 123 +++++ 47 files changed, 3151 insertions(+), 135 deletions(-) create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/NestedListUiTest.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListLevels.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ListMoves.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/ListReorder.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/NestedListCodecTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/model/ListLevelsTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ListNestingTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TextRangeNestedTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/ListReorderTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyListIcons.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/data/export/NestedListMarkdownTest.kt diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index c3164000..13364f9b 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -690,6 +690,11 @@ فهمت تُحذف الملاحظات نهائيًا بعد 28 يومًا في سلة المحذوفات. تحديد الكتلة + إعادة ترتيب العنصر + نقل العنصر لأعلى + نقل العنصر لأسفل + زيادة إزاحة العنصر + تقليل إزاحة العنصر تحديد الكل إلغاء التحديد تم النسخ diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index e7656da6..cc1b2065 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index 6289e84b..d027f59b 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index eadf4716..24a84e71 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 06835957..197bd4bc 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index ee8a5912..a99d1625 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index b7ac5c58..4522434c 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index 78b9696e..67463111 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -53,6 +53,11 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index f2685392..7c1ad1be 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -53,6 +53,11 @@ Заблокувати чекліст Розблокувати чекліст Виділити блок + Перемістити пункт + Перемістити пункт вгору + Перемістити пункт вниз + Збільшити відступ пункту + Зменшити відступ пункту Виділено блоків: %1$d Зняти виділення Копіювати diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index 9b344e01..b3a02565 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -70,6 +70,11 @@ 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 diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index d9613e7d..01bf86da 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -44,7 +44,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -276,15 +281,19 @@ public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { 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;)V - public synthetic fun (Ljava/lang/String;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 copy (Ljava/lang/String;ZLjava/lang/String;)Lcom/pasich/encly/editor/model/ItemListBlock; - public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/ItemListBlock; + 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 @@ -315,6 +324,23 @@ public final class com/pasich/encly/editor/model/LinkDataBlock { 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 abstract interface class com/pasich/encly/editor/model/TextualBlock { public abstract fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; } @@ -507,6 +533,15 @@ public final class com/pasich/encly/editor/state/ListEditingKt { 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 @@ -954,6 +989,8 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 onShortcut (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index d9613e7d..01bf86da 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -44,7 +44,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -276,15 +281,19 @@ public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { 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;)V - public synthetic fun (Ljava/lang/String;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 copy (Ljava/lang/String;ZLjava/lang/String;)Lcom/pasich/encly/editor/model/ItemListBlock; - public static synthetic fun copy$default (Lcom/pasich/encly/editor/model/ItemListBlock;Ljava/lang/String;ZLjava/lang/String;ILjava/lang/Object;)Lcom/pasich/encly/editor/model/ItemListBlock; + 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 @@ -315,6 +324,23 @@ public final class com/pasich/encly/editor/model/LinkDataBlock { 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 abstract interface class com/pasich/encly/editor/model/TextualBlock { public abstract fun getText ()Lkotlinx/coroutines/flow/MutableStateFlow; } @@ -507,6 +533,15 @@ public final class com/pasich/encly/editor/state/ListEditingKt { 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 @@ -954,6 +989,8 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 onShortcut (Lcom/pasich/encly/editor/model/Block;Ljava/lang/String;Lcom/pasich/encly/editor/state/Shortcut;)Z diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index e928a305..6178c09a 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -153,7 +153,12 @@ final enum class com.pasich.encly.editor/EditorString : kotlin/Enum, 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 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] @@ -320,19 +327,25 @@ final class com.pasich.encly.editor.focus/BlockFocusRegistry { // com.pasich.enc } final class com.pasich.encly.editor.model/ItemListBlock { // com.pasich.encly.editor.model/ItemListBlock|null[0] - constructor (kotlin/String = ..., kotlin/Boolean = ..., kotlin/String = ...) // com.pasich.encly.editor.model/ItemListBlock.|(kotlin.String;kotlin.Boolean;kotlin.String){}[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 copy(kotlin/String = ..., kotlin/Boolean = ..., kotlin/String = ...): com.pasich.encly.editor.model/ItemListBlock // com.pasich.encly.editor.model/ItemListBlock.copy|copy(kotlin.String;kotlin.Boolean;kotlin.String){}[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] @@ -1022,6 +1035,9 @@ final object com.pasich.encly.editor.model/BlockFactory { // com.pasich.encly.ed 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] @@ -1095,6 +1111,8 @@ final fun (androidx.compose.foundation.lazy/LazyListScope).com.pasich.encly.edit 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] @@ -1104,7 +1122,9 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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/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] @@ -1112,21 +1132,33 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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/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] 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/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt index 4bd7aeba..511b4854 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt @@ -61,6 +61,11 @@ public enum class EditorString(internal val english: String) { 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"), } /** 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 index 10c3097f..f0062278 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt @@ -4,6 +4,7 @@ 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 @@ -81,12 +82,19 @@ internal object BlockJson { put( "items", JsonArray( - block.items.value.filter { it.value.isNotBlank() }.map { item -> - buildJsonObject { - put("value", item.value) - put("isCheck", item.isCheck) - } - }, + 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 @@ -127,29 +135,39 @@ internal object BlockJson { "SEPARATOR" -> Block.SeparatorBlock() // "LIST" kept as a legacy alias for checklists. - "LIST", "LIST_CHECK", "LIST_NUMBER", "LIST_BULLET" -> Block.ListBlock( - items = MutableStateFlow( - json.array("items")?.map { item -> - val itemObject = item.asObject() - ItemListBlock( - value = itemObject.string("value").orEmpty(), - isCheck = itemObject.string("isCheck").toBoolean(), - ) - } ?: 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(json.string("isLocked").toBoolean()), - ) + "LIST", "LIST_CHECK", "LIST_NUMBER", "LIST_BULLET" -> listBlock(json, blockType) 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 == null) 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") 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 index 41564d13..33e2a198 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -4,12 +4,15 @@ 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 /** * 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); + * 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. @@ -61,6 +64,30 @@ public object MarkdownBlockMapper : MarkdownBlockParser { 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 { @@ -105,6 +132,8 @@ public object MarkdownBlockMapper : MarkdownBlockParser { private val code = ArrayList() private val items = ArrayList() private var listType: BlockType? = null + + private val indents = IndentLevels() private var inFence = false fun line(raw: String) { @@ -170,13 +199,13 @@ public object MarkdownBlockMapper : MarkdownBlockParser { blocks += ImportBlocks.heading(readable(heading.title), heading.level) } - check != null -> item(BlockType.LIST_CHECK, check.groupValues[2], check.groupValues[1] != " ") + 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] != "☐") + glyph != null -> item(BlockType.LIST_CHECK, glyph.groupValues[2], glyph.groupValues[1] != "☐", raw) - bullet != null -> item(BlockType.LIST_BULLET, bullet.groupValues[1], false) + bullet != null -> item(BlockType.LIST_BULLET, bullet.groupValues[1], false, raw) - number != null -> item(BlockType.LIST_NUMBER, number.groupValues[1], false) + number != null -> item(BlockType.LIST_NUMBER, number.groupValues[1], false, raw) quoted != null -> { flushParagraph() @@ -210,12 +239,19 @@ public object MarkdownBlockMapper : MarkdownBlockParser { /** [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) { + private fun item(type: BlockType, text: String, checked: Boolean, raw: String) { flushParagraph() flushQuote() - if (listType != type) flushList() - listType = type - items += ItemListBlock(value = readable(text).trim(), isCheck = checked) + 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() { @@ -248,8 +284,10 @@ public object MarkdownBlockMapper : MarkdownBlockParser { 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() } + 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/model/ItemListBlock.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt index 2c77bcd7..0eb22e31 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/ItemListBlock.kt @@ -5,10 +5,18 @@ 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 only [value] and [isCheck]), so stored notes are unchanged. + * 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/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/state/BlockTools.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt index 55329d6b..19f4ae8d 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt @@ -68,6 +68,7 @@ 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) @@ -103,7 +104,7 @@ private fun convertText(block: Block, text: String, tool: BlockType): Block? = w 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), blockType = tool) + Block.ListBlock(MutableStateFlow(items.map { it.copy(kind = null) }), blockType = tool) } // Only an empty paragraph becomes a link; text is never thrown away. 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 index b20c342d..db248fe6 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt @@ -2,6 +2,8 @@ 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 @@ -18,9 +20,9 @@ public fun BlockEditorState.listLineBreak(listId: String, itemId: String, lineBr } /** Item [at] of [list], the block at [index]. */ -private data class ListItemAt(val index: Int, val list: Block.ListBlock, val at: Int) +internal data class ListItemAt(val index: Int, val list: Block.ListBlock, val at: Int) -private fun BlockEditorState.itemAt(listId: String, itemId: String): ListItemAt? { +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 @@ -33,8 +35,10 @@ private fun BlockEditorState.listPaste(item: ListItemAt, lineBreak: LineBreak): 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 here = items[at] + // 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 + lineBreak.tail) val newItems = items.toMutableList().apply { this[at] = items[at].copy(value = kept) @@ -50,21 +54,27 @@ private fun BlockEditorState.listEnter(where: ListItemAt, head: String, tail: St 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() -> { - exitList(index, list, at, from = CaretTarget(list.id, item.id, 0)) + 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() -> { - batch { setValue(list.id, list.items, items.toMutableList().apply { add(at, ItemListBlock("")) }) } + 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) + val next = ItemListBlock(tail).alongside(item) val newItems = items.toMutableList().apply { this[at] = item.copy(value = head) add(at + 1, next) @@ -77,6 +87,13 @@ private fun BlockEditorState.listEnter(where: ListItemAt, head: String, tail: St } } +/** 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. @@ -84,7 +101,7 @@ private fun BlockEditorState.listEnter(where: ListItemAt, head: String, tail: St 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 after = items.drop(at + 1).normalizedLevels(list.blockType) val paragraph = Block.TextBlock() batch { when { @@ -107,8 +124,9 @@ private fun BlockEditorState.exitList(index: Int, list: Block.ListBlock, at: Int } /** - * Backspace with the cursor at the start of item [itemId]: the first item leaves the list as a - * paragraph above it, and any other item, empty or not, joins the item above (see [joinIntoCurrent]). + * 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 @@ -116,13 +134,18 @@ public fun BlockEditorState.listBackspaceAtStart(listId: String, itemId: String) 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)) + setValue(list.id, list.items, items.drop(1).normalizedLevels(list.blockType)) addBlock(index, paragraph) } } @@ -148,10 +171,15 @@ private fun BlockEditorState.joinIntoCurrent(where: ListItemAt) { 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) + 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) } + 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) } 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..a9c03879 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt @@ -0,0 +1,90 @@ +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. + +/** 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) ?: 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/SelectedBlocksEdits.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt index 097010d1..5216b9fb 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt @@ -4,6 +4,9 @@ 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.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 @@ -51,12 +54,17 @@ private fun Block.plainLines(): String = when (this) { is Block.LinkBlock -> block.value.let { if (it.title.isBlank()) it.url else "${it.title} (${it.url})" } - is Block.ListBlock -> items.value.filter { it.value.isNotBlank() }.withIndex().joinToString("\n") { (at, item) -> - val marker = when (blockType) { - BlockType.LIST_NUMBER -> "${at + 1}. " - BlockType.LIST_CHECK -> if (item.isCheck) "[x] " else "[ ] " - else -> "- " + 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 } - marker + item.value } } 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 index 6f89589a..2f3cdce2 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt @@ -7,6 +7,7 @@ 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 @@ -318,12 +319,13 @@ public fun BlockEditorState.textRangeBlocks(): List { 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 } @@ -404,16 +406,24 @@ private class RangePlan(state: BlockEditorState, private val r: ResolvedRange, r batch.setValue( keptList.id, keptList.items, - keptList.items.value.take(r.firstItem) + item(keptList) + tail, + (keptList.items.value.take(r.firstItem) + item(keptList) + tail) + .normalizedLevels(keptList.blockType), ) - keptList != null -> batch.setValue(keptList.id, keptList.items, listOf(item(keptList)) + tail) + 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) batch.setValue(last.id, (last as Block.ListBlock).items, tail) + 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) 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 index a0f35bff..35038611 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt @@ -67,6 +67,12 @@ internal interface BlockActions { /** 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 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 index 0ea1e2d5..0695d289 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt @@ -74,6 +74,12 @@ internal class BlockActionsImpl( 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() 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 index 0043ae88..5f40b43a 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockChrome.kt @@ -38,6 +38,7 @@ 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 @@ -152,12 +153,16 @@ private fun BoxScope.BlockHandle(marked: Boolean, onSelect: () -> Unit) { 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), - modifier = Modifier.align(Alignment.TopEnd), + // 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) }, 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 index 74445eb8..422aff04 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt @@ -128,4 +128,10 @@ public interface EditorController { /** 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 } 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 index d26ec2ea..6e605039 100644 --- 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 @@ -1,5 +1,8 @@ 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 @@ -57,6 +60,9 @@ 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 @@ -81,6 +87,8 @@ 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 @@ -189,6 +197,7 @@ internal class ItemValues(private val items: StateFlow>) { * [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, @@ -199,6 +208,12 @@ internal fun ListBlock( ) { 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() @@ -208,14 +223,18 @@ internal fun ListBlock( val theme = LocalEditorTheme.current val marker = rememberListMarker(block.blockType, items.size) val locks = remember(isLocked, textLocked) { ItemLocks(editorLocked = isLocked, textLocked = textLocked) } + 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())), + modifier = modifier + .fillMaxWidth() + .padding(start = listIndent(listFontDp())) + .dropLine(dropColor) { drag.dropLineAt() }, verticalArrangement = Arrangement.spacedBy( theme.lineSpacing.listItemGap(checklist, theme.spacing.xxs), ), ) { - items.forEachIndexed { itemIndex, item -> + rows.forEachIndexed { itemIndex, item -> key(item.id) { val state = rememberSyncedTextFieldState( model = { values.valueOf(item.id) }, @@ -233,7 +252,9 @@ internal fun ListBlock( 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) { ItemField(state, highlight, marker.metrics) } + 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) { @@ -246,10 +267,18 @@ internal fun ListBlock( ReadingDirection(text = { state.text }) { ListItemRow( item = item, - marker = marker.forItem(itemIndex + 1), + marker = marker.forItem(numbers[itemIndex], item.kindIn(block.blockType)), field = field, editor = editor, - locks = locks, + context = ItemContext( + drag = drag, + moves = moves[itemIndex], + editable = !isLocked && !textLocked, + reorderable = items.size > 1, + onMove = { id, by -> actions.onListItemMove(id, by) }, + onShift = { id, deeper -> actions.onListItemShift(id, deeper) }, + ), + modifier = Modifier.dragged(drag, item.id), fieldModifier = itemModifier, ) } @@ -285,14 +314,25 @@ 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) + return 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, and its list's [metrics]. */ -private class ItemField(val state: TextFieldState, val highlight: FieldHighlight, val metrics: LineMetrics) +/** 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 @@ -303,9 +343,12 @@ private data class ListMarker( 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, ) { - fun forItem(number: Int) = copy(number = number) + /** 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 @@ -314,15 +357,20 @@ private fun ListItemRow( marker: ListMarker, field: ItemField, editor: ListEditor, - locks: ItemLocks, + 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( - modifier = modifier.fillMaxWidth(), + // 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) { @@ -341,7 +389,6 @@ private fun ListItemRow( input = editor.inputFor(item.id), checked = checklist && item.isCheck, placeholder = placeholder, - locks = locks, modifier = Modifier .weight(1f) .then( @@ -352,28 +399,13 @@ private fun ListItemRow( }, ) .then(fieldModifier) - .onPreviewKeyEvent { event -> - val selection = state.selection - val line = if (event.isArrow()) { - caretLine(field.highlight.geometry(), state.text.length, selection.end) - } else { - null - } - editor.rangeKey(event, rangeField(editor.blockId, item.id, selection, line)) || - editor.selectAllText(event, selection, state.text.length) || - 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, - ) - }, + .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) + } } } @@ -416,6 +448,34 @@ private class ListEditor( 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) @@ -536,10 +596,10 @@ private fun ListItemField( input: BlockInput, checked: Boolean, placeholder: String, - locks: ItemLocks, modifier: Modifier = Modifier, ) { val state = field.state + val locks = field.locks val highlight = field.highlight val metrics = field.metrics val base = listTextStyle() 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..3a54b8b6 --- /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 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/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/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/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/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/ui/StateController.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt index 21804386..d3606ddc 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt @@ -41,8 +41,10 @@ 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.pasteLink import com.pasich.encly.editor.state.replaceTextRange +import com.pasich.encly.editor.state.shiftListItem import com.pasich.encly.editor.state.textRangePlainText import com.pasich.encly.editor.state.toggleChecklistLock import com.pasich.encly.editor.ui.sheets.BlockSheetKind @@ -154,6 +156,12 @@ internal class StateController(val state: BlockEditorState) : EditorController { 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) 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/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt index bce9637c..80fe8ec8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt @@ -4,7 +4,11 @@ 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.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. @@ -22,6 +26,9 @@ import com.pasich.encly.editor.model.hasContent */ 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 val LINK_SCHEMES = setOf("http", "https", "mailto", "tel") private val ENCODED_IN_LINK = mapOf( ' ' to "%20", @@ -79,17 +86,25 @@ object MarkdownBlocks { } private fun list(type: BlockType, items: List): String { - val shown = items.filter { it.value.isNotBlank() } + 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 marker = when (type) { - BlockType.LIST_NUMBER -> "${index + 1}. " + 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 indent = " ".repeat(marker.length) - (listOf(marker + lines.first()) + lines.drop(1).map { if (it.isEmpty()) "" else indent + it }) - .joinToString("\n") + 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") } } 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 index 9bf489e1..f7f5bd3e 100644 --- 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 @@ -41,6 +41,8 @@ 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.editor.model.BlockType @@ -50,6 +52,8 @@ 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.Outdent import com.pasich.encly.presentation.designsystem.ToolStyle import com.pasich.encly.presentation.effects.rememberImeVisible import com.pasich.encly.presentation.viewmodel.EditNoteViewModel @@ -303,6 +307,7 @@ private fun MoreTools(blockTools: BlockMoveTools, onApply: ((BlockType) -> Unit) onClick = lock.onToggle, ) } + blockTools.indent?.let { ListIndentButtons(it) } EnclyToolButton( icon = EnclyIcons.Trash, contentDescription = stringResource(Res.string.delete_block), @@ -310,3 +315,18 @@ private fun MoreTools(blockTools: BlockMoveTools, onApply: ((BlockType) -> Unit) ) } } + +/** 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/ToolbarState.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt index 779e980d..0f203037 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/ToolbarState.kt @@ -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/designsystem/EnclyIcons.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt index 22a1b050..5088e5ee 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyIcons.kt @@ -698,6 +698,7 @@ 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) 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/screen/editnote/EditNoteViewModelController.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteViewModelController.kt index f4d37398..017223db 100644 --- 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 @@ -115,6 +115,12 @@ internal class EditNoteViewModelController( 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) } /** The editor's view of the screen's note view model. */ 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 index aa38769a..5b0f63d9 100644 --- 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 @@ -26,7 +26,12 @@ 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 @@ -181,6 +186,11 @@ internal fun EditorString.resource(): StringResource = when (this) { 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 } /** 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 index 2c4c5a59..a7e71277 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -50,15 +50,18 @@ 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.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.textRangeBlocks import com.pasich.encly.editor.state.toggleChecklistLock import com.pasich.encly.presentation.editor.persistence.LoadNoteState @@ -560,6 +563,20 @@ class EditNoteViewModel( 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) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt index 074cbe0a..fe26c42f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt @@ -4,6 +4,9 @@ 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 +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. @@ -81,32 +84,21 @@ object NotesTextFormatter { } 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") - } - } + // 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") } } 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))) + } +} From dd53cda41cc941e75fac1d4ae3306434425711ee Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 18:28:13 +0200 Subject: [PATCH 56/75] feat(editor): tables, code blocks and a toolbar you arrange yourself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 +- .../com/pasich/encly/core/di/AppGraphTest.kt | 6 +- config/detekt/detekt.yml | 4 + .../core/keystore/SecretServiceSession.kt | 3 + .../commonMain/strings/values-ar/strings.xml | 19 + .../commonMain/strings/values-de/strings.xml | 19 + .../commonMain/strings/values-es/strings.xml | 19 + .../commonMain/strings/values-fr/strings.xml | 19 + .../commonMain/strings/values-it/strings.xml | 19 + .../commonMain/strings/values-nl/strings.xml | 19 + .../commonMain/strings/values-pl/strings.xml | 19 + .../commonMain/strings/values-pt/strings.xml | 19 + .../commonMain/strings/values-uk/strings.xml | 19 + .../src/commonMain/strings/values/strings.xml | 19 + core/security/api/android/security.api | 3 + core/security/api/desktop/security.api | 3 + core/security/api/security.klib.api | 6 + .../encly/core/backup/BackupSettings.kt | 32 ++ editor/api/android/editor.api | 117 ++++- editor/api/desktop/editor.api | 117 ++++- editor/api/editor.klib.api | 125 ++++- .../encly/editor/ui/TablesAndCodeUiTest.kt | 433 +++++++++++++++++ .../com/pasich/encly/editor/EditorStrings.kt | 8 + .../pasich/encly/editor/codec/BlockJson.kt | 19 + .../pasich/encly/editor/codec/ImportBlocks.kt | 8 + .../encly/editor/codec/MarkdownBlockMapper.kt | 99 +++- .../pasich/encly/editor/codec/TableJson.kt | 28 ++ .../com/pasich/encly/editor/model/Block.kt | 17 + .../pasich/encly/editor/model/BlockFactory.kt | 2 + .../pasich/encly/editor/model/BlockUtils.kt | 2 + .../pasich/encly/editor/model/TableData.kt | 101 ++++ .../encly/editor/state/BlockDuplicate.kt | 4 + .../pasich/encly/editor/state/BlockEditing.kt | 11 +- .../encly/editor/state/BlockEditorState.kt | 4 + .../encly/editor/state/BlockSelection.kt | 2 +- .../pasich/encly/editor/state/BlockTools.kt | 7 +- .../pasich/encly/editor/state/CaretTarget.kt | 3 + .../com/pasich/encly/editor/state/NoteFind.kt | 9 + .../encly/editor/state/SelectedBlocksEdits.kt | 9 +- .../com/pasich/encly/editor/state/Shortcut.kt | 3 +- .../encly/editor/state/SlashCommands.kt | 2 + .../pasich/encly/editor/state/TableEditing.kt | 100 ++++ .../pasich/encly/editor/theme/EditorIcons.kt | 11 + .../encly/editor/ui/EditorBlocksHost.kt | 47 +- .../encly/editor/ui/EditorController.kt | 4 + .../encly/editor/ui/blocks/BlockInput.kt | 18 +- .../encly/editor/ui/blocks/CodeBlock.kt | 133 ++++++ .../encly/editor/ui/blocks/SlashMenuPopup.kt | 2 + .../encly/editor/ui/blocks/TableBlock.kt | 446 ++++++++++++++++++ .../ui/sheets/ActionOtherBottomSheet.kt | 2 + .../editor/codec/MarkdownBlockMapperTest.kt | 11 +- .../editor/codec/TablesAndCodeCodecTest.kt | 192 ++++++++ .../encly/editor/model/BlockFactoryTest.kt | 8 +- .../encly/editor/model/TableDataTest.kt | 157 ++++++ .../encly/editor/state/BlockEditingTest.kt | 2 + .../encly/editor/state/BranchGapsTest.kt | 2 +- .../encly/editor/state/ContentChangesTest.kt | 2 + .../encly/editor/state/SlashCommandsTest.kt | 3 +- .../encly/editor/state/SmartPasteTest.kt | 4 + .../editor/state/TablesAndCodeStateTest.kt | 436 +++++++++++++++++ .../pasich/encly/editor/ui/StateController.kt | 7 + .../metadata/android/ar/full_description.txt | 2 +- .../android/de-DE/full_description.txt | 2 +- .../android/en-US/full_description.txt | 2 +- .../android/es-ES/full_description.txt | 2 +- .../android/fr-FR/full_description.txt | 2 +- .../android/it-IT/full_description.txt | 2 +- .../android/nl-NL/full_description.txt | 2 +- .../android/pl-PL/full_description.txt | 2 +- .../android/pt-PT/full_description.txt | 2 +- .../metadata/android/uk/full_description.txt | 2 +- .../ui/screens/EditorToolbarScreenTest.kt | 135 ++++++ .../encly/ui/screens/LocalizedScreensTest.kt | 2 +- .../encly/ui/screens/NotePreviewsTest.kt | 2 +- .../screens/ScreenshotAllowanceScreensTest.kt | 2 +- .../encly/ui/screens/SecurityScreensTest.kt | 2 +- .../com/pasich/encly/ui/screens/TestApp.kt | 7 + .../encly/data/backup/BackupPreferences.kt | 10 +- .../encly/data/export/MarkdownBlocks.kt | 30 ++ .../pasich/encly/data/export/NoteDocument.kt | 7 + .../encly/data/importer/ImportMapper.kt | 9 + .../encly/data/importer/ImportReview.kt | 7 + .../encly/domain/model/EditorToolbar.kt | 191 ++++++++ .../components/editNote/BlockTool.kt | 58 ++- .../components/editNote/NoteBottomBar.kt | 90 ++-- .../encly/presentation/designsystem/Editor.kt | 16 +- .../designsystem/EnclyEditorIcons.kt | 28 ++ .../editor/persistence/NotePersistence.kt | 14 +- .../encly/presentation/navigation/EnclyApp.kt | 4 + .../encly/presentation/navigation/NavHost.kt | 5 + .../presentation/navigation/NavRoutes.kt | 1 + .../editnote/EditNoteViewModelController.kt | 3 + .../screen/editnote/EditorBindings.kt | 22 +- .../screen/settings/AppearanceScreen.kt | 16 + .../screen/settings/EditorToolbarScreen.kt | 268 +++++++++++ .../viewmodel/EditNoteViewModel.kt | 9 + .../viewmodel/EditorToolbarViewModel.kt | 29 ++ .../pasich/encly/utils/NotesTextFormatter.kt | 17 +- .../data/export/TablesAndCodeExportTest.kt | 101 ++++ .../editor/persistence/NotePersistenceTest.kt | 13 + .../encly/data/backup/SettingsBackupTest.kt | 33 +- .../encly/data/importer/ImportFixtures.kt | 6 + .../encly/domain/model/EditorToolbarTest.kt | 150 ++++++ 103 files changed, 4158 insertions(+), 123 deletions(-) create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/TableJson.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TableEditing.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/CodeBlock.kt create mode 100644 editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TableBlock.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditorToolbarScreenTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/domain/model/EditorToolbar.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/EnclyEditorIcons.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/EditorToolbarScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditorToolbarViewModel.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/model/EditorToolbarTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fb505d0b..e5d2d776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,15 @@ IzzyOnDroid) and used as the GitHub Release notes. ### 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)). -- Unlock with a password instead of, or next to, the PIN: Settings → Security → Password (8 to 128 characters, any language). The lock screen switches between them, both share the same limit on wrong attempts and the same protection by the phone's secure hardware. The wipe PIN and the duress PIN stay PIN-only. At least one of PIN and password always remains; the PIN can be removed once a password is set ([#95](https://github.com/pasichDev/Encly/issues/95)). + +- 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)). +- 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)). +- 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)). ### Changed 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 index 4fd8cc6e..634a0767 100644 --- a/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt +++ b/app/src/test/java/com/pasich/encly/core/di/AppGraphTest.kt @@ -34,6 +34,7 @@ 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 @@ -167,10 +168,10 @@ class AppGraphTest { } @Test - fun theViewModelsAreTheTwentySixOfTheApp() { + fun theViewModelsAreTheTwentySevenOfTheApp() { assertEquals(PLAIN_VIEW_MODELS, graph.viewModelProviders.keys) assertEquals(ASSISTED_VIEW_MODELS, graph.assistedFactoryProviders.keys) - assertEquals(26, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) + assertEquals(27, PLAIN_VIEW_MODELS.size + ASSISTED_VIEW_MODELS.size) assertTrue(graph.manualAssistedFactoryProviders.isEmpty()) } @@ -252,6 +253,7 @@ class AppGraphTest { MarkdownExportViewModel::class, NoteListViewModel::class, NotePreviewsViewModel::class, + EditorToolbarViewModel::class, NoteSearchViewModel::class, NoteTabsViewModel::class, OnboardingViewModel::class, diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 8a1fa3ff..08f0842b 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -63,6 +63,10 @@ Compose: # 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). 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 index 9358f742..326a72d8 100644 --- 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 @@ -113,9 +113,12 @@ 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( diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index 13364f9b..26870cc5 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -706,4 +706,23 @@ نعم. في الإعدادات ← الأمان ← كلمة المرور يمكنك إضافة كلمة مرور من 8 أحرف على الأقل وبأي لغة. تعمل إلى جانب رمز PIN: تتيح لك شاشة القفل التبديل بينهما ويفتح كل منهما بمفرده. يتشاركان الحد نفسه للمحاولات الخاطئة والحماية نفسها بمفتاح الجهاز. يبقى رمز المسح ورمز الإكراه رمزَي PIN فقط. يمكنك إزالة أحدهما ما دام الآخر مُعيَّنًا. ما طول رمز PIN المسموح به؟ من 6 إلى 12 رقمًا بحسب اختيارك. لا تُعرض رموز أقصر: فالقفل المؤقت ومفتاح الجهاز يحدّان من عدد المحاولات، و6 أرقام هي أقصر طول يبقى آمنًا مع هذه الحدود. لا تُظهر شاشة القفل طول رمزك أبدًا: تضغط «فتح القفل» عند الانتهاء، والأمر نفسه ينطبق على رمز المسح ورمز الإكراه. تبقى الرموز التي عيّنتها سابقًا تعمل كما هي. + كود + جدول + إضافة صف + إضافة عمود + حذف الصف + حذف العمود + صف الرأس + خيارات الخلية + شريط أدوات المحرر + اختر الأدوات ورتّبها + اسحب المقبض لإعادة الترتيب. تخفي العين الأداة أو تعيدها. أدوات «المزيد» خلف السهم. + إعادة تعيين + الشريط + المزيد + المخفية + إخفاء الأداة + إظهار الأداة + أعلى + أسفل diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index cc1b2065..b2b71840 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -693,4 +693,23 @@ Ja. Unter Einstellungen → Sicherheit → Passwort kannst du ein Passwort mit mindestens 8 Zeichen in jeder Sprache hinzufügen. Es gilt neben deiner PIN: Auf dem Sperrbildschirm wechselst du zwischen beiden, und jedes entsperrt für sich. Beide teilen sich dasselbe Limit für Fehlversuche und denselben Schutz durch den Gerätschlüssel. Lösch-PIN und Zwangs-PIN bleiben reine PINs. Du kannst jedes der beiden entfernen, solange das andere festgelegt ist. 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index d027f59b..e78b4193 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -699,4 +699,23 @@ Sí. En Ajustes → Seguridad → Contraseña puedes añadir una contraseña de al menos 8 caracteres, en cualquier idioma. Funciona junto a tu PIN: la pantalla de bloqueo te deja cambiar entre ambos y cada uno desbloquea por sí solo. Comparten el mismo límite de intentos fallidos y la misma protección con la clave del dispositivo. El PIN de borrado y el PIN de coacción siguen siendo solo PIN. Puedes quitar cualquiera de los dos mientras el otro esté establecido. ¿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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index 24a84e71..75aca0dc 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -699,4 +699,23 @@ Oui. Dans Paramètres → Sécurité → Mot de passe, vous pouvez ajouter un mot de passe d’au moins 8 caractères, dans n’importe quelle langue. Il s’ajoute à votre code PIN : l’écran de verrouillage permet de passer de l’un à l’autre, et chacun déverrouille à lui seul. Les deux partagent la même limite d’essais erronés et la même protection par la clé de l’appareil. Le PIN d’effacement et le PIN de contrainte restent des codes PIN. Vous pouvez supprimer l’un des deux tant que l’autre est défini. 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 197bd4bc..975a6cb0 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -699,4 +699,23 @@ Sì. In Impostazioni → Sicurezza → Password puoi aggiungere una password di almeno 8 caratteri, in qualsiasi lingua. Funziona accanto al PIN: la schermata di blocco permette di passare dal PIN alla password e viceversa, e ciascuno sblocca da solo. Condividono lo stesso limite di tentativi errati e la stessa protezione con la chiave del dispositivo. Il PIN di cancellazione e il PIN di coercizione restano solo PIN. Puoi rimuovere uno dei due finché l’altro è impostato. 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ù diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index a99d1625..83b91243 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -693,4 +693,23 @@ Ja. Onder Instellingen → Beveiliging → Wachtwoord kun je een wachtwoord van minstens 8 tekens toevoegen, in elke taal. Het werkt naast je pincode: op het vergrendelscherm wissel je tussen beide en elk ontgrendelt zelfstandig. Ze delen dezelfde limiet voor foute pogingen en dezelfde bescherming door de sleutel van het apparaat. De wis-pincode en de dwangpincode blijven pincodes. Je kunt een van beide verwijderen zolang de ander is ingesteld. 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index 4522434c..7f784ad9 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -705,4 +705,23 @@ Tak. W Ustawienia → Bezpieczeństwo → Hasło możesz dodać hasło z co najmniej 8 znaków, w dowolnym języku. Działa obok PIN-u: na ekranie blokady przełączasz się między nimi, a każde odblokowuje samodzielnie. Oba mają ten sam limit błędnych prób i tę samą ochronę kluczem urządzenia. PIN kasujący i PIN przymusowy pozostają wyłącznie kodami PIN. Możesz usunąć jedno z nich, dopóki drugie jest ustawione. 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ół diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index 67463111..3a5a672b 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -699,4 +699,23 @@ Sim. Em Configurações → Segurança → Senha você pode adicionar uma senha com pelo menos 8 caracteres, em qualquer idioma. Ela funciona ao lado do PIN: a tela de bloqueio permite alternar entre os dois e cada um desbloqueia sozinho. Os dois compartilham o mesmo limite de tentativas erradas e a mesma proteção pela chave do dispositivo. O PIN de apagamento e o PIN de coação continuam sendo apenas PINs. Você pode remover um dos dois enquanto o outro estiver definido. 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index 7c1ad1be..e4abbd5b 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -705,4 +705,23 @@ Так. У Налаштування → Безпека → Пароль можна додати пароль щонайменше з 8 символів будь-якою мовою. Він працює поряд із PIN: на екрані блокування можна перемикатися між ними, і кожен розблоковує сам по собі. Обидва мають спільний ліміт помилкових спроб і той самий захист ключем пристрою. PIN для стирання та PIN під примусом залишаються лише PIN-кодами. Будь-який із двох можна видалити, поки задано інший. Якої довжини може бути PIN? Від 6 до 12 цифр на ваш вибір. Коротших PIN не пропонується: блокування й ключ пристрою обмежують кількість спроб, а 6 цифр — найкоротший PIN, що лишається безпечним за цих обмежень. Екран блокування ніколи не показує довжину вашого PIN: коли закінчите, натисніть «Розблокувати» — так само для PIN для стирання та PIN під примусом. PIN, задані раніше, працюють без змін. + Код + Таблиця + Додати рядок + Додати стовпець + Видалити рядок + Видалити стовпець + Рядок заголовка + Параметри клітинки + Панель редактора + Оберіть інструменти та їхній порядок + Перетягуйте за ручку, щоб змінити порядок. Око ховає інструмент або повертає його. Інструменти з розділу «Додаткове меню» ховаються за стрілкою. + Скинути + Панель + Додаткове меню + Приховані + Приховати інструмент + Показати інструмент + Вгору + Вниз diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index b3a02565..985dfb30 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -711,4 +711,23 @@ Yes. In Settings → Security → Password you can add a password of at least 8 characters, any language. It works next to your PIN: the lock screen lets you switch between them, and each one unlocks on its own. Both share the same limit on wrong attempts and the same protection by the device key. The wipe PIN and the duress PIN stay PIN-only. You can remove either one while the other is set. 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 diff --git a/core/security/api/android/security.api b/core/security/api/android/security.api index cee578a1..4279f4bc 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -201,6 +201,7 @@ public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/ 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; @@ -230,6 +231,8 @@ public final class com/pasich/encly/core/backup/BackupSettings { 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 { diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index cee578a1..4279f4bc 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -201,6 +201,7 @@ public final class com/pasich/encly/core/backup/BackupSecret$RecoveryRoot : com/ 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; @@ -230,6 +231,8 @@ public final class com/pasich/encly/core/backup/BackupSettings { 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 { diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index 6a65fab4..d3239b41 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -1128,6 +1128,8 @@ final object com.pasich.encly.core.backup/BackupSettings { // com.pasich.encly.c 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] @@ -1179,6 +1181,10 @@ final object com.pasich.encly.core.backup/BackupSettings { // com.pasich.encly.c 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] 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 index 49d42ee0..50ec0911 100644 --- 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 @@ -51,6 +51,28 @@ public object BackupSettings { "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" @@ -68,11 +90,20 @@ public object BackupSettings { 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. */ @@ -85,6 +116,7 @@ public object BackupSettings { 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, diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index 01bf86da..90bd7e85 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -24,6 +24,7 @@ public final class com/pasich/encly/editor/EditorServicesKt { 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; @@ -35,6 +36,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -60,6 +62,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -114,10 +122,12 @@ public final class com/pasich/encly/editor/codec/BlockConverter { 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; } @@ -151,6 +161,22 @@ public abstract class com/pasich/encly/editor/model/Block { 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 @@ -233,6 +259,22 @@ public final class com/pasich/encly/editor/model/Block$SeparatorBlock : com/pasi 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 @@ -262,6 +304,7 @@ public final class com/pasich/encly/editor/model/BlockKt { } 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; @@ -272,6 +315,7 @@ public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { 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; @@ -341,6 +385,68 @@ public final class com/pasich/encly/editor/model/ListMovesKt { 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; } @@ -613,6 +719,7 @@ public final class com/pasich/encly/editor/state/Shortcut { 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; @@ -621,6 +728,7 @@ public final class com/pasich/encly/editor/state/SlashCommand : java/lang/Enum { 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; @@ -664,6 +772,10 @@ public final class com/pasich/encly/editor/state/SlashPicker { 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 { } @@ -847,8 +959,8 @@ public final class com/pasich/encly/editor/theme/EditorFontSizes { 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 { @@ -995,6 +1107,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 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 diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index 01bf86da..90bd7e85 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -24,6 +24,7 @@ public final class com/pasich/encly/editor/EditorServicesKt { 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; @@ -35,6 +36,7 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -60,6 +62,12 @@ public final class com/pasich/encly/editor/EditorString : java/lang/Enum { 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; @@ -114,10 +122,12 @@ public final class com/pasich/encly/editor/codec/BlockConverter { 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; } @@ -151,6 +161,22 @@ public abstract class com/pasich/encly/editor/model/Block { 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 @@ -233,6 +259,22 @@ public final class com/pasich/encly/editor/model/Block$SeparatorBlock : com/pasi 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 @@ -262,6 +304,7 @@ public final class com/pasich/encly/editor/model/BlockKt { } 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; @@ -272,6 +315,7 @@ public final class com/pasich/encly/editor/model/BlockType : java/lang/Enum { 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; @@ -341,6 +385,68 @@ public final class com/pasich/encly/editor/model/ListMovesKt { 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; } @@ -613,6 +719,7 @@ public final class com/pasich/encly/editor/state/Shortcut { 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; @@ -621,6 +728,7 @@ public final class com/pasich/encly/editor/state/SlashCommand : java/lang/Enum { 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; @@ -664,6 +772,10 @@ public final class com/pasich/encly/editor/state/SlashPicker { 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 { } @@ -847,8 +959,8 @@ public final class com/pasich/encly/editor/theme/EditorFontSizes { 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;)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;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 { @@ -995,6 +1107,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 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 diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index 6178c09a..4363fcd3 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -7,6 +7,7 @@ // 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] @@ -17,6 +18,7 @@ final enum class com.pasich.encly.editor.model/BlockType : 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] @@ -86,6 +89,7 @@ final enum class com.pasich.encly.editor.state/SlashCommand : 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] @@ -144,6 +149,7 @@ final enum class com.pasich.encly.editor/EditorString : kotlin/Enum(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] @@ -769,7 +845,7 @@ final class com.pasich.encly.editor.theme/EditorFontSizes { // com.pasich.encly. } 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 = ...) // 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){}[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] @@ -902,6 +978,22 @@ sealed class com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.mo 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] @@ -988,6 +1080,22 @@ sealed class com.pasich.encly.editor.model/Block { // com.pasich.encly.editor.mo 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] @@ -1015,10 +1123,12 @@ final object com.pasich.encly.editor.codec/BlockConverter { // com.pasich.encly. } 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] } @@ -1045,14 +1155,19 @@ final val com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBl 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] @@ -1089,6 +1204,7 @@ final val com.pasich.encly.editor.theme/isCompact // com.pasich.encly.editor.the 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] @@ -1166,14 +1282,19 @@ final fun com.pasich.encly.editor.codec/com_pasich_encly_editor_codec_MarkdownBl 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] @@ -1198,12 +1319,14 @@ final fun com.pasich.encly.editor.state/com_pasich_encly_editor_state_TextRangeE 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] 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..d1830cda --- /dev/null +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt @@ -0,0 +1,433 @@ +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.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 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() + } + + @Test + fun shiftTabInTheFirstCellLeavesItToTheFocusSystem() { + show(table(listOf("a", "b"))) + fields()[0].performClick() + + fields()[0].performKeyInput { + keyDown(Key.ShiftLeft) + pressKey(Key.Tab) + keyUp(Key.ShiftLeft) + } + idle() + + assertEquals(1, state.blocks.size) + } + + @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/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt index 511b4854..a5fccbd4 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/EditorStrings.kt @@ -66,6 +66,14 @@ public enum class EditorString(internal val english: String) { 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"), } /** 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 index f0062278..18544823 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt @@ -102,9 +102,22 @@ internal object BlockJson { // 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") @@ -137,6 +150,12 @@ internal object BlockJson { // "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") } } 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 index f5bbe183..8d07a152 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/ImportBlocks.kt @@ -3,6 +3,7 @@ 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. */ @@ -26,6 +27,13 @@ public object ImportBlocks { 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() } 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 index 33e2a198..c02165c0 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapper.kt @@ -6,6 +6,7 @@ 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. @@ -16,7 +17,9 @@ import com.pasich.encly.editor.model.normalizedLevels * `>` -> 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 + * - 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. @@ -37,7 +40,9 @@ public object MarkdownBlockMapper : MarkdownBlockParser { 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 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<>@/]+)>""") @@ -125,6 +130,7 @@ public object MarkdownBlockMapper : MarkdownBlockParser { 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() @@ -135,6 +141,11 @@ public object MarkdownBlockMapper : MarkdownBlockParser { 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) { @@ -144,18 +155,20 @@ public object MarkdownBlockMapper : MarkdownBlockParser { return } } - val fence = FENCE.matches(raw) + if (paste == null && !inFence && tableLine(raw)) return + val fence = FENCE.matchEntire(raw) when { - inFence && fence -> { + inFence && closesFence(raw) -> { if (paste != null) code += raw closeFence() } inFence -> code += raw - fence -> { + fence != null -> { flushAll() inFence = true + fenceMarker = fence.groupValues[1] if (paste != null) code += raw } @@ -169,13 +182,49 @@ public object MarkdownBlockMapper : MarkdownBlockParser { } } + // 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 } - private fun block(raw: String) { + // 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) @@ -228,6 +277,12 @@ public object MarkdownBlockMapper : MarkdownBlockParser { items += last.copy(value = (last.value + " " + readable(raw.trim())).trim()) } + paste == null && mayStartTable && '|' in raw -> { + flushList() + flushQuote() + tableHeader = raw + } + else -> { flushList() flushQuote() @@ -258,10 +313,40 @@ public object MarkdownBlockMapper : MarkdownBlockParser { inFence = false val text = code.joinToString("\n").trimEnd() code.clear() - if (text.isNotBlank()) blocks += ImportBlocks.text(text) + // 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() 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/model/Block.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt index a8e7e633..13808bbe 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/Block.kt @@ -17,6 +17,8 @@ public enum class BlockType { H2, H3, H4, + CODE, + TABLE, } /** A fresh editor-session identity for a block or list item. Never persisted. */ @@ -81,6 +83,19 @@ public sealed class Block { */ 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() } /** @@ -94,4 +109,6 @@ public fun Block.hasContent(): Boolean = when (this) { 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt index 3865956a..359eb902 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockFactory.kt @@ -20,5 +20,7 @@ public 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt index 7ad9db31..011b52bb 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/BlockUtils.kt @@ -19,6 +19,8 @@ internal 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/TableData.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt new file mode 100644 index 00000000..e21bd6cd --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt @@ -0,0 +1,101 @@ +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. + */ +public data class TableData(val rows: List, val header: Boolean = true) { + /** How many rows the table has. */ + val rowCount: Int get() = rows.size + + /** How many columns the table has. */ + val columnCount: Int get() = rows.firstOrNull()?.let { it.cells.size } ?: 0 + + /** 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/state/BlockDuplicate.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt index 48435046..df58e948 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockDuplicate.kt @@ -36,6 +36,10 @@ internal fun Block.duplicate(): Block = when (this) { 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(), diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt index 0b43dd44..32f1ac7d 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditing.kt @@ -20,7 +20,8 @@ import kotlinx.coroutines.flow.MutableStateFlow 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 { @@ -78,7 +79,7 @@ public 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 } 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 index 5d160f76..3f56083a 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt @@ -254,6 +254,10 @@ private fun Block.contentFlow(): Flow? = when (this) { is Block.ListBlock -> combine(items, locked) { list, locked -> list to locked } is Block.SeparatorBlock -> null + + is Block.CodeBlock -> text + + is Block.TableBlock -> table } /** 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 index 607735b4..f18bd107 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockSelection.kt @@ -147,7 +147,7 @@ internal fun Block.firstItemId(): String? = (this as? Block.ListBlock)?.let { it /** 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 -> true + 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 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 index 19f4ae8d..f6da2199 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt @@ -56,6 +56,8 @@ public fun Block.toolType(): BlockType? = when (this) { is Block.ListBlock -> blockType is Block.LinkBlock -> BlockType.LINK is Block.SeparatorBlock -> BlockType.SEPARATOR + is Block.CodeBlock -> BlockType.CODE + is Block.TableBlock -> BlockType.TABLE } /** @@ -112,7 +114,10 @@ private fun convertText(block: Block, text: String, tool: BlockType): Block? = w BlockType.TEXT -> Block.TextBlock(MutableStateFlow(text)) - BlockType.SEPARATOR -> null + 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) { diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt index 6fc6b247..b2c63d76 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/CaretTarget.kt @@ -2,6 +2,7 @@ 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.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]. */ @@ -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 } 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 index a8be469b..86951fed 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/NoteFind.kt @@ -50,9 +50,18 @@ internal fun findMatches(blocks: List, query: String): List { 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 } } 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 index 5216b9fb..186b9f0e 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt @@ -3,6 +3,7 @@ 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 @@ -44,14 +45,12 @@ public fun BlockEditorState.pickedPlainText(): String = pickedBlocks.picked().pl public fun List.plainText(): String = filter { it.hasContent() }.joinToString("\n\n") { it.plainLines() } private fun Block.plainLines(): String = when (this) { - is Block.TextBlock -> text.value - - is Block.HBlock -> text.value - - is Block.QuoteBlock -> text.value + 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 -> { diff --git a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt index e1e5d3ee..48cc65e4 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/Shortcut.kt @@ -69,7 +69,8 @@ internal fun shortcutBlock(shortcut: Shortcut, rest: String): Block = when (shor 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) 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 index 07bffe9b..3d87caaf 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SlashCommands.kt @@ -33,6 +33,8 @@ public enum class SlashCommand( 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")), } /** 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/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt index b0651698..9c72d17c 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/theme/EditorIcons.kt @@ -44,6 +44,8 @@ public class EditorIcons( 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 @@ -166,6 +168,15 @@ private object DefaultIcons { 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, 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 index 669cff5f..fcf835a7 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorBlocksHost.kt @@ -38,16 +38,19 @@ 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 @@ -99,6 +102,9 @@ public fun LazyListScope.editorBlocks( 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) { @@ -152,6 +158,7 @@ private class EditorBlockCallbacks( 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. */ @@ -170,8 +177,8 @@ private fun EditorBlock( ) { val theme = LocalEditorTheme.current 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) + // 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) } @@ -198,9 +205,9 @@ private fun EditorBlock( .padding(horizontal = theme.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 + // 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() @@ -220,6 +227,7 @@ private fun EditorBlock( isLocked = isLocked, isEditingLink = isEditingLink, showPlaceholder = !isLocked && (hasFocus || isOnlyBlock), + onTableChange = callbacks.onTableChange, ), onOpenSheet = callbacks.onOpenSheet, modifier = if (isLocked) Modifier else blockAccessibilityActions(commands), @@ -228,6 +236,9 @@ private fun EditorBlock( } } +/** 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 @@ -293,8 +304,13 @@ private fun blockAccessibilityActions(commands: List): Modifier = customActions = commands.map { command -> CustomAccessibilityAction(command.label) { true.also { command.run() } } } } -/** How a block shows: read-only, its link being edited, its placeholder. */ -private class BlockContentState(val isLocked: Boolean, val isEditingLink: Boolean, val showPlaceholder: Boolean) +/** 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( @@ -336,6 +352,23 @@ private fun BlockContent( 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, 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 index 422aff04..568c6aa7 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt @@ -4,6 +4,7 @@ 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 @@ -134,4 +135,7 @@ public interface EditorController { /** 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/blocks/BlockInput.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/BlockInput.kt index 9928a3ee..652b8b1e 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -25,6 +26,9 @@ internal sealed interface 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 } /** @@ -53,7 +57,14 @@ internal class BlockInput( if (old == new) return val cursor = if (selection.collapsed) selection.start else -1 val diff = textDiff(old, new, cursor) - if (actions().textRange.isCrossField) return replaceRange(diff.inserted) + 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 { lineBreak != null -> { @@ -80,6 +91,11 @@ internal class BlockInput( } } + // 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)) + } + // The selection across blocks 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. The field itself keeps its text. Enter only deletes. private fun TextFieldBuffer.replaceRange(inserted: String) { 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/SlashMenuPopup.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/SlashMenuPopup.kt index 099786a0..9d317560 100644 --- 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 @@ -125,6 +125,8 @@ private fun SlashCommand.icon(icons: EditorIcons): ImageVector = when (this) { SlashCommand.QUOTE -> icons.quote SlashCommand.LINK -> icons.link SlashCommand.DIVIDER -> icons.separator + SlashCommand.CODE -> icons.code + SlashCommand.TABLE -> icons.table } /** 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..b09179e3 --- /dev/null +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/blocks/TableBlock.kt @@ -0,0 +1,446 @@ +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.KeyEventType +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.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 context = CellContext(block, blockActions, focus, commands, defer) + 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 = { text -> change(block.table.value.withCell(cell.id, text), true) }, + 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, +) + +/** 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: (String) -> Unit, + isLocked: Boolean, +) { + val theme = LocalEditorTheme.current + val block = context.block + val actions by rememberUpdatedState(context.blockActions) + val state = rememberSyncedTextFieldState( + model = { block.table.value.cell(cell.id)?.value }, + modelChanges = remember(block, cell.id) { block.table.map { it.cell(cell.id)?.value }.distinctUntilChanged() }, + onEdit = onValue, + ) + 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) { 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), and 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. + */ +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 + when { + event.key == Key.Tab && event.type == KeyEventType.KeyDown -> + if (event.isShiftPressed) { + commands.step(cellId, CellStep.PREVIOUS, caret = Int.MAX_VALUE) + } else { + true.also { commands.tab(cellId) } + } + + event.key == Key.Tab -> true + + else -> 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, + ) + } +} + +/** + * 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. + */ +private class CellInput(private val defer: (() -> Unit) -> Unit, private val onEnter: () -> Unit) : + InputTransformation { + override fun TextFieldBuffer.transformInput() { + val new = asCharSequence().toString() + if (new.none { it in LINE_BREAK_CHARS }) return + val old = originalText.toString() + val diff = textDiff(old, new, if (selection.collapsed) selection.start else -1) + if (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/sheets/ActionOtherBottomSheet.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/sheets/ActionOtherBottomSheet.kt index 74abf6da..50417238 100644 --- 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 @@ -36,4 +36,6 @@ internal fun Block.describe(): EditorString = when (this) { 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/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt index b9ee846d..f0a54edd 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/MarkdownBlockMapperTest.kt @@ -22,6 +22,11 @@ class MarkdownBlockMapperTest { 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" @@ -87,8 +92,8 @@ class MarkdownBlockMapperTest { } @Test - fun codeIsKeptAsTextWithoutItsFences() { - assertEquals(listOf("text:val x = 1\nprint(x)"), blocks("```kotlin\nval x = 1\nprint(x)\n```")) + fun fencedCodeIsACodeBlockWithoutItsFences() { + assertEquals(listOf("code:val x = 1\nprint(x)"), blocks("```kotlin\nval x = 1\nprint(x)\n```")) } @Test @@ -102,7 +107,7 @@ class MarkdownBlockMapperTest { @Test fun noTextIsDropped() { assertEquals( - listOf("text:#hashtag no space", "text:| a | b |\n|---|---|"), + listOf("text:#hashtag no space", "table:a|b"), blocks("#hashtag no space\n\n| a | b |\n|---|---|"), ) assertEquals(emptyList(), blocks("\n\n \n")) 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..69631d86 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt @@ -0,0 +1,192 @@ +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 blankCodeAndTablesAreNotStoredButOneBetweenOtherBlocksIsKept() { + val blank = listOf(Block.CodeBlock(), Block.TableBlock()) + assertEquals("[]", BlockConverter.blocksToJson(blank)) + + val kept = BlockConverter.blocksToJson( + listOf(Block.CodeBlock(MutableStateFlow("x")), Block.TableBlock(), Block.TextBlock(MutableStateFlow("t"))), + ) + assertEquals(listOf("code:x", "text:t"), describe(BlockConverter.jsonToBlocks(kept))) + } + + // --- 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/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt index fc1e278a..d2f67e57 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/BlockFactoryTest.kt @@ -20,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(BlockUtils.isBlockEmpty(block), "$type starts empty") + 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/TableDataTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt new file mode 100644 index 00000000..f4836223 --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt @@ -0,0 +1,157 @@ +package com.pasich.encly.editor.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlin.test.Test +import kotlin.test.assertEquals +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 aTableWithoutRowsHasNoColumns() { + assertEquals(0, TableData(emptyList()).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/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt index 6ac5605e..b53615d7 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BlockEditingTest.kt @@ -526,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/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt index a31814b8..23fc73db 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/BranchGapsTest.kt @@ -403,7 +403,7 @@ class BranchGapsTest { for (tool in BlockType.entries) { val paragraph = text("x\ny") val converted = convertBlock(paragraph, tool) - if (tool == BlockType.TEXT || tool == BlockType.SEPARATOR || tool == BlockType.LINK) { + if (tool in listOf(BlockType.TEXT, BlockType.SEPARATOR, BlockType.LINK, BlockType.TABLE)) { assertNull(converted, tool.name) } else { assertNotNull(converted, tool.name) 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 index 0be953d7..bb2f67e2 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ContentChangesTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/ContentChangesTest.kt @@ -24,6 +24,8 @@ class ContentChangesTest { Block.LinkBlock(), Block.ListBlock(blockType = BlockType.LIST_CHECK), Block.SeparatorBlock(), + Block.CodeBlock(), + Block.TableBlock(), ), ) editor.contentChanges.first() diff --git a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt index 3139d8b6..d6789f91 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SlashCommandsTest.kt @@ -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/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt index 30591274..17f9aa16 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/SmartPasteTest.kt @@ -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/TablesAndCodeStateTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt new file mode 100644 index 00000000..66f6512a --- /dev/null +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt @@ -0,0 +1,436 @@ +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 aRangeThatReachesATableTakesTheWholeTableWhetherItsEndIsInOrAround() { + 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 }) + + // An end inside a cell: the table goes whole, the text on the other side is cut as usual. + 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(1, editor.textRangeBlocks().count { it is Block.TableBlock }) + assertTrue(editor.replaceTextRange("")) + assertEquals(listOf("text"), editor.blocks.map { (it as Block.TextBlock).text.value }) + + 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("head "), editor.blocks.map { (it as Block.TextBlock).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/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt index d3606ddc..1da2e667 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt @@ -19,6 +19,7 @@ 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 @@ -45,6 +46,7 @@ import com.pasich.encly.editor.state.moveListItem 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 @@ -136,6 +138,11 @@ internal class StateController(val state: BlockEditorState) : EditorController { 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) diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt index 1284399f..09f62336 100644 --- a/fastlane/metadata/android/ar/full_description.txt +++ b/fastlane/metadata/android/ar/full_description.txt @@ -8,7 +8,7 @@ Encly تطبيق ملاحظات مشفرة لملاحظاتك ومهامك ال • بلا حساب ولا تسجيل ولا تحليلات ولا إعلانات ولا مشتريات داخل التطبيق. • دون اتصال فعلًا: لا يطلب Encly إذن الوصول إلى الإنترنت (INTERNET)، لذا لا يستطيع إرسال ملاحظاتك إلى أي جهة. • ملاحظات مشفرة أثناء التخزين: قاعدة بيانات SQLCipher (بتشفير AES-256) بمفتاح عشوائي طوله 256 بت. -• قفل برمز PIN: رمز من 6 أرقام مرتبط بالعتاد الآمن في هاتفك، مع فتح اختياري ببصمة الإصبع أو الوجه (القياسات الحيوية القوية فقط). +• قفل برمز PIN: رمز من 6 إلى 12 رقمًا، مرتبط بالعتاد الآمن في هاتفك، مع فتح اختياري ببصمة الإصبع أو الوجه (القياسات الحيوية القوية فقط). • يقفل نفسه: بعد مغادرتك التطبيق (15 ثانية افتراضيًا، أو فورًا) أو لحظة إطفاء الشاشة، تُغلق قاعدة البيانات ويُمحى المفتاح من الذاكرة. • لقطات الشاشة وتسجيل الشاشة ومعاينات التطبيقات الأخيرة محظورة. والنسخ الاحتياطي السحابي في Android معطّل. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 4619f16f..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. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6e33d3fe..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. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index be1a2fee..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. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 57b27c8f..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. diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index e907ee44..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. diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt index 88eb46ab..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. diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt index 43865e32..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. diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt index d4a21f89..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. diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 068bb130..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 вимкнене. 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/LocalizedScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt index 29e61087..6d7708e2 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/LocalizedScreensTest.kt @@ -134,7 +134,7 @@ class LocalizedScreensTest(private val language: String) : ComposeScreenTest() { @Test fun security() { setNavScreen( - viewModels(app.securitySettings(), app.notePreviewsVm(), app.backup()), + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), route = NavRoutes.SecuritySettingsRoute.name, ) { nav -> SecuritySettingsScreen(nav) 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 index 486948c1..6f7170df 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt @@ -108,7 +108,7 @@ class NotePreviewsTest : ComposeScreenTest() { @Test fun theSecuritySwitchTurnsPreviewsOffAndOn() { setNavScreen( - viewModels(app.securitySettings(), app.notePreviewsVm(), app.backup()), + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), route = NavRoutes.SecuritySettingsRoute.name, ) { nav -> SecuritySettingsScreen(nav) } val title = str(Res.string.security_previews_title) 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 index 108877d4..e5fe7f08 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ScreenshotAllowanceScreensTest.kt @@ -47,7 +47,7 @@ class ScreenshotAllowanceScreensTest : ComposeScreenTest() { private fun showSecurity(offered: Boolean = true) { app.withAuth(recoveryPhrase = true, biometric = BiometricStatus.UNAVAILABLE) setNavScreen( - viewModels(app.securitySettings(), app.notePreviewsVm(), app.backup()), + viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), app.backup()), route = NavRoutes.SecuritySettingsRoute.name, ) { nav -> CompositionLocalProvider(LocalScreenshotAllowance provides app.screenshots.takeIf { offered }) { 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 index 2b1b8e2b..4f96da08 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/SecurityScreensTest.kt @@ -101,7 +101,7 @@ class SecurityScreensTest : ComposeScreenTest() { private fun showSecurity(recoveryPhrase: Boolean, biometric: BiometricStatus = BiometricStatus.UNAVAILABLE) { app.withAuth(recoveryPhrase, biometric) vault = app.backup() - val owner = viewModels(app.securitySettings(), app.notePreviewsVm(), vault) + val owner = viewModels(app.securitySettings(), app.notePreviewsVm(), app.toolbarVm(), vault) setNavScreen(owner, route = NavRoutes.SecuritySettingsRoute.name) { nav -> SecuritySettingsScreen(nav) } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 78e81a17..30a7ee96 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -32,6 +32,7 @@ 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.model.EditorToolbar import com.pasich.encly.domain.model.NotePreviews import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.domain.usecase.OnboardingUseCase @@ -52,6 +53,7 @@ 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 @@ -208,6 +210,10 @@ internal class TestApp { 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, @@ -281,6 +287,7 @@ internal class TestApp { fun home(noteList: NoteListViewModel = noteList()): Array = arrayOf( mainListState(), notePreviewsVm(), + toolbarVm(), startup(), settingsVm(), noteList, 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 index 304aca7d..035bcab7 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupPreferences.kt @@ -7,6 +7,7 @@ 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 @@ -14,6 +15,7 @@ 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 @@ -67,8 +69,8 @@ object NoBackupPreferences : BackupPreferences { /** * The app's preference stores behind [BackupPreferences]: the settings repository (DataStore), the - * auto-lock delay, strict keyboard privacy and note previews (flag store, held in memory by their - * own classes, so they are set through those), and the in-app language. + * 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) @@ -79,6 +81,7 @@ class SettingsBackup( private val keyboardPrivacy: KeyboardPrivacy, private val notePreviews: NotePreviews, private val locales: AppLocaleController, + private val toolbar: EditorToolbar, ) : BackupPreferences { override suspend fun snapshot(): Map { @@ -102,6 +105,7 @@ class SettingsBackup( 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 } @@ -157,6 +161,8 @@ class SettingsBackup( 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 } 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 index 80fe8ec8..f988226c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownBlocks.kt @@ -5,6 +5,7 @@ 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 @@ -19,16 +20,20 @@ import com.pasich.encly.editor.model.numbers * `<`, `\`, `~`, 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", @@ -50,8 +55,33 @@ object MarkdownBlocks { 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 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 index 9fbdb2e3..41bf820c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt @@ -68,6 +68,13 @@ class NoteDocument(val title: String, val tag: String?, val blocks: List 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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt index ad232f43..68b8fde0 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt @@ -150,9 +150,18 @@ 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 -> "" } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt index a9143779..1689a56b 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt @@ -49,6 +49,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 +72,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 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/presentation/components/editNote/BlockTool.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/components/editNote/BlockTool.kt index e9901e9c..7a506c85 100644 --- 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 @@ -6,17 +6,21 @@ 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.editor.state.HEADING_TOOL +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 @@ -29,28 +33,38 @@ data class BlockTool( 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, Res.string.block_heading), - BlockTool(EnclyIcons.Checklist, BlockType.LIST_CHECK, Res.string.block_checklist), - BlockTool(EnclyIcons.ListBullet, BlockType.LIST_BULLET, Res.string.block_bulleted_list), - BlockTool(EnclyIcons.ListNumbered, BlockType.LIST_NUMBER, Res.string.block_numbered_list), - BlockTool(EnclyIcons.Quote, BlockType.QUOTE, Res.string.quote), -) +/** 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) -/** Behind "More block tools": a plain paragraph, every heading level, a link and a separator. */ -val moreBlockTools = listOf( - BlockTool(EnclyIcons.Type, BlockType.TEXT, Res.string.text), - BlockTool(EnclyIcons.Heading1, BlockType.H1, Res.string.block_heading_level, headingLevel = 1), - BlockTool(EnclyIcons.Heading2, BlockType.H2, Res.string.block_heading_level, headingLevel = 2), - BlockTool(EnclyIcons.Heading3, BlockType.H3, Res.string.block_heading_level, headingLevel = 3), - BlockTool(EnclyIcons.Heading4, BlockType.H4, Res.string.block_heading_level, headingLevel = 4), - BlockTool(EnclyIcons.Link, BlockType.LINK, Res.string.block_link), - BlockTool(EnclyIcons.Separator, BlockType.SEPARATOR, Res.string.block_separator), -) + 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 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 index f7f5bd3e..78adffa5 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -25,11 +26,13 @@ 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 @@ -45,6 +48,8 @@ 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 @@ -53,7 +58,9 @@ 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 @@ -62,6 +69,9 @@ 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 @@ -90,6 +100,7 @@ fun NoteBottomBar( } val history = rememberHistoryTools(viewModel, keepingKeyboard) val blockTools = rememberBlockMoveTools(viewModel, interactedBlockId, checklistLock, keepingKeyboard) + val layout = LocalEditorToolbar.current EnclyEditorToolbar(modifier = modifier.imePadding()) { KeyboardAnchorField(anchor) when { @@ -107,6 +118,7 @@ fun NoteBottomBar( ) { more -> if (more) { MoreTools( + layout = layout, blockTools = blockTools, onApply = toolApplier(checklistLock) { type -> keepingKeyboard { viewModel.applyTool(type, exact = true) } @@ -115,9 +127,14 @@ fun NoteBottomBar( ) } else { MainTools( - activeTool = activeTool, + tools = RowTools( + layout = layout, + activeTool = activeTool, + onApply = toolApplier(checklistLock) { type -> + keepingKeyboard { viewModel.applyTool(type) } + }, + ), history = history, - onApply = toolApplier(checklistLock) { type -> keepingKeyboard { viewModel.applyTool(type) } }, onMore = { showMore = true }, keyboard = KeyboardToggleState(blockFocused, viewModel::focusWorkingBlock), ) @@ -127,16 +144,13 @@ fun NoteBottomBar( } } +/** 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( - activeTool: BlockType?, - history: HistoryTools, - onApply: ((BlockType) -> Unit)?, - onMore: () -> Unit, - keyboard: KeyboardToggleState, -) { - ToolRow(spread = true) { +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), @@ -145,11 +159,12 @@ private fun MainTools( ) // 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 -> + tools.layout.main.forEach { entry -> + val tool = entry.toBlockTool() BlockToolButton( tool = tool, - active = tool.type == activeTool, - onClick = onApply?.let { apply -> { apply(tool.type) } }, + active = tool.type == tools.activeTool, + onClick = tools.onApply?.let { apply -> { apply(tool.type) } }, ) } EnclyToolbarRule() @@ -230,23 +245,34 @@ private const val SHOW_AFTER_FRAMES = 2 * scrolling sideways on a screen too narrow for them. */ @Composable -private fun ToolRow(spread: Boolean, content: @Composable RowScope.() -> Unit) { +private fun ToolRow(spread: Boolean, buttons: Int? = null, 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, - ) + // 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( @@ -263,14 +289,20 @@ private fun HistoryButtons(history: HistoryTools) { /** 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) { +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, ) - moreBlockTools.forEach { tool -> + layout.more.forEach { entry -> + val tool = entry.toBlockTool() BlockToolButton( tool = tool, active = false, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Editor.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/Editor.kt index f6a1e49a..c277e0d1 100644 --- a/shared/src/commonMain/kotlin/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 @@ -25,6 +26,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pasich.encly.ui.theme.EnclyTheme @@ -32,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). @@ -63,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( @@ -72,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 +99,7 @@ fun EnclyToolButton( contentColor = if (enabled) content else content.disabled(), modifier = Modifier .enclyInteractive(ToolShape, enabled) - .size(ToolSize) + .size(size) .semantics { role = Role.Button selected = style == ToolStyle.ACTIVE @@ -99,7 +109,7 @@ fun EnclyToolButton( Icon( icon, contentDescription = contentDescription, - modifier = Modifier.size(EnclyTheme.spacing.iconMedium), + modifier = Modifier.size(iconSize), ) } } 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/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 index 8827aa75..edc75f7b 100644 --- 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 @@ -118,6 +118,12 @@ class NotePersistence( 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 @@ -369,7 +375,7 @@ class NotePersistence( if (!loaded || suspended || currentNote.id == -1L) return@withLock false val updated = currentNote.copy( - value = contentJson(), + value = contentToKeep(currentNote), isArchived = archived, isPinned = currentNote.isPinned && !archived, ) @@ -401,7 +407,7 @@ class NotePersistence( /** 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() + val content = contentToKeep(currentNote) withContext(ioDispatcher) { notesRepository.insertNote( Note.new(title = copyTitle(currentNote.title), value = content, tagId = currentNote.tagId) @@ -486,6 +492,10 @@ private fun List.frozen(): List = map { block -> 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 } } 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 index e01021de..c4c0c9a3 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/EnclyApp.kt @@ -33,6 +33,7 @@ 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 @@ -40,6 +41,7 @@ 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 @@ -318,6 +320,7 @@ private fun AppContent( .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. @@ -325,6 +328,7 @@ private fun AppContent( 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 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 index b7cc4a51..379e2909 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -40,6 +40,7 @@ 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.PasswordScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen @@ -160,6 +161,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR 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) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt index d0a7eb45..144ff69e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -26,6 +26,7 @@ enum class NavRoutes { AppearanceRoute, LicensesRoute, ImportNotesRoute, + EditorToolbarRoute, } /** Query argument of [NavRoutes.TasksRoute]: open the new-task sheet on arrival. */ 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 index 017223db..2fe8b8bb 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -121,6 +122,8 @@ internal class EditNoteViewModelController( 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. */ 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 index 5b0f63d9..30048896 100644 --- 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 @@ -6,6 +6,7 @@ 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 @@ -16,6 +17,7 @@ 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 @@ -42,6 +44,12 @@ 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 @@ -78,7 +86,9 @@ 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 @@ -133,7 +143,7 @@ internal fun rememberEditorStrings(): EditorStrings { } /** The string resource of [this] key. */ -@Suppress("CyclomaticComplexMethod") // One branch per string the editor shows. +@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 @@ -191,6 +201,14 @@ internal fun EditorString.resource(): StringResource = when (this) { 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 } /** @@ -279,6 +297,8 @@ private val AppEditorIcons by lazy { lock = EnclyIcons.Lock, lockOpen = EnclyIcons.LockOpen, grip = EnclyIcons.Grip, + code = EnclyIcons.Code, + table = EnclyIcons.Table, ) } 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 index ec0cb57d..64ebee8d 100644 --- 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 @@ -51,6 +51,8 @@ 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 @@ -65,6 +67,8 @@ 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 @@ -74,6 +78,7 @@ 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 @@ -110,6 +115,7 @@ fun AppearanceScreen( onMode = viewModel::selectMode, onDynamicColor = viewModel::setDynamicColor, onFontStyle = viewModel::selectFontStyle, + onEditorToolbar = { navController.navigate(NavRoutes.EditorToolbarRoute.name) }, ), modifier = modifier, ) @@ -157,6 +163,15 @@ private fun AppearanceContent( } 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() } } @@ -192,6 +207,7 @@ private class AppearanceActions( val onMode: (ThemeType) -> Unit, val onDynamicColor: (Boolean) -> Unit, val onFontStyle: (FontStyleType) -> Unit, + val onEditorToolbar: () -> Unit, ) private fun ThemePalette.labelRes(): StringResource = when (this) { 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index a7e71277..02f5c3bb 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -22,6 +22,7 @@ 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 @@ -62,6 +63,7 @@ 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 @@ -509,6 +511,13 @@ class EditNoteViewModel( 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 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/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt index fe26c42f..775739ef 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/utils/NotesTextFormatter.kt @@ -3,6 +3,7 @@ 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 @@ -38,6 +39,8 @@ object NotesTextFormatter { 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") @@ -51,13 +54,8 @@ object NotesTextFormatter { 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) + is Block.TextBlock, is Block.HBlock, is Block.CodeBlock -> { + text.append((block as TextualBlock).text.value) text.append("\n") } @@ -83,6 +81,11 @@ object NotesTextFormatter { 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) 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..d0e838e1 --- /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, tagName = null, 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/presentation/editor/persistence/NotePersistenceTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistenceTest.kt index cc26aedb..a545f93f 100644 --- 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 @@ -261,6 +261,19 @@ class NotePersistenceTest { assertEquals(0, repository.updateCalls) } + @Test + fun duplicatingUnreadableContentCopiesTheStoredText() = 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 = 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 = 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 index 7e41b421..5f3636ef 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/SettingsBackupTest.kt @@ -8,6 +8,7 @@ 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 @@ -15,6 +16,9 @@ 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 @@ -32,7 +36,8 @@ class SettingsBackupTest { private val autoLock = AutoLock(flags) private val keyboard = KeyboardPrivacy(flags) private val previews = NotePreviews(flags) - private val backup = SettingsBackup(repository, autoLock, keyboard, previews, locales) + private val toolbar = EditorToolbar(flags) + private val backup = SettingsBackup(repository, autoLock, keyboard, previews, locales, toolbar) @Test fun theSnapshotHoldsExactlyTheAllowListedKeys() = runTest { @@ -147,12 +152,35 @@ class SettingsBackupTest { 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) { @@ -161,7 +189,8 @@ class SettingsBackupTest { val autoLock = AutoLock(flags) val keyboard = KeyboardPrivacy(flags) val previews = NotePreviews(flags) - val backup = SettingsBackup(settings, autoLock, keyboard, previews, locales) + val toolbar = EditorToolbar(flags) + val backup = SettingsBackup(settings, autoLock, keyboard, previews, locales, toolbar) } private class FakeLocales : AppLocaleController { diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt index 07ef74ce..ba93cea6 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportFixtures.kt @@ -133,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/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))) + } +} From f41db3db3d0a53f1a33d8e08ac02f517fb1c45a1 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 18:55:42 +0200 Subject: [PATCH 57/75] fix(security): no password unlock, a live lock state on Linux 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 --- README.md | 4 +- SECURITY.md | 69 +-- .../core/keystore/DesktopPinFactorTest.kt | 14 - .../encly/core/keystore/IosPinFactor.kt | 15 +- .../commonMain/strings/values-ar/strings.xml | 4 +- .../strings/values-ar/strings_security.xml | 42 +- .../commonMain/strings/values-de/strings.xml | 4 +- .../strings/values-de/strings_security.xml | 42 +- .../commonMain/strings/values-es/strings.xml | 4 +- .../strings/values-es/strings_security.xml | 42 +- .../commonMain/strings/values-fr/strings.xml | 4 +- .../strings/values-fr/strings_security.xml | 42 +- .../commonMain/strings/values-it/strings.xml | 4 +- .../strings/values-it/strings_security.xml | 42 +- .../commonMain/strings/values-nl/strings.xml | 4 +- .../strings/values-nl/strings_security.xml | 42 +- .../commonMain/strings/values-pl/strings.xml | 4 +- .../strings/values-pl/strings_security.xml | 42 +- .../commonMain/strings/values-pt/strings.xml | 4 +- .../strings/values-pt/strings_security.xml | 42 +- .../commonMain/strings/values-uk/strings.xml | 4 +- .../strings/values-uk/strings_security.xml | 42 +- .../src/commonMain/strings/values/strings.xml | 4 +- .../strings/values/strings_security.xml | 42 +- core/security/api/android/security.api | 51 +- core/security/api/desktop/security.api | 51 +- core/security/api/security.klib.api | 56 +-- .../core/security/AuthenticationManager.kt | 310 ++---------- .../encly/core/security/PasswordSlots.kt | 90 ---- .../encly/core/security/SecurityManager.kt | 113 +---- .../encly/core/security/SessionLockManager.kt | 6 + .../encly/core/security/CryptoGoldenTest.kt | 82 --- .../encly/core/security/GoldenVectors.kt | 24 - .../core/security/SecurityBoundaryTest.kt | 7 +- .../encly/core/security/IosNormalizer.kt | 15 - .../encly/core/security/JvmNormalizer.kt | 18 - .../encly/core/security/PasswordSlotTest.kt | 472 ------------------ .../core/security/RecoveryFollowUpTest.kt | 167 +++++++ .../core/security/SessionLockManagerTest.kt | 12 + .../security/UnlockMethodsLifecycleTest.kt | 319 ------------ .../desktop/nativeos/NativeScreenLock.kt | 118 ++++- .../desktop/security/DesktopSessionLock.kt | 28 +- .../com/pasich/encly/desktop/SessionTest.kt | 85 ++++ .../presentation/viewmodel/BackupFlowsTest.kt | 32 -- .../viewmodel/BackupViewModelTest.kt | 2 - .../viewmodel/MarkdownExportViewModelTest.kt | 2 - .../encly/ui/screens/PinLengthScreensTest.kt | 146 ++++++ .../com/pasich/encly/ui/screens/TestApp.kt | 4 +- .../ui/screens/UnlockMethodsScreensTest.kt | 447 ----------------- .../presentation/designsystem/TextFields.kt | 3 - .../encly/presentation/navigation/NavHost.kt | 5 - .../presentation/navigation/NavRoutes.kt | 1 - .../encly/presentation/screen/FaqScreen.kt | 3 - .../presentation/screen/LockFormState.kt | 60 +-- .../encly/presentation/screen/LockForms.kt | 113 +---- .../encly/presentation/screen/LockScreen.kt | 6 +- .../screen/PinCodeConfigScreen.kt | 69 +-- .../screen/RecoveryChoiceScreen.kt | 14 +- .../screen/backup/ReauthDialog.kt | 91 +--- .../screen/pincode/PasswordInput.kt | 74 --- .../screen/pincode/PasswordStepScaffold.kt | 62 --- .../screen/settings/DuressPinScreen.kt | 1 - .../screen/settings/PasswordScreen.kt | 460 ----------------- .../screen/settings/PasswordSections.kt | 62 --- .../presentation/screen/settings/PinFlow.kt | 2 +- .../screen/settings/SecuritySettingsScreen.kt | 15 +- .../screen/settings/WipePinScreen.kt | 1 - .../presentation/viewmodel/BackupFlows.kt | 28 +- .../presentation/viewmodel/BackupViewModel.kt | 2 +- .../presentation/viewmodel/LockViewModel.kt | 53 +- .../viewmodel/MarkdownExportViewModel.kt | 2 +- .../viewmodel/SecuritySettingsViewModel.kt | 54 -- .../presentation/screen/LockFormStateTest.kt | 79 +-- .../viewmodel/LockViewModelTest.kt | 77 +-- .../SecuritySettingsViewModelTest.kt | 112 ----- 75 files changed, 782 insertions(+), 3886 deletions(-) delete mode 100644 core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt delete mode 100644 core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt delete mode 100644 core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt delete mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt create mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/RecoveryFollowUpTest.kt delete mode 100644 core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/PinLengthScreensTest.kt delete mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt delete mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt delete mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt delete mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt delete mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt diff --git a/README.md b/README.md index 4477d0f9..90746b06 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ 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 PIN of 6 to 12 digits bound to the phone's Android Keystore, an optional - password as another way in, and optional Class 3 biometric unlock. +- 🔐 **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. - 🔑 **Optional BIP39 recovery phrase** — 12 words that unlock the vault if you forget the PIN. diff --git a/SECURITY.md b/SECURITY.md index be72d5cf..17b51181 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -16,7 +16,6 @@ Encly does **not** derive the SQLCipher passphrase directly from a PIN or seed. ``` DEK (random 256 bit) ── SQLCipher raw key ├─ PIN slot AES-GCM(kek_pin) kek_pin = HKDF(PBKDF2(pin, salt, 600k) ‖ HMAC_keystore(salt ‖ pin)) -├─ password slot AES-GCM(kek_pw) kek_pw = HKDF(PBKDF2(pw, salt', 600k) ‖ HMAC_keystore("encly/pw/hw/v1" ‖ salt' ‖ pw)) ├─ biometric slot AES-GCM(keystore key) auth-per-use, BIOMETRIC_STRONG CryptoObject ├─ recovery slot AES-GCM(kek_rec) kek_rec = HKDF(SHA-256(normalized 12 words), "encly/recovery/kek/v3") ├─ backup-key slot AES-GCM(HKDF(DEK)) holds backupRoot = HKDF(SHA-256(words), "encly/backup/root/v1") @@ -69,58 +68,13 @@ 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. -- The PIN can be removed (Settings → Security → Password) while a password is set, and only - then: one of PIN and password always remains. Removing it turns the wipe PIN and the duress - PIN off with it (they can only be opened by a PIN run): their slots become random bytes of the - same length, the decoy vault keeps its notes as it does after a PIN key reset. The screen asks - for the password first, which proves the password opens the vault before the PIN is gone. - -### Password slot - -An optional second way to unlock, next to the PIN or instead of it (Settings → Security → -Password; set, change and remove each after re-authentication, with the PIN, or with the password -when there is no PIN). It is the PIN's construction over its own salt and domain: no new primitive. - -- **Rules.** 8 to 128 characters (Unicode code points), any script, **NFC-normalised** before - use so the same word typed on another keyboard or platform is the same bytes. Text that is - plain Latin (every character below U+0300) is already NFC and is never turned into a String; - other text is normalised by the platform (`java.text.Normalizer`, Foundation), which cannot - promise to wipe the intermediate copy. A text field also holds its text as a String; both are - accepted limitations, as for the recovery words. -- **KEK.** `kek_pw = HKDF-SHA256(ikm = PBKDF2-HMAC-SHA256(utf8(pw), salt', 600 000) ‖ hw, - salt = salt', info = "encly/pw/kek/v1")` with `hw = HMAC_device(slot, "encly/pw/hw/v1" ‖ salt' ‖ - utf8(pw))`, the same device-bound factor, the same key slot (A or B) and the same number of - iterations as the PIN. The factor signs any bytes, so no platform needed a change: Android - Keystore, the desktop secret stores and the iOS Enclave factor all take the new input. The - domain tag keeps the input apart from the PIN's `salt ‖ digits`. -- **Slot.** `pin.password.slot` = `version(1) ‖ salt'(16) ‖ IV(12) ‖ AES-GCM(DEK) ‖ tag`, AAD - `"encly/pw/slot/v1" ‖ version ‖ salt'`: the same 77 bytes as the PIN slot, with version 1 (the PIN - slot is 3) and an AAD of its own. A new salt is made every time the password is set. The - golden vectors in `CryptoGoldenTest` pin the slot, the KEK and a full chain with a Unicode password, - computed with an implementation independent of the code under test. -- **Lockout and strikes are shared** with the PIN: the same durable counters, charged before the - KDF runs. Five wrong tries in any mix of PIN and password start the 30 s lockout; a right - password ends the misses like a right PIN. A device key that is gone or a Keystore failure is - reported and not counted, as for the PIN. -- **PIN-only features.** The wipe PIN and the duress PIN stay PIN-only (the UI says so): a - password never opens a decoy vault and never erases. Inside a decoy session a password - re-check is counted and always refused, so a duress session cannot be used to test guesses at - the real password. -- **The device key.** Both slots hang on the same key. A key that had to be made anew (see the - PIN slot) kills the other slot too; whichever is set in that session drops the dead one in the - same atomic write (a new PIN drops the password and Settings tells; a password set then drops - the PIN with its wipe and duress slots). A wipe-PIN erase moves the key to the other alias and - drops the password slot with the rest of the old vault. -- **Old builds.** The slot is one more entry in the slot file, whose format did not change. An - older build never reads the key and ignores it. The entry lives under the `pin.` prefix on purpose: - an older build that wipes its PIN state drops it with the rest, so no password slot is left - behind for a vault that build re-creates. What an older build can still do to a vault with a - password: change the PIN (the password stays valid), or reset the key (the password then - stops working; the PIN works and the password can be set again). A vault whose PIN was removed - has no `pin.slot`: an older build then treats it as a vault with a lost PIN slot, so it - asks for the recovery phrase if there is one, and otherwise shows the damaged-vault screen, - which only wipes after an explicit confirmation. Nothing is lost by opening it with an older build - as long as nobody confirms the wipe; the newer build opens it as before. +- **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 @@ -383,11 +337,10 @@ 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 a PIN slot or a password - 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; - *password* when there is no PIN) or *Set a new PIN*. Only when no slot is left, the key is lost or a - lockout runs is a new PIN required before going on. *Keep* changes nothing in the store and +- **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). 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 index 9bef2068..074ed621 100644 --- 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 @@ -53,20 +53,6 @@ class DesktopPinFactorTest { assertFalse(first.contentEquals(factor.mac(PinKeySlot.A, salt + "482914".toByteArray()))) } - @Test - fun theSameKeyAlsoServesThePasswordSlotWhoseInputIsLongerAndNotJustNumbers() { - // The password slot signs `tag ‖ salt ‖ UTF-8 password` under the PIN's key slot: any bytes, any length. - factor.ensureKey(PinKeySlot.A) - val password = "Pässwörd-日本語-🔑".toByteArray(Charsets.UTF_8) - val input = "encly/pw/hw/v1".toByteArray(Charsets.UTF_8) + salt + password - - val hw = factor.mac(PinKeySlot.A, input) - - assertContentEquals(referenceHmac(store.peek(PinKeySlot.A)!!, input), hw) - assertNotEquals(factor.mac(PinKeySlot.A, data).toHex(), hw.toHex()) - assertContentEquals(hw, factor.mac(PinKeySlot.A, input)) - } - @Test fun macOfTheRealRandomSecretMatchesAPlainJavaxCryptoHMAC() { factor.ensureKey(PinKeySlot.A) 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 index 90fcc9ed..c3f4e645 100644 --- 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 @@ -19,10 +19,15 @@ import com.pasich.encly.core.security.SensitiveDataCleaner * ``` * - 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, the vault file or a dump of this process's memory taken at another time. 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. + * 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: @@ -40,7 +45,7 @@ import com.pasich.encly.core.security.SensitiveDataCleaner * 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 CFData the system returns for the agreement is immutable and is not. + * 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) diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index 26870cc5..0eaf3e03 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -118,7 +118,7 @@ كيف أغيّر رمز PIN؟ ينشئ Encly مفتاحًا عشوائيًا لقاعدة البيانات طوله 256 بت. يحمي رمز PIN خانة فتح منفصلة بتشفير AES-GCM، وتستخدم القياسات الحيوية مفتاحًا في Android Keystore يتطلب مصادقة عند كل استخدام. كيف يُحمى مفتاح قاعدة البيانات؟ - في شاشة القفل اضغط «استعادة الوصول» وأدخل كلماتك الـ12. إن كان رمز PIN أو كلمة المرور يعمل بعدُ فيمكنك الاحتفاظ به أو تعيين رمز PIN جديد، وإلا فتعيّن رمز PIN جديدًا فورًا. بدون عبارة الاسترداد لا توجد وسيلة أخرى للدخول: لا أحد، ولا حتى المطوّر، يستطيع إعادة تعيينها. + في شاشة القفل اضغط «استعادة الوصول» وأدخل كلماتك الـ12. إن كان رمز PIN يعمل بعدُ فيمكنك الاحتفاظ به أو تعيين رمز PIN جديد، وإلا فتعيّن رمز PIN جديدًا فورًا. بدون عبارة الاسترداد لا توجد وسيلة أخرى للدخول: لا أحد، ولا حتى المطوّر، يستطيع إعادة تعيينها. ماذا لو نسيت رمز PIN؟ افتح الإعدادات ← اللغة واختر إحدى اللغات المتاحة، أو «افتراضي النظام» لاتّباع لغة جهازك. يُطبَّق التغيير فورًا. كيف أغيّر اللغة؟ @@ -702,8 +702,6 @@ الكتل المحددة: %1$d قص حذف الكتل المحددة - هل يمكنني الفتح بكلمة مرور بدلًا من رمز PIN؟ - نعم. في الإعدادات ← الأمان ← كلمة المرور يمكنك إضافة كلمة مرور من 8 أحرف على الأقل وبأي لغة. تعمل إلى جانب رمز PIN: تتيح لك شاشة القفل التبديل بينهما ويفتح كل منهما بمفرده. يتشاركان الحد نفسه للمحاولات الخاطئة والحماية نفسها بمفتاح الجهاز. يبقى رمز المسح ورمز الإكراه رمزَي PIN فقط. يمكنك إزالة أحدهما ما دام الآخر مُعيَّنًا. ما طول رمز PIN المسموح به؟ من 6 إلى 12 رقمًا بحسب اختيارك. لا تُعرض رموز أقصر: فالقفل المؤقت ومفتاح الجهاز يحدّان من عدد المحاولات، و6 أرقام هي أقصر طول يبقى آمنًا مع هذه الحدود. لا تُظهر شاشة القفل طول رمزك أبدًا: تضغط «فتح القفل» عند الانتهاء، والأمر نفسه ينطبق على رمز المسح ورمز الإكراه. تبقى الرموز التي عيّنتها سابقًا تعمل كما هي. كود diff --git a/core/resources/src/commonMain/strings/values-ar/strings_security.xml b/core/resources/src/commonMain/strings/values-ar/strings_security.xml index f596ef6b..b35877e1 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings_security.xml @@ -48,7 +48,8 @@ %d وسم - لم يعد رمز PIN وكلمة المرور قادرين على فتح Encly على هذا الهاتف: أُعيد تعيين مفتاحهما العتادي. استخدم عبارة الاسترداد (أو بصمتك)، ثم عيّن رمز PIN جديدًا. + لم يعد رمز PIN قادرًا على فتح Encly على هذا الهاتف: أُعيد تعيين مفتاحه العتادي. استخدم عبارة الاسترداد (أو بصمتك)، ثم عيّن رمز PIN جديدًا. + يُبلغ الجهاز أنه ما زال مقفلًا، لذلك بقي Encly مغلقًا. افتح قفل الجهاز ثم حاول مرة أخرى. محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة محاولات كثيرة جدًا. أعد المحاولة بعد %d دقيقة @@ -153,47 +154,8 @@ تعرض قائمة الملاحظات أول أسطر كل ملاحظة. أوقفه لعرض العنوان والوسم والتاريخ فقط؛ ويظل البحث يجد النص الكامل. الأرقام المُدخلة: %1$d فتح القفل - استخدام كلمة المرور - استخدام رمز PIN - أدخل كلمة المرور للمتابعة - كلمة المرور - كلمة المرور خاطئة - كلمة المرور - كلمة المرور طريقة أخرى لفتح Encly إلى جانب رمز PIN. تفتح كل واحدة منهما الخزنة بمفردها، وتُحسب المحاولات الخاطئة بأي منهما ضمن القفل المؤقت نفسه. استخدم 8 أحرف على الأقل؛ وتصلح عدة كلمات متتابعة. - يبقى رمز المسح ورمز الإكراه رمزَي PIN فقط: لا يمكن أن تكون كلمة المرور أيًّا منهما. - أُعيد تعيين مفتاح الجهاز، فتوقفت كلمة المرور عن العمل وأُزيلت. عيّنها من جديد إن أردت واحدة. - تعيين كلمة مرور - تغيير كلمة المرور - إزالة كلمة المرور - إزالة كلمة المرور؟ - سيُفتح Encly برمز PIN فقط. - إزالة - أُزيلت كلمة المرور - تم حفظ كلمة المرور - تعذّر حفظ كلمة المرور. لم يتغيّر شيء. - استخدم من 8 إلى 128 حرفًا - كلمتا المرور غير متطابقتين - كلمة مرور جديدة - أعد كتابة كلمة المرور - حفظ كلمة المرور - اختر كلمة مرور - أدخل كلمة المرور - أكّد كلمة المرور قبل تغيير طريقة فتح Encly - كلمة المرور غير صحيحة - إزالة رمز PIN - إزالة رمز PIN؟ - سيُفتح Encly بكلمة المرور فقط. يحتاج رمز المسح ورمز الإكراه إلى رمز PIN، لذا يتوقفان أيضًا؛ وتحتفظ الخزنة الوهمية بملاحظاتها. اكتب كلمة المرور للتأكد من أنها تعمل. - إزالة PIN - أُزيل رمز PIN - تعذّرت إزالة رمز PIN. لم يتغيّر شيء. - مُعيَّنة. اضغط لتغييرها أو إزالتها. - غير مُعيَّنة. أضف طريقة أخرى للفتح. - توقفت عن العمل عند إعادة تعيين مفتاح الجهاز. اضغط لتعيينها من جديد. - لم يُعيَّن رمز PIN. اضغط لإضافته. تم قبول عبارة الاسترداد رمز PIN لا يزال يعمل. احتفظ به أو عيّن رمزًا جديدًا. - كلمة المرور لا تزال تعمل. احتفظ بها أو عيّن رمز 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 index b2b71840..ae45f871 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -132,7 +132,7 @@ 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 oder dein Passwort 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. + 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? @@ -689,8 +689,6 @@ Die Änderungen dieser Version Verstanden Notizen werden nach 28 Tagen im Papierkorb endgültig gelöscht. - Kann ich statt mit einer PIN mit einem Passwort entsperren? - Ja. Unter Einstellungen → Sicherheit → Passwort kannst du ein Passwort mit mindestens 8 Zeichen in jeder Sprache hinzufügen. Es gilt neben deiner PIN: Auf dem Sperrbildschirm wechselst du zwischen beiden, und jedes entsperrt für sich. Beide teilen sich dasselbe Limit für Fehlversuche und denselben Schutz durch den Gerätschlüssel. Lösch-PIN und Zwangs-PIN bleiben reine PINs. Du kannst jedes der beiden entfernen, solange das andere festgelegt ist. 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 diff --git a/core/resources/src/commonMain/strings/values-de/strings_security.xml b/core/resources/src/commonMain/strings/values-de/strings_security.xml index 23ddfde2..bce41ba4 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_security.xml @@ -36,7 +36,8 @@ %d Tags - Deine PIN und dein Passwort können 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. + 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 @@ -133,47 +134,8 @@ 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 - Passwort verwenden - PIN verwenden - Gib dein Passwort ein, um fortzufahren - Passwort - Falsches Passwort - Passwort - Ein Passwort ist ein weiterer Weg, Encly zu entsperren, neben deiner PIN. Jedes von beiden öffnet den Tresor für sich, und Fehlversuche mit einem von beiden zählen für dieselbe Sperre. Nutze mindestens 8 Zeichen; mehrere aneinandergereihte Wörter eignen sich gut. - Lösch-PIN und Zwangs-PIN bleiben reine PINs: Ein Passwort kann keine von beiden sein. - Dein Gerätschlüssel wurde zurückgesetzt, daher funktionierte das Passwort nicht mehr und wurde entfernt. Lege es neu fest, wenn du eines möchtest. - Passwort festlegen - Passwort ändern - Passwort entfernen - Passwort entfernen? - Encly lässt sich dann nur noch mit deiner PIN entsperren. - Entfernen - Passwort entfernt - Passwort gespeichert - Das Passwort konnte nicht gespeichert werden. Es wurde nichts geändert. - Verwende 8 bis 128 Zeichen - Die Passwörter stimmen nicht überein - Neues Passwort - Passwort wiederholen - Passwort speichern - Wähle ein Passwort - Gib dein Passwort ein - Bestätige dein Passwort, bevor du änderst, wie Encly entsperrt wird - Das Passwort ist falsch - PIN entfernen - PIN entfernen? - Encly lässt sich dann nur noch mit deinem Passwort entsperren. Lösch-PIN und Zwangs-PIN brauchen eine PIN und werden deshalb ebenfalls ausgeschaltet; der Tarn-Tresor behält seine Notizen. Gib dein Passwort ein, um sicherzugehen, dass es funktioniert. - PIN entfernen - PIN entfernt - Die PIN konnte nicht entfernt werden. Es wurde nichts geändert. - Festgelegt. Tippe, um es zu ändern oder zu entfernen. - Nicht festgelegt. Füge einen weiteren Weg zum Entsperren hinzu. - Funktioniert nicht mehr, seit der Gerätschlüssel zurückgesetzt wurde. Tippe, um es neu festzulegen. - Keine PIN festgelegt. Tippe, um eine hinzuzufügen. Wiederherstellungsphrase akzeptiert Deine PIN funktioniert noch. Behalte sie oder lege eine neue fest. - Dein Passwort funktioniert noch. Behalte es oder lege eine PIN fest. Meine PIN behalten - Mein Passwort 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 index e78b4193..83b9b82c 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -132,7 +132,7 @@ ¿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 o tu contraseña siguen funcionando, puedes conservarlos 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. + 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? @@ -695,8 +695,6 @@ Los cambios de esta versión Entendido Las notas se eliminan definitivamente tras 28 días en la papelera. - ¿Puedo desbloquear con una contraseña en lugar de un PIN? - Sí. En Ajustes → Seguridad → Contraseña puedes añadir una contraseña de al menos 8 caracteres, en cualquier idioma. Funciona junto a tu PIN: la pantalla de bloqueo te deja cambiar entre ambos y cada uno desbloquea por sí solo. Comparten el mismo límite de intentos fallidos y la misma protección con la clave del dispositivo. El PIN de borrado y el PIN de coacción siguen siendo solo PIN. Puedes quitar cualquiera de los dos mientras el otro esté establecido. ¿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 diff --git a/core/resources/src/commonMain/strings/values-es/strings_security.xml b/core/resources/src/commonMain/strings/values-es/strings_security.xml index 87f4acc8..c5bd89cd 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_security.xml @@ -39,7 +39,8 @@ %d etiquetas - Tu PIN y tu contraseña ya no pueden 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. + 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 @@ -138,47 +139,8 @@ 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 - Usar contraseña - Usar PIN - Introduce tu contraseña para continuar - Contraseña - Contraseña incorrecta - Contraseña - Una contraseña es otra forma de desbloquear Encly, además de tu PIN. Cada una abre la bóveda por sí sola, y los intentos fallidos con cualquiera de las dos cuentan para el mismo bloqueo. Usa al menos 8 caracteres; unas cuantas palabras seguidas funcionan bien. - El PIN de borrado y el PIN de coacción siguen siendo solo PIN: una contraseña no puede ser ninguno de los dos. - Se restableció la clave del dispositivo, así que la contraseña dejó de funcionar y se eliminó. Vuelve a configurarla si quieres una. - Establecer una contraseña - Cambiar la contraseña - Quitar la contraseña - ¿Quitar la contraseña? - Encly solo se desbloqueará con tu PIN. - Quitar - Contraseña eliminada - Contraseña guardada - No se pudo guardar la contraseña. No se cambió nada. - Usa de 8 a 128 caracteres - Las contraseñas no coinciden - Contraseña nueva - Repite la contraseña - Guardar la contraseña - Elige una contraseña - Introduce tu contraseña - Confirma tu contraseña antes de cambiar cómo se desbloquea Encly - La contraseña no es correcta - Quitar el PIN - ¿Quitar el PIN? - Encly solo se desbloqueará con tu contraseña. El PIN de borrado y el PIN de coacción necesitan un PIN, así que también se desactivan; la bóveda señuelo conserva sus notas. Escribe tu contraseña para comprobar que funciona. - Quitar el PIN - PIN eliminado - No se pudo quitar el PIN. No se cambió nada. - Establecida. Toca para cambiarla o quitarla. - Sin establecer. Añade otra forma de desbloquear. - Dejó de funcionar al restablecerse la clave del dispositivo. Toca para configurarla de nuevo. - No hay PIN. Toca para añadir uno. Frase de recuperación aceptada Tu PIN sigue funcionando. Consérvalo o elige uno nuevo. - Tu contraseña sigue funcionando. Consérvala o establece un PIN. Conservar mi PIN - Conservar mi contraseña 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 index 75aca0dc..05f56701 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -132,7 +132,7 @@ 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 ou votre mot de passe 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. + 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 ? @@ -695,8 +695,6 @@ Les changements de cette version Compris Les notes sont supprimées définitivement après 28 jours dans la corbeille. - Puis-je déverrouiller avec un mot de passe plutôt qu’un code PIN ? - Oui. Dans Paramètres → Sécurité → Mot de passe, vous pouvez ajouter un mot de passe d’au moins 8 caractères, dans n’importe quelle langue. Il s’ajoute à votre code PIN : l’écran de verrouillage permet de passer de l’un à l’autre, et chacun déverrouille à lui seul. Les deux partagent la même limite d’essais erronés et la même protection par la clé de l’appareil. Le PIN d’effacement et le PIN de contrainte restent des codes PIN. Vous pouvez supprimer l’un des deux tant que l’autre est défini. 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings_security.xml b/core/resources/src/commonMain/strings/values-fr/strings_security.xml index 30b659fe..8bc23d8c 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_security.xml @@ -39,7 +39,8 @@ %d étiquettes - Votre PIN et votre mot de passe ne peuvent plus déverrouiller Encly sur ce téléphone : leur clé matérielle a été réinitialisée. Utilisez votre phrase de récupération (ou votre empreinte), puis définissez un nouveau PIN. + 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 @@ -138,47 +139,8 @@ 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 - Utiliser le mot de passe - Utiliser le code PIN - Saisissez votre mot de passe pour continuer - Mot de passe - Mot de passe incorrect - Mot de passe - Un mot de passe est une autre façon de déverrouiller Encly, en plus de votre code PIN. Chacun ouvre le coffre à lui seul, et les échecs de l’un comme de l’autre comptent pour le même blocage. Utilisez au moins 8 caractères ; quelques mots à la suite fonctionnent bien. - Le PIN d\'effacement et le PIN de contrainte restent des codes PIN : un mot de passe ne peut être ni l’un ni l’autre. - La clé de l’appareil a été réinitialisée : le mot de passe ne fonctionnait plus et a été supprimé. Définissez-le à nouveau si vous en voulez un. - Définir un mot de passe - Modifier le mot de passe - Supprimer le mot de passe - Supprimer le mot de passe ? - Encly ne se déverrouillera plus qu’avec votre code PIN. - Supprimer - Mot de passe supprimé - Mot de passe enregistré - Impossible d\'enregistrer le mot de passe. Rien n’a été modifié. - Utilisez de 8 à 128 caractères - Les mots de passe ne correspondent pas - Nouveau mot de passe - Répétez le mot de passe - Enregistrer le mot de passe - Choisissez un mot de passe - Saisissez votre mot de passe - Confirmez votre mot de passe avant de modifier le déverrouillage d’Encly - Le mot de passe est incorrect - Supprimer le code PIN - Supprimer le code PIN ? - Encly ne se déverrouillera plus qu’avec votre mot de passe. Le PIN d’effacement et le PIN de contrainte ont besoin d’un code PIN : ils sont donc désactivés aussi ; le coffre leurre garde ses notes. Saisissez votre mot de passe pour vérifier qu’il fonctionne. - Supprimer le PIN - Code PIN supprimé - Impossible de supprimer le code PIN. Rien n’a été modifié. - Défini. Touchez pour le modifier ou le supprimer. - Non défini. Ajoutez une autre façon de déverrouiller. - A cessé de fonctionner après la réinitialisation de la clé de l’appareil. Touchez pour le redéfinir. - Aucun code PIN défini. Touchez pour en ajouter un. Phrase de récupération acceptée Votre code PIN fonctionne toujours. Gardez-le ou définissez-en un nouveau. - Votre mot de passe fonctionne toujours. Gardez-le ou définissez un code PIN. Garder mon code PIN - Garder mon mot de passe 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 index 975a6cb0..4779661a 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -132,7 +132,7 @@ 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 o la password funzionano ancora, puoi tenerli 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. + 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? @@ -695,8 +695,6 @@ Le novità di questa versione Ho capito Le note vengono eliminate definitivamente dopo 28 giorni nel cestino. - Posso sbloccare con una password invece del PIN? - Sì. In Impostazioni → Sicurezza → Password puoi aggiungere una password di almeno 8 caratteri, in qualsiasi lingua. Funziona accanto al PIN: la schermata di blocco permette di passare dal PIN alla password e viceversa, e ciascuno sblocca da solo. Condividono lo stesso limite di tentativi errati e la stessa protezione con la chiave del dispositivo. Il PIN di cancellazione e il PIN di coercizione restano solo PIN. Puoi rimuovere uno dei due finché l’altro è impostato. 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings_security.xml b/core/resources/src/commonMain/strings/values-it/strings_security.xml index e161e980..d7857b7a 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_security.xml @@ -39,7 +39,8 @@ %d tag - Il tuo PIN e la tua password non possono più sbloccare Encly su questo telefono: la loro chiave hardware è stata reimpostata. Usa la frase di recupero (o l\'impronta), poi imposta un nuovo PIN. + 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 @@ -138,47 +139,8 @@ 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 - Usa la password - Usa il PIN - Inserisci la password per continuare - Password - Password errata - Password - Una password è un altro modo per sbloccare Encly, oltre al PIN. Ciascuno dei due apre la cassaforte da solo e i tentativi errati con uno qualsiasi dei due contano per lo stesso blocco. Usa almeno 8 caratteri; alcune parole di seguito vanno benissimo. - Il PIN di cancellazione e il PIN di coercizione restano solo PIN: una password non può essere nessuno dei due. - La chiave del dispositivo è stata reimpostata, quindi la password ha smesso di funzionare ed è stata rimossa. Impostala di nuovo se ti serve. - Imposta una password - Cambia password - Rimuovi la password - Rimuovere la password? - Encly si sbloccherà solo con il PIN. - Rimuovi - Password rimossa - Password salvata - Impossibile salvare la password. Non è cambiato nulla. - Usa da 8 a 128 caratteri - Le password non corrispondono - Nuova password - Ripeti la password - Salva la password - Scegli una password - Inserisci la password - Conferma la password prima di cambiare il modo in cui si sblocca Encly - La password non è corretta - Rimuovi il PIN - Rimuovere il PIN? - Encly si sbloccherà solo con la password. Il PIN di cancellazione e il PIN di coercizione richiedono un PIN, quindi vengono disattivati anch’essi; la cassaforte civetta conserva le sue note. Scrivi la password per verificare che funzioni. - Rimuovi il PIN - PIN rimosso - Impossibile rimuovere il PIN. Non è cambiato nulla. - Impostata. Tocca per cambiarla o rimuoverla. - Non impostata. Aggiungi un altro modo per sbloccare. - Ha smesso di funzionare quando la chiave del dispositivo è stata reimpostata. Tocca per impostarla di nuovo. - Nessun PIN impostato. Tocca per aggiungerne uno. Frase di recupero accettata Il tuo PIN funziona ancora. Tienilo o imposta un nuovo PIN. - La tua password funziona ancora. Tienila o imposta un PIN. Tieni il mio PIN - Tieni la mia password 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 index 83b91243..7bf648ed 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -132,7 +132,7 @@ 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 of wachtwoord 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. + 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? @@ -689,8 +689,6 @@ De wijzigingen in deze versie Begrepen Notities worden na 28 dagen in de prullenbak definitief verwijderd. - Kan ik met een wachtwoord ontgrendelen in plaats van met een pincode? - Ja. Onder Instellingen → Beveiliging → Wachtwoord kun je een wachtwoord van minstens 8 tekens toevoegen, in elke taal. Het werkt naast je pincode: op het vergrendelscherm wissel je tussen beide en elk ontgrendelt zelfstandig. Ze delen dezelfde limiet voor foute pogingen en dezelfde bescherming door de sleutel van het apparaat. De wis-pincode en de dwangpincode blijven pincodes. Je kunt een van beide verwijderen zolang de ander is ingesteld. 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings_security.xml b/core/resources/src/commonMain/strings/values-nl/strings_security.xml index c92224ec..1692fc24 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_security.xml @@ -36,7 +36,8 @@ %d tags - Je pincode en wachtwoord kunnen Encly op deze telefoon niet meer ontgrendelen: de hardwaresleutel is gereset. Gebruik je herstelzin (of vingerafdruk) en stel daarna een nieuwe pincode in. + 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 @@ -133,47 +134,8 @@ 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 - Wachtwoord gebruiken - Pincode gebruiken - Voer je wachtwoord in om door te gaan - Wachtwoord - Onjuist wachtwoord - Wachtwoord - Een wachtwoord is een andere manier om Encly te ontgrendelen, naast je pincode. Elk van beide opent de kluis zelfstandig, en foute pogingen met een van beide tellen mee voor dezelfde blokkade. Gebruik minstens 8 tekens; een paar woorden achter elkaar werkt goed. - De wis-pincode en de dwangpincode blijven pincodes: een wachtwoord kan geen van beide zijn. - De sleutel van het apparaat is gereset, dus het wachtwoord werkte niet meer en is verwijderd. Stel het opnieuw in als je er een wilt. - Wachtwoord instellen - Wachtwoord wijzigen - Wachtwoord verwijderen - Wachtwoord verwijderen? - Encly ontgrendelt dan alleen nog met je pincode. - Verwijderen - Wachtwoord verwijderd - Wachtwoord opgeslagen - Kan het wachtwoord niet opslaan. Er is niets gewijzigd. - Gebruik 8 tot 128 tekens - De wachtwoorden komen niet overeen - Nieuw wachtwoord - Herhaal het wachtwoord - Wachtwoord opslaan - Kies een wachtwoord - Voer je wachtwoord in - Bevestig je wachtwoord voordat je wijzigt hoe Encly ontgrendelt - Het wachtwoord is onjuist - Pincode verwijderen - Pincode verwijderen? - Encly ontgrendelt dan alleen nog met je wachtwoord. De wis-pincode en de dwangpincode hebben een pincode nodig en worden dus ook uitgeschakeld; de schijnkluis behoudt zijn notities. Typ je wachtwoord om te controleren of het werkt. - Pincode verwijderen - Pincode verwijderd - Kan de pincode niet verwijderen. Er is niets gewijzigd. - Ingesteld. Tik om het te wijzigen of te verwijderen. - Niet ingesteld. Voeg een andere manier om te ontgrendelen toe. - Werkt niet meer sinds de sleutel van het apparaat is gereset. Tik om het opnieuw in te stellen. - Geen pincode ingesteld. Tik om er een toe te voegen. Herstelzin geaccepteerd Je pincode werkt nog. Behoud hem of stel een nieuwe in. - Je wachtwoord werkt nog. Behoud het of stel een pincode in. Mijn pincode behouden - Mijn wachtwoord 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 index 7f784ad9..59223781 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -132,7 +132,7 @@ 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 lub hasło nadal działa, możesz je 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ć. + 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? @@ -701,8 +701,6 @@ Zmiany w tej wersji Rozumiem Notatki są trwale usuwane po 28 dniach w koszu. - Czy mogę odblokowywać hasłem zamiast PIN-em? - Tak. W Ustawienia → Bezpieczeństwo → Hasło możesz dodać hasło z co najmniej 8 znaków, w dowolnym języku. Działa obok PIN-u: na ekranie blokady przełączasz się między nimi, a każde odblokowuje samodzielnie. Oba mają ten sam limit błędnych prób i tę samą ochronę kluczem urządzenia. PIN kasujący i PIN przymusowy pozostają wyłącznie kodami PIN. Możesz usunąć jedno z nich, dopóki drugie jest ustawione. 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings_security.xml b/core/resources/src/commonMain/strings/values-pl/strings_security.xml index 9a703b08..5b4e13d6 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_security.xml @@ -42,7 +42,8 @@ %d tagu - Twój PIN i hasło nie mogą już odblokować Encly na tym telefonie: ich klucz sprzętowy został zresetowany. Użyj frazy odzyskiwania (lub odcisku palca), a potem ustaw nowy PIN. + 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 @@ -143,47 +144,8 @@ 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 - Użyj hasła - Użyj PIN-u - Wpisz hasło, aby kontynuować - Hasło - Nieprawidłowe hasło - Hasło - Hasło to kolejny sposób odblokowania Encly obok PIN-u. Każde z nich samodzielnie otwiera sejf, a błędne próby z użyciem któregokolwiek wliczają się do tej samej blokady. Użyj co najmniej 8 znaków; dobrze sprawdza się kilka słów z rzędu. - PIN kasujący i PIN przymusowy pozostają wyłącznie kodami PIN: hasło nie może być żadnym z nich. - Klucz urządzenia został zresetowany, więc hasło przestało działać i zostało usunięte. Ustaw je ponownie, jeśli chcesz. - Ustaw hasło - Zmień hasło - Usuń hasło - Usunąć hasło? - Encly będzie odblokowywane tylko za pomocą PIN-u. - Usuń - Hasło usunięte - Hasło zapisane - Nie udało się zapisać hasła. Nic nie zostało zmienione. - Użyj od 8 do 128 znaków - Hasła nie są takie same - Nowe hasło - Powtórz hasło - Zapisz hasło - Wybierz hasło - Wpisz hasło - Potwierdź hasło, zanim zmienisz sposób odblokowywania Encly - Hasło jest nieprawidłowe - Usuń PIN - Usunąć PIN? - Encly będzie odblokowywane tylko hasłem. PIN kasujący i PIN przymusowy wymagają PIN-u, więc również zostaną wyłączone; sejf-przynęta zachowa swoje notatki. Wpisz hasło, aby upewnić się, że działa. - Usuń PIN - PIN usunięty - Nie udało się usunąć PIN-u. Nic nie zostało zmienione. - Ustawione. Stuknij, aby zmienić lub usunąć. - Nie ustawiono. Dodaj kolejny sposób odblokowania. - Przestało działać po zresetowaniu klucza urządzenia. Stuknij, aby ustawić ponownie. - Nie ustawiono PIN-u. Stuknij, aby go dodać. Fraza odzyskiwania zaakceptowana Twój PIN nadal działa. Zachowaj go albo ustaw nowy. - Twoje hasło nadal działa. Zachowaj je albo ustaw PIN. Zachowaj mój PIN - Zachowaj moje hasło 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 index 3a5a672b..dfb6d37b 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -132,7 +132,7 @@ 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 ou a senha ainda funcionarem, você pode mantê-los 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. + 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? @@ -695,8 +695,6 @@ As alterações desta versão Entendi As notas são eliminadas definitivamente após 28 dias na lixeira. - Posso desbloquear com uma senha em vez de um PIN? - Sim. Em Configurações → Segurança → Senha você pode adicionar uma senha com pelo menos 8 caracteres, em qualquer idioma. Ela funciona ao lado do PIN: a tela de bloqueio permite alternar entre os dois e cada um desbloqueia sozinho. Os dois compartilham o mesmo limite de tentativas erradas e a mesma proteção pela chave do dispositivo. O PIN de apagamento e o PIN de coação continuam sendo apenas PINs. Você pode remover um dos dois enquanto o outro estiver definido. 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings_security.xml b/core/resources/src/commonMain/strings/values-pt/strings_security.xml index 5e847dfc..1854e936 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_security.xml @@ -39,7 +39,8 @@ %d etiquetas - Seu PIN e sua senha não podem mais desbloquear o Encly neste telefone: a chave de hardware deles foi redefinida. Use sua frase de recuperação (ou impressão digital) e depois defina um novo PIN. + 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 @@ -138,47 +139,8 @@ 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 - Usar senha - Usar PIN - Digite sua senha para continuar - Senha - Senha incorreta - Senha - Uma senha é outra forma de desbloquear o Encly, além do PIN. Cada uma abre o cofre sozinha, e as tentativas erradas com qualquer uma contam para o mesmo bloqueio. Use pelo menos 8 caracteres; algumas palavras em sequência funcionam bem. - O PIN de apagamento e o PIN de coação continuam sendo apenas PINs: uma senha não pode ser nenhum dos dois. - A chave do dispositivo foi redefinida, então a senha parou de funcionar e foi removida. Defina-a de novo se quiser uma. - Definir uma senha - Alterar senha - Remover senha - Remover a senha? - O Encly será desbloqueado apenas com o PIN. - Remover - Senha removida - Senha salva - Não foi possível salvar a senha. Nada foi alterado. - Use de 8 a 128 caracteres - As senhas não coincidem - Nova senha - Repita a senha - Salvar senha - Escolha uma senha - Digite sua senha - Confirme sua senha antes de mudar como o Encly é desbloqueado - A senha está incorreta - Remover PIN - Remover o PIN? - O Encly será desbloqueado apenas com a senha. O PIN de apagamento e o PIN de coação precisam de um PIN, então também serão desativados; o cofre isca mantém as notas. Digite sua senha para ter certeza de que funciona. - Remover PIN - PIN removido - Não foi possível remover o PIN. Nada foi alterado. - Definida. Toque para alterar ou remover. - Não definida. Adicione outra forma de desbloquear. - Parou de funcionar quando a chave do dispositivo foi redefinida. Toque para definir de novo. - Nenhum PIN definido. Toque para adicionar um. Frase de recuperação aceita Seu PIN ainda funciona. Mantenha-o ou defina um novo. - Sua senha ainda funciona. Mantenha-a ou defina um PIN. Manter meu PIN - Manter minha senha 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 index e4abbd5b..2809643f 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -132,7 +132,7 @@ Як змінити PIN? Encly створює випадковий 256-бітний ключ бази даних. PIN захищає окрему зашифровану (AES-GCM) копію цього ключа, а біометрія використовує ключ Android Keystore, який щоразу потребує підтвердження біометрією. Як захищений ключ бази даних? - На екрані блокування натисніть «Відновити доступ» і введіть 12 слів. Якщо ваш PIN або пароль ще працює, його можна залишити або задати новий PIN; якщо ні, новий PIN задається одразу. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. + На екрані блокування натисніть «Відновити доступ» і введіть 12 слів. Якщо ваш PIN ще працює, його можна залишити або задати новий PIN; якщо ні, новий PIN задається одразу. Без фрази відновлення іншого способу увійти немає: скинути його не може ніхто, навіть розробник. Що буде, якщо я забуду PIN? Відкрийте Налаштування → Мова та виберіть одну з доступних мов або «Як у системі», щоб використовувати мову пристрою. Зміна застосовується одразу. Як змінити мову? @@ -701,8 +701,6 @@ Зміни в цій версії Зрозуміло Нотатки остаточно видаляються через 28 днів у кошику. - Чи можна розблоковувати паролем замість PIN? - Так. У Налаштування → Безпека → Пароль можна додати пароль щонайменше з 8 символів будь-якою мовою. Він працює поряд із PIN: на екрані блокування можна перемикатися між ними, і кожен розблоковує сам по собі. Обидва мають спільний ліміт помилкових спроб і той самий захист ключем пристрою. PIN для стирання та PIN під примусом залишаються лише PIN-кодами. Будь-який із двох можна видалити, поки задано інший. Якої довжини може бути PIN? Від 6 до 12 цифр на ваш вибір. Коротших PIN не пропонується: блокування й ключ пристрою обмежують кількість спроб, а 6 цифр — найкоротший PIN, що лишається безпечним за цих обмежень. Екран блокування ніколи не показує довжину вашого PIN: коли закінчите, натисніть «Розблокувати» — так само для PIN для стирання та PIN під примусом. PIN, задані раніше, працюють без змін. Код diff --git a/core/resources/src/commonMain/strings/values-uk/strings_security.xml b/core/resources/src/commonMain/strings/values-uk/strings_security.xml index 566e3290..7ea63052 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_security.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_security.xml @@ -42,7 +42,8 @@ %d тега - Ваші PIN і пароль більше не можуть розблокувати Encly на цьому телефоні: їхній апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. + Ваш PIN більше не може розблокувати Encly на цьому телефоні: його апаратний ключ скинуто. Скористайтеся фразою відновлення (або відбитком пальця), а потім задайте новий PIN. + Пристрій повідомляє, що він досі заблокований, тому Encly залишився закритим. Розблокуйте пристрій і спробуйте ще раз. Забагато спроб. Спробуйте через %d хвилину Забагато спроб. Спробуйте через %d хвилини @@ -143,47 +144,8 @@ Список нотаток показує перші рядки кожної нотатки. Вимкніть, щоб бачити лише заголовок, мітку й дату; пошук і далі знаходить увесь текст. Введено цифр: %1$d Розблокувати - Використати пароль - Використати PIN - Введіть пароль, щоб продовжити - Пароль - Неправильний пароль - Пароль - Пароль — ще один спосіб розблокувати Encly поряд із PIN. Кожен із них відкриває сховище самостійно, а помилкові спроби з будь-яким із них враховуються в одному й тому самому блокуванні. Використайте щонайменше 8 символів; добре підходить кілька слів поспіль. - PIN для стирання та PIN під примусом залишаються лише PIN-кодами: пароль не може бути жодним із них. - Ключ пристрою було скинуто, тож пароль перестав працювати й був видалений. Задайте його знову, якщо він вам потрібен. - Задати пароль - Змінити пароль - Видалити пароль - Видалити пароль? - Encly розблоковуватиметься лише за допомогою PIN. - Видалити - Пароль видалено - Пароль збережено - Не вдалося зберегти пароль. Нічого не змінено. - Використайте від 8 до 128 символів - Паролі не збігаються - Новий пароль - Повторіть пароль - Зберегти пароль - Оберіть пароль - Введіть свій пароль - Підтвердьте пароль, перш ніж змінювати спосіб розблокування Encly - Пароль неправильний - Видалити PIN - Видалити PIN? - Encly розблоковуватиметься лише паролем. PIN для стирання та PIN під примусом потребують PIN, тому їх також буде вимкнено; підставне сховище збереже свої нотатки. Введіть пароль, щоб переконатися, що він працює. - Видалити PIN - PIN видалено - Не вдалося видалити PIN. Нічого не змінено. - Задано. Натисніть, щоб змінити або видалити. - Не задано. Додайте ще один спосіб розблокування. - Перестав працювати після скидання ключа пристрою. Натисніть, щоб задати знову. - PIN не задано. Натисніть, щоб додати. Фразу відновлення прийнято Ваш PIN усе ще працює. Залиште його або задайте новий. - Ваш пароль усе ще працює. Залиште його або задайте PIN. Залишити мій PIN - Залишити мій пароль Задати новий PIN diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index 985dfb30..eca674a7 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -149,7 +149,7 @@ 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 or password 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. + 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? @@ -707,8 +707,6 @@ The changes in this version Got it Notes are deleted for good after 28 days in the trash. - Can I unlock with a password instead of a PIN? - Yes. In Settings → Security → Password you can add a password of at least 8 characters, any language. It works next to your PIN: the lock screen lets you switch between them, and each one unlocks on its own. Both share the same limit on wrong attempts and the same protection by the device key. The wipe PIN and the duress PIN stay PIN-only. You can remove either one while the other is set. 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 diff --git a/core/resources/src/commonMain/strings/values/strings_security.xml b/core/resources/src/commonMain/strings/values/strings_security.xml index 36af40b9..d5715a3c 100644 --- a/core/resources/src/commonMain/strings/values/strings_security.xml +++ b/core/resources/src/commonMain/strings/values/strings_security.xml @@ -36,7 +36,8 @@ %d tags - Your PIN and password can no longer unlock Encly on this phone: their hardware key was reset. Use your recovery phrase (or fingerprint), then set a new PIN. + 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 @@ -133,47 +134,8 @@ 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 - Use password - Use PIN - Enter your password to continue - Password - Wrong password - Password - A password is another way to unlock Encly, next to your PIN. Each one opens the vault on its own, and wrong attempts with either count toward the same lockout. Use at least 8 characters; a few words strung together work well. - The wipe PIN and the duress PIN stay PIN-only: a password can\'t be either of them. - Your device key was reset, so the password stopped working and was removed. Set it again if you want one. - Set a password - Change password - Remove password - Remove the password? - Encly will unlock with your PIN only. - Remove - Password removed - Password saved - Couldn\'t save the password. Nothing was changed. - Use 8 to 128 characters - Passwords don\'t match - New password - Repeat the password - Save password - Choose a password - Enter your password - Confirm your password before changing how Encly unlocks - Password is incorrect - Remove PIN - Remove the PIN? - Encly will unlock with your password only. The wipe PIN and the duress PIN need a PIN, so they are turned off too; the decoy vault keeps its notes. Type your password to make sure it works. - Remove PIN - PIN removed - Couldn\'t remove the PIN. Nothing was changed. - Set. Tap to change or remove it. - Not set. Add another way to unlock. - Stopped working when the device key was reset. Tap to set it again. - No PIN is set. Tap to add one. Recovery phrase accepted Your PIN still works. Keep it, or set a new one. - Your password still works. Keep it, or set a PIN. Keep my PIN - Keep my password Set a new PIN diff --git a/core/security/api/android/security.api b/core/security/api/android/security.api index 4279f4bc..65e067b0 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -358,8 +358,8 @@ public final class com/pasich/encly/core/backup/ParsedBackup { } public final class com/pasich/encly/core/security/AuthSettings { - public fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)V - public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 @@ -367,17 +367,13 @@ public final class com/pasich/encly/core/security/AuthSettings { public final fun component5 ()Z public final fun component6 ()Z public final fun component7 ()Z - public final fun component8 ()Z - public final fun component9 ()Z - public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; - public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILjava/lang/Object;)Lcom/pasich/encly/core/security/AuthSettings; + 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 getHasPassword ()Z public final fun getHasPinSlot ()Z - public final fun getPasswordTurnedOff ()Z public final fun getWipePinTurnedOff ()Z public fun hashCode ()I public final fun isBiometricEnabled ()Z @@ -413,7 +409,6 @@ public final class com/pasich/encly/core/security/AuthenticationManager { 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 changePassword ([C[B)Lcom/pasich/encly/core/security/PasswordChange; 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; @@ -426,24 +421,17 @@ public final class com/pasich/encly/core/security/AuthenticationManager { public final fun ensureWipeSlot ()Z public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; public final fun hasDecoyVault ([B)Z - public final fun hasPasswordSlot ()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 passwordTurnedOff ()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 removePassword ()Z - public final fun removePin ([B)Z public final fun removeWipePin ()Z - public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/PinUnlock; public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; - public final fun verifyPasswordAuth ([CZ)Z - public static synthetic fun verifyPasswordAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z 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 @@ -470,9 +458,7 @@ public final class com/pasich/encly/core/security/AuthenticationManager$MetroFac } public final class com/pasich/encly/core/security/AuthenticationManagerKt { - public static final field MAX_PASSWORD_LENGTH I public static final field MAX_PIN_LENGTH I - public static final field MIN_PASSWORD_LENGTH I public static final field MIN_PIN_LENGTH I } @@ -699,15 +685,6 @@ public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; } -public final class com/pasich/encly/core/security/PasswordChange : java/lang/Enum { - public static final field FAILED Lcom/pasich/encly/core/security/PasswordChange; - public static final field INVALID Lcom/pasich/encly/core/security/PasswordChange; - public static final field SET Lcom/pasich/encly/core/security/PasswordChange; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PasswordChange; - public static fun values ()[Lcom/pasich/encly/core/security/PasswordChange; -} - 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; @@ -843,7 +820,6 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; public final fun canReauthWithBiometric ()Z public final fun canResetPinWithoutCurrent ()Z - public final fun changePassword ([C)Lcom/pasich/encly/core/security/PasswordChange; public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; public final fun commitInitialSetup ()Z public final fun completePendingWipe ()V @@ -877,21 +853,16 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun pinLockoutRemainingMillis ()J public final fun recoveryStage ()Lcom/pasich/encly/core/security/RecoveryStage; public final fun removeDuressPin ()Z - public final fun removePassword ()Z - public final fun removePin ()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 unlockMethods ()Lcom/pasich/encly/core/security/UnlockMethods; - public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; 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 verifyPassword ([C)Z public final fun verifyPin ([C)Z public final fun verifySeed ([C)Z public final fun wipeAndReset ()Z @@ -960,6 +931,7 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ 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 @@ -993,19 +965,6 @@ public final class com/pasich/encly/core/security/SessionLockManagerKt { public static final field LOCK_FLUSH_TIMEOUT_MS J } -public final class com/pasich/encly/core/security/UnlockMethods { - public fun (ZZ)V - public final fun component1 ()Z - public final fun component2 ()Z - public final fun copy (ZZ)Lcom/pasich/encly/core/security/UnlockMethods; - public static synthetic fun copy$default (Lcom/pasich/encly/core/security/UnlockMethods;ZZILjava/lang/Object;)Lcom/pasich/encly/core/security/UnlockMethods; - public fun equals (Ljava/lang/Object;)Z - public final fun getPassword ()Z - public final fun getPin ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - 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 diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index 4279f4bc..65e067b0 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -358,8 +358,8 @@ public final class com/pasich/encly/core/backup/ParsedBackup { } public final class com/pasich/encly/core/security/AuthSettings { - public fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)V - public synthetic fun (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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 @@ -367,17 +367,13 @@ public final class com/pasich/encly/core/security/AuthSettings { public final fun component5 ()Z public final fun component6 ()Z public final fun component7 ()Z - public final fun component8 ()Z - public final fun component9 ()Z - public final fun copy (Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZ)Lcom/pasich/encly/core/security/AuthSettings; - public static synthetic fun copy$default (Lcom/pasich/encly/core/security/AuthSettings;Lcom/pasich/encly/core/security/AuthType;ZZZZZZZZILjava/lang/Object;)Lcom/pasich/encly/core/security/AuthSettings; + 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 getHasPassword ()Z public final fun getHasPinSlot ()Z - public final fun getPasswordTurnedOff ()Z public final fun getWipePinTurnedOff ()Z public fun hashCode ()I public final fun isBiometricEnabled ()Z @@ -413,7 +409,6 @@ public final class com/pasich/encly/core/security/AuthenticationManager { 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 changePassword ([C[B)Lcom/pasich/encly/core/security/PasswordChange; 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; @@ -426,24 +421,17 @@ public final class com/pasich/encly/core/security/AuthenticationManager { public final fun ensureWipeSlot ()Z public final fun getAuthType ()Lcom/pasich/encly/core/security/AuthType; public final fun hasDecoyVault ([B)Z - public final fun hasPasswordSlot ()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 passwordTurnedOff ()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 removePassword ()Z - public final fun removePin ([B)Z public final fun removeWipePin ()Z - public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/PinUnlock; public final fun unlockWithPin ([C)Lcom/pasich/encly/core/security/PinUnlock; - public final fun verifyPasswordAuth ([CZ)Z - public static synthetic fun verifyPasswordAuth$default (Lcom/pasich/encly/core/security/AuthenticationManager;[CZILjava/lang/Object;)Z 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 @@ -470,9 +458,7 @@ public final class com/pasich/encly/core/security/AuthenticationManager$MetroFac } public final class com/pasich/encly/core/security/AuthenticationManagerKt { - public static final field MAX_PASSWORD_LENGTH I public static final field MAX_PIN_LENGTH I - public static final field MIN_PASSWORD_LENGTH I public static final field MIN_PIN_LENGTH I } @@ -699,15 +685,6 @@ public final class com/pasich/encly/core/security/NeverLocked : com/pasich/encly public fun registerPreLockFlush (Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; } -public final class com/pasich/encly/core/security/PasswordChange : java/lang/Enum { - public static final field FAILED Lcom/pasich/encly/core/security/PasswordChange; - public static final field INVALID Lcom/pasich/encly/core/security/PasswordChange; - public static final field SET Lcom/pasich/encly/core/security/PasswordChange; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/pasich/encly/core/security/PasswordChange; - public static fun values ()[Lcom/pasich/encly/core/security/PasswordChange; -} - 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; @@ -843,7 +820,6 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun biometricStatus ()Lcom/pasich/encly/core/security/BiometricStatus; public final fun canReauthWithBiometric ()Z public final fun canResetPinWithoutCurrent ()Z - public final fun changePassword ([C)Lcom/pasich/encly/core/security/PasswordChange; public final fun changePin ([C)Lcom/pasich/encly/core/security/PinChange; public final fun commitInitialSetup ()Z public final fun completePendingWipe ()V @@ -877,21 +853,16 @@ public final class com/pasich/encly/core/security/SecurityManager { public final fun pinLockoutRemainingMillis ()J public final fun recoveryStage ()Lcom/pasich/encly/core/security/RecoveryStage; public final fun removeDuressPin ()Z - public final fun removePassword ()Z - public final fun removePin ()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 unlockMethods ()Lcom/pasich/encly/core/security/UnlockMethods; - public final fun unlockWithPassword ([C)Lcom/pasich/encly/core/security/VaultUnlockResult; 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 verifyPassword ([C)Z public final fun verifyPin ([C)Z public final fun verifySeed ([C)Z public final fun wipeAndReset ()Z @@ -960,6 +931,7 @@ public final class com/pasich/encly/core/security/SessionLockManager : androidx/ 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 @@ -993,19 +965,6 @@ public final class com/pasich/encly/core/security/SessionLockManagerKt { public static final field LOCK_FLUSH_TIMEOUT_MS J } -public final class com/pasich/encly/core/security/UnlockMethods { - public fun (ZZ)V - public final fun component1 ()Z - public final fun component2 ()Z - public final fun copy (ZZ)Lcom/pasich/encly/core/security/UnlockMethods; - public static synthetic fun copy$default (Lcom/pasich/encly/core/security/UnlockMethods;ZZILjava/lang/Object;)Lcom/pasich/encly/core/security/UnlockMethods; - public fun equals (Ljava/lang/Object;)Z - public final fun getPassword ()Z - public final fun getPin ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - 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 diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index d3239b41..b28ceb29 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -140,18 +140,6 @@ final enum class com.pasich.encly.core.security/InitialStatus : kotlin/Enum // com.pasich.encly.core.security/InitialStatus.values|values#static(){}[0] } -final enum class com.pasich.encly.core.security/PasswordChange : kotlin/Enum { // com.pasich.encly.core.security/PasswordChange|null[0] - enum entry FAILED // com.pasich.encly.core.security/PasswordChange.FAILED|null[0] - enum entry INVALID // com.pasich.encly.core.security/PasswordChange.INVALID|null[0] - enum entry SET // com.pasich.encly.core.security/PasswordChange.SET|null[0] - - final val entries // com.pasich.encly.core.security/PasswordChange.entries|#static{}entries[0] - final fun (): kotlin.enums/EnumEntries // com.pasich.encly.core.security/PasswordChange.entries.|#static(){}[0] - - final fun valueOf(kotlin/String): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/PasswordChange.valueOf|valueOf#static(kotlin.String){}[0] - final fun values(): kotlin/Array // com.pasich.encly.core.security/PasswordChange.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] @@ -691,7 +679,7 @@ final class com.pasich.encly.core.backup/ParsedBackup { // com.pasich.encly.core } 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 = ..., 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;kotlin.Boolean;kotlin.Boolean){}[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] @@ -699,16 +687,12 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 hasPassword // com.pasich.encly.core.security/AuthSettings.hasPassword|{}hasPassword[0] - final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.hasPassword.|(){}[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 passwordTurnedOff // com.pasich.encly.core.security/AuthSettings.passwordTurnedOff|{}passwordTurnedOff[0] - final fun (): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.passwordTurnedOff.|(){}[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] @@ -719,9 +703,7 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 component8(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component8|component8(){}[0] - final fun component9(): kotlin/Boolean // com.pasich.encly.core.security/AuthSettings.component9|component9(){}[0] - final fun copy(com.pasich.encly.core.security/AuthType = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., 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;kotlin.Boolean;kotlin.Boolean){}[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] @@ -730,7 +712,6 @@ final class com.pasich.encly.core.security/AuthSettings { // com.pasich.encly.co 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 changePassword(kotlin/CharArray, kotlin/ByteArray): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/AuthenticationManager.changePassword|changePassword(kotlin.CharArray;kotlin.ByteArray){}[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] @@ -743,23 +724,17 @@ final class com.pasich.encly.core.security/AuthenticationManager { // com.pasich 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 hasPasswordSlot(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.hasPasswordSlot|hasPasswordSlot(){}[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 passwordTurnedOff(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.passwordTurnedOff|passwordTurnedOff(){}[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 removePassword(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removePassword|removePassword(){}[0] - final fun removePin(kotlin/ByteArray): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removePin|removePin(kotlin.ByteArray){}[0] final fun removeWipePin(): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.removeWipePin|removeWipePin(){}[0] - final fun unlockWithPassword(kotlin/CharArray): com.pasich.encly.core.security/PinUnlock // com.pasich.encly.core.security/AuthenticationManager.unlockWithPassword|unlockWithPassword(kotlin.CharArray){}[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 verifyPasswordAuth(kotlin/CharArray, kotlin/Boolean = ...): kotlin/Boolean // com.pasich.encly.core.security/AuthenticationManager.verifyPasswordAuth|verifyPasswordAuth(kotlin.CharArray;kotlin.Boolean){}[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] @@ -882,7 +857,6 @@ final class com.pasich.encly.core.security/SecurityManager { // com.pasich.encly 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 changePassword(kotlin/CharArray): com.pasich.encly.core.security/PasswordChange // com.pasich.encly.core.security/SecurityManager.changePassword|changePassword(kotlin.CharArray){}[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] @@ -915,19 +889,14 @@ final class com.pasich.encly.core.security/SecurityManager { // com.pasich.encly 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 removePassword(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removePassword|removePassword(){}[0] - final fun removePin(): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.removePin|removePin(){}[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 unlockMethods(): com.pasich.encly.core.security/UnlockMethods // com.pasich.encly.core.security/SecurityManager.unlockMethods|unlockMethods(){}[0] - final fun unlockWithPassword(kotlin/CharArray): com.pasich.encly.core.security/VaultUnlockResult // com.pasich.encly.core.security/SecurityManager.unlockWithPassword|unlockWithPassword(kotlin.CharArray){}[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 verifyPassword(kotlin/CharArray): kotlin/Boolean // com.pasich.encly.core.security/SecurityManager.verifyPassword|verifyPassword(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] @@ -990,6 +959,7 @@ final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecyc 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] @@ -1010,22 +980,6 @@ final class com.pasich.encly.core.security/SessionLockManager : androidx.lifecyc } } -final class com.pasich.encly.core.security/UnlockMethods { // com.pasich.encly.core.security/UnlockMethods|null[0] - constructor (kotlin/Boolean, kotlin/Boolean) // com.pasich.encly.core.security/UnlockMethods.|(kotlin.Boolean;kotlin.Boolean){}[0] - - final val password // com.pasich.encly.core.security/UnlockMethods.password|{}password[0] - final fun (): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.password.|(){}[0] - final val pin // com.pasich.encly.core.security/UnlockMethods.pin|{}pin[0] - final fun (): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.pin.|(){}[0] - - final fun component1(): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.component1|component1(){}[0] - final fun component2(): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.component2|component2(){}[0] - final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ...): com.pasich.encly.core.security/UnlockMethods // com.pasich.encly.core.security/UnlockMethods.copy|copy(kotlin.Boolean;kotlin.Boolean){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.core.security/UnlockMethods.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // com.pasich.encly.core.security/UnlockMethods.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // com.pasich.encly.core.security/UnlockMethods.toString|toString(){}[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] @@ -1222,11 +1176,7 @@ final object com.pasich.encly.core.security/RecoveryWords { // com.pasich.encly. 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_PASSWORD_LENGTH // com.pasich.encly.core.security/MAX_PASSWORD_LENGTH|{}MAX_PASSWORD_LENGTH[0] - final fun (): kotlin/Int // com.pasich.encly.core.security/MAX_PASSWORD_LENGTH.|(){}[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_PASSWORD_LENGTH // com.pasich.encly.core.security/MIN_PASSWORD_LENGTH|{}MIN_PASSWORD_LENGTH[0] - final fun (): kotlin/Int // com.pasich.encly.core.security/MIN_PASSWORD_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/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt index 83aec7a0..91e788c6 100644 --- a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt +++ b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/AuthenticationManager.kt @@ -30,12 +30,6 @@ public const val MIN_PIN_LENGTH: Int = 6 /** The longest PIN. */ public const val MAX_PIN_LENGTH: Int = 12 -/** The fewest characters a password has (Unicode code points, after NFC normalisation). */ -public const val MIN_PASSWORD_LENGTH: Int = Passwords.MIN_LENGTH - -/** The most characters a password has. */ -public const val MAX_PASSWORD_LENGTH: Int = Passwords.MAX_LENGTH - public enum class AuthType { NONE, PIN, @@ -97,23 +91,14 @@ public enum class WipePinChange { FAILED, } -/** The outcome of setting the vault's password from Settings. */ -public enum class PasswordChange { - SET, - - /** Shorter than [MIN_PASSWORD_LENGTH] or longer than [MAX_PASSWORD_LENGTH] characters. */ - INVALID, - FAILED, -} - /** * What a recovery-phrase unlock asks of the user next (see [AuthenticationManager.recoveryFollowUp]). */ public enum class RecoveryFollowUp { - /** The PIN (or password) still opens the vault: the user may keep it or set a new PIN. */ + /** The PIN still opens the vault: the user may keep it or set a new PIN. */ KEEP_OR_CHANGE, - /** No PIN or password slot is left that can be used (missing, lost key, locked out): a new PIN is set now. */ + /** No PIN slot is left that can be used (missing, lost key, locked out): a new PIN is set now. */ MUST_SET_PIN, } @@ -175,22 +160,12 @@ public enum class WipeStage { * same length, so the store has the same keys and sizes either way and only the wipe PIN itself * can tell. Every attempt runs the KDF once and tries both slots, with no early exit. * - * A password (`pin.password.slot`) is a second way to the same DEK, for a user who prefers one to a - * PIN (or has no PIN). It is the same chain over its own salt and domain, with the same hardware key: - * - * hw = HMAC-SHA256_keystore("encly/pw/hw/v1" ‖ salt ‖ utf8(password)) - * sw = PBKDF2-HMAC-SHA256(password, salt, 600 000) - * kek = HKDF-SHA256(ikm = sw ‖ hw, salt = salt, info = "encly/pw/kek/v1") - * - * It has no wipe or duress slot (those stay PIN-only) and shares the PIN's lockout counters: a guess - * at either spends the same attempts. - * * Failed attempts are counted durably *before* the KDF runs, under one lock, so killing the * 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]. */ @SingleIn(AppScope::class) -@Suppress("TooManyFunctions", "LargeClass") // PIN, password, wipe, duress and lockout in one place. +@Suppress("TooManyFunctions", "LargeClass") // The complete PIN slot, wipe slot and lockout lifecycle in one place. @Inject public class AuthenticationManager( private val store: VaultStore, @@ -254,9 +229,7 @@ public class AuthenticationManager( keyReset || keptSalt == null -> WipeSlotChange.TURN_OFF else -> WipeSlotChange.KEEP } - // A password is sealed with the same device key: a key made anew cannot open it either. - val dropPassword = keyReset && hasPasswordSlot() - val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange, dropPassword)) + val written = writePinSlot(dek, keys.kek, salt, keySlot, SlotChanges(change, decoyChange)) if (written) PinChange.SET else PinChange.FAILED } catch (_: CryptoException) { PinChange.FAILED @@ -309,163 +282,9 @@ public class AuthenticationManager( } } - /** - * One password attempt from the lock screen. [password] stays the caller's to wipe. Counted - * and locked out exactly like a PIN attempt (the same counters), and answers with the same - * results: [PinUnlock.Success] (the caller owns and wipes the DEK), [PinUnlock.WrongPin] for a wrong - * password, [PinUnlock.LockedOut], [PinUnlock.KeyLost] and [PinUnlock.Failed]. Never a decoy or an erase: - * the duress and wipe PINs are PIN-only. - */ - public fun unlockWithPassword(password: CharArray): PinUnlock = attemptPassword(password, Purpose.UNLOCK) - - /** - * Whether [password] is the password of the open session's vault; counts like an unlock attempt. - * [password] stays the caller's. In a decoy session ([decoy]) it is never checked against the real - * vault's slot, so that session can not be used to test guesses: the attempt is counted and refused. - */ - public fun verifyPasswordAuth(password: CharArray, decoy: Boolean = false): Boolean { - val result = attemptPassword(password, if (decoy) Purpose.VERIFY_DECOY else Purpose.VERIFY) - if (result is PinUnlock.Success) SensitiveDataCleaner.clear(result.dek) - return result is PinUnlock.Success - } - - /** One counted password attempt; see [unlockWithPassword] and [verifyPasswordAuth]. */ - @Suppress("ReturnCount") // Fail-closed gates: lockout, missing slot, unrecorded attempt, unusable text. - private fun attemptPassword(password: CharArray, purpose: Purpose): PinUnlock = synchronized(attemptLock) { - if (remainingLockoutMillis() > 0) return PinUnlock.LockedOut - val slot = store.getBytes(PASSWORD_SLOT_KEY) ?: return PinUnlock.WrongPin - try { - val previous = Misses(store.getInt(FAILURES_KEY, 0), store.getInt(STRIKES_KEY, 0)) - // Counted before the KDF, like a PIN attempt. - if (!chargeAttempt(previous.next())) return PinUnlock.Failed - if (purpose == Purpose.VERIFY_DECOY) return PinUnlock.WrongPin - val normalized = Passwords.normalize(password) - try { - if (!Passwords.isPlausible(normalized)) return PinUnlock.WrongPin - val parsed = PasswordSlots.parseSlot(slot) ?: return PinUnlock.WrongPin - try { - openPasswordSlot(normalized, parsed, previous) - } finally { - parsed.wipe() - } - } finally { - SensitiveDataCleaner.clear(normalized) - } - } finally { - SensitiveDataCleaner.clear(slot) - } - } - - @Suppress("ReturnCount") // Fail-closed: a key that never answered, a wrong password, a right one. - private fun openPasswordSlot(normalized: CharArray, parsed: ParsedSlot, previous: Misses): PinUnlock { - val kek = try { - derivePasswordKek(normalized, parsed.salt, activeKeySlot()) - } catch (e: PinFactorException) { - // Not a guess: the hardware never answered. Give the attempt back. - refundAttempt(previous) - return if (e.lost) PinUnlock.KeyLost else PinUnlock.Failed - } - return try { - val dek = PasswordSlots.openSlotOrNull(parsed, kek) ?: return PinUnlock.WrongPin - // The right password ends the lockout and clears the misses, as the right PIN does. - store.edit { - removePrefix(LOCKOUT_PREFIX) - remove(STRIKES_KEY) - } - PinUnlock.Success(dek) - } finally { - SensitiveDataCleaner.clear(kek) - } - } - - /** - * Creates (or replaces) the password slot around [dek]; [password] stays the caller's to wipe. - * Refused with [PasswordChange.INVALID] when it is not [MIN_PASSWORD_LENGTH]..[MAX_PASSWORD_LENGTH] - * characters. A new salt every time, the same device key as the PIN. When that key had to be made anew - * (it was lost) a PIN slot sealed under the old one can never open again: it is dropped in the same write, - * with the wipe and duress PINs that hung on it. - */ - @Suppress("ReturnCount", "CyclomaticComplexMethod") // Validation gates fail closed before any key is touched. - public fun changePassword(password: CharArray, dek: ByteArray): PasswordChange = synchronized(attemptLock) { - if (dek.size != DEK_LENGTH) return PasswordChange.FAILED - val normalized = Passwords.normalize(password) - val salt = Crypto.randomBytes(PasswordSlots.SALT_SIZE) - var keyReset = false - var kek: ByteArray? = null - try { - if (!Passwords.isAcceptable(normalized)) return PasswordChange.INVALID - val keySlot = activeKeySlot() - kek = try { - keyReset = factor.ensureKey(keySlot) - try { - derivePasswordKek(normalized, salt, keySlot) - } catch (e: PinFactorException) { - // A key the system invalidated can be replaced: what it served is lost anyway. - if (!e.lost) throw e - factor.reset(keySlot) - keyReset = true - derivePasswordKek(normalized, salt, keySlot) - } - } catch (_: PinFactorException) { - return PasswordChange.FAILED - } - val slot = PasswordSlots.sealSlot(dek, kek, salt) - val retired = if (keyReset && hasPinSlot()) retirePin(dek) else null - val written = try { - store.edit { - putBytes(PASSWORD_SLOT_KEY, slot) - remove(PASSWORD_NOTICE_KEY) - retired?.applyTo(this) - removePrefix(LOCKOUT_PREFIX) - remove(STRIKES_KEY) - } - } finally { - SensitiveDataCleaner.clear(slot) - retired?.wipe() - } - return if (written) PasswordChange.SET else PasswordChange.FAILED - } catch (_: CryptoException) { - return PasswordChange.FAILED - } finally { - SensitiveDataCleaner.clear(normalized) - SensitiveDataCleaner.clear(salt) - kek?.let(SensitiveDataCleaner::clear) - } - } - - /** - * Turns the password off. The last way to unlock stays: refused (false) while there is no PIN slot. - * The caller has checked who is asking (the vault is open and the PIN was just confirmed). - */ - public fun removePassword(): Boolean = synchronized(attemptLock) { - if (!hasPinSlot()) return false - store.edit { - remove(PASSWORD_SLOT_KEY) - remove(PASSWORD_NOTICE_KEY) - } - } - - /** - * Turns the PIN off, leaving the password as the way in: the PIN slot goes, and the wipe slot and the - * duress slot, which only the PIN can open, become random bytes again (the decoy vault keeps its notes and its - * duress PIN can be set again, as after a PIN key reset). Refused (false) while there is no password: - * the last way to unlock stays. [dek] is the open session's own key. - */ - @Suppress("ReturnCount") - public fun removePin(dek: ByteArray): Boolean = synchronized(attemptLock) { - if (dek.size != DEK_LENGTH || !hasPasswordSlot()) return false - if (!hasPinSlot()) return true - val retired = retirePin(dek) - try { - store.edit { retired.applyTo(this) } - } finally { - retired.wipe() - } - } - /** What a recovery-phrase unlock asks of the user next; see [RecoveryFollowUp]. */ public fun recoveryFollowUp(): RecoveryFollowUp = synchronized(attemptLock) { - val usable = (hasPinSlot() || hasPasswordSlot()) && + val usable = hasPinSlot() && remainingLockoutMillis() <= 0L && !deviceKeyLost() if (usable) RecoveryFollowUp.KEEP_OR_CHANGE else RecoveryFollowUp.MUST_SET_PIN @@ -726,15 +545,6 @@ public class AuthenticationManager( public fun hasPinSlot(): Boolean = store.contains(PIN_SLOT_KEY) - /** A password slot exists (whether it can still open is only known by trying). */ - public fun hasPasswordSlot(): Boolean = store.contains(PASSWORD_SLOT_KEY) - - /** - * True after the device key had to be reset (see [changePin]): the password stopped working and was - * dropped. Settings says so until the password is set again or removed. - */ - public fun passwordTurnedOff(): Boolean = store.getBoolean(PASSWORD_NOTICE_KEY, false) - public fun getAuthType(): AuthType { val ordinal = store.getInt(AUTH_TYPE_KEY, AuthType.NONE.ordinal) return AuthType.entries.getOrNull(ordinal) ?: AuthType.NONE @@ -746,7 +556,7 @@ public class AuthenticationManager( AuthType.NONE -> AuthStrategy.NONE AuthType.PIN -> when { - !hasPinSlot() && !hasPasswordSlot() -> AuthStrategy.RECOVERY_DATA + !hasPinSlot() -> AuthStrategy.RECOVERY_DATA biometric -> AuthStrategy.PIN_BIOMETRIC else -> AuthStrategy.PIN } @@ -896,9 +706,9 @@ public class AuthenticationManager( putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(WIPE_PENDING_KEY, WipeStage.DATABASE.ordinal) remove(WIPE_NOTICE_KEY) - // The password opens the erased vault's DEK: it goes with the rest. - remove(PASSWORD_SLOT_KEY) - remove(PASSWORD_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) @@ -947,7 +757,7 @@ public class AuthenticationManager( keySlot: PinKeySlot, changes: SlotChanges, ): Boolean { - val (change, decoyChange, dropPassword) = changes + val (change, decoyChange) = changes val slot = sealSlot(dek, kek, salt) val decoy = if (change == WipeSlotChange.KEEP) null else decoyWipeSlot() val duressSlot = if (decoyChange == WipeSlotChange.KEEP) null else randomSlot() @@ -964,10 +774,9 @@ public class AuthenticationManager( putInt(KEY_SLOT_KEY, keySlot.ordinal) putInt(AUTH_TYPE_KEY, AuthType.PIN.ordinal) if (change == WipeSlotChange.TURN_OFF_AND_TELL) putBoolean(WIPE_NOTICE_KEY, true) - if (dropPassword) { - remove(PASSWORD_SLOT_KEY) - putBoolean(PASSWORD_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) } @@ -1093,19 +902,27 @@ public class AuthenticationManager( 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) } } @@ -1133,56 +950,6 @@ public class AuthenticationManager( private fun pbkdf2(pin: CharArray, salt: ByteArray): ByteArray = Crypto.pbkdf2HmacSha256(pin, salt, PBKDF2_ITERATIONS, DEK_LENGTH) - /** - * The KEK of the password slot. [PinFactorException] leaves nothing behind; the result is the caller's - * to wipe. [password] is already NFC-normalised. - */ - private fun derivePasswordKek(password: CharArray, salt: ByteArray, keySlot: PinKeySlot): ByteArray { - // The hardware half first, as for the PIN: a lost key fails before the PBKDF2 cost. - val utf8 = Utf8.encode(password) - val macInput = PasswordSlots.HARDWARE_TAG + salt + utf8 - val hardware = try { - factor.mac(keySlot, macInput) - } finally { - SensitiveDataCleaner.clear(utf8) - SensitiveDataCleaner.clear(macInput) - } - val stretched = pbkdf2(password, salt) - val ikm = stretched + hardware - return try { - Hkdf.sha256(ikm = ikm, salt = salt, info = PasswordSlots.KEK_INFO, length = DEK_LENGTH) - } finally { - SensitiveDataCleaner.clear(hardware) - SensitiveDataCleaner.clear(stretched) - SensitiveDataCleaner.clear(ikm) - } - } - - /** - * What drops the PIN slot: the slot itself, and the wipe slot and duress slot (only the PIN opens them) as - * random bytes again, with the decoy vault's key sealed as turned off so a new duress PIN opens the same notes. - * [dek] is the real vault's. - */ - private fun retirePin(dek: ByteArray): RetiredPin = - RetiredPin(wipeSlot = decoyWipeSlot(), duressSlot = randomSlot(), decoyKey = turnedOffDecoyKey(dek)) - - /** The entries a dropped PIN slot is replaced with; [applyTo] writes them into one store edit. */ - private class RetiredPin(val wipeSlot: ByteArray, val duressSlot: ByteArray, val decoyKey: ByteArray) { - fun applyTo(editor: VaultStore.Editor) { - editor.remove(PIN_SLOT_KEY) - editor.putBytes(WIPE_SLOT_KEY, wipeSlot) - editor.putBytes(DECOY_SLOT_KEY, duressSlot) - editor.putBytes(DECOY_KEY_KEY, decoyKey) - editor.remove(WIPE_NOTICE_KEY) - } - - fun wipe() { - SensitiveDataCleaner.clear(wipeSlot) - SensitiveDataCleaner.clear(duressSlot) - SensitiveDataCleaner.clear(decoyKey) - } - } - /** Whether [decoyKek] opens the duress slot; the DEK it holds is wiped at once. */ private fun opensDecoySlot(decoyKek: ByteArray, salt: ByteArray): Boolean { val dek = openDecoySlotOrNull(decoyKek, salt) ?: return false @@ -1285,12 +1052,7 @@ public class AuthenticationManager( private enum class WipeSlotChange { KEEP, TURN_OFF, TURN_OFF_AND_TELL } /** What a new PIN slot does to the wipe slot and to the duress slot. */ - private data class SlotChanges( - val wipe: WipeSlotChange, - val decoy: WipeSlotChange, - /** The password slot is dropped: the device key it was sealed with is gone. */ - val dropPassword: Boolean = false, - ) + private data class SlotChanges(val wipe: WipeSlotChange, val decoy: WipeSlotChange) public companion object { private const val PIN_PREFIX = "pin." @@ -1299,13 +1061,11 @@ public class AuthenticationManager( private const val WIPE_NOTICE_KEY = "pin.wipe.notice" /** - * The password slot. Under `pin.` on purpose: an older build that wipes the PIN state drops it too, so no - * slot is left behind for a vault it re-created. + * 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 PASSWORD_SLOT_KEY = "pin.password.slot" - - /** Set when the device key was reset and the password with it; cleared by setting or removing the password. */ - private const val PASSWORD_NOTICE_KEY = "pin.password.notice" + 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" diff --git a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt b/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt deleted file mode 100644 index ed2cf575..00000000 --- a/core/security/src/commonMain/kotlin/com/pasich/encly/core/security/PasswordSlots.kt +++ /dev/null @@ -1,90 +0,0 @@ -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.ParsedSlot - -/** - * The byte format of the password slot (`pin.password.slot`): `version ‖ salt ‖ iv ‖ AES-GCM(DEK)`, - * the same shape and size as the PIN slot, with a version, an AAD and a KDF domain of its own so - * that no key or ciphertext of the PIN is ever valid here. The KDF chain is the PIN's (PBKDF2 600 000, - * the device-bound HMAC, HKDF); see [AuthenticationManager]. Pure functions. - * - * An older build never reads this entry: it does not know the key, so it ignores it (and drops it - * with every other `pin.` entry when it wipes the PIN state). - */ -internal object PasswordSlots { - const val SALT_SIZE = 16 - const val SLOT_VERSION: Byte = 1 - private const val SLOT_LENGTH = 1 + SALT_SIZE + IV_LENGTH + DEK_LENGTH + GCM_TAG_BYTES - - private val SLOT_AAD = "encly/pw/slot/v1".encodeToByteArray() - - /** HKDF info of the password KEK. */ - val KEK_INFO = "encly/pw/kek/v1".encodeToByteArray() - - /** Prefix of the device-bound HMAC input (`tag ‖ salt ‖ UTF-8 password`): the PIN's input has none. */ - val HARDWARE_TAG = "encly/pw/hw/v1".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 + SALT_SIZE) - offset += 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 password). 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 = SLOT_AAD + byteArrayOf(SLOT_VERSION) + salt -} - -/** - * The rules for a password: Unicode, NFC-normalised (so the same word typed on another keyboard or - * platform is the same bytes), at least [MIN_LENGTH] and at most [MAX_LENGTH] characters (code points). - */ -internal object Passwords { - const val MIN_LENGTH = 8 - const val MAX_LENGTH = 128 - - /** Every character below this is its own NFC form and cannot combine with the one before it. */ - private const val FIRST_COMBINING = 0x300 - - /** - * [password] in NFC, in a fresh array; the caller wipes both. Plain Latin text needs no - * normalising and no String is made; other text is normalised by the platform, which cannot - * promise to wipe what it made on the way. - */ - fun normalize(password: CharArray): CharArray = - if (password.all { it.code < FIRST_COMBINING }) password.copyOf() else normalizeNfc(password) - - /** Whether [normalized] has an acceptable number of characters to be set as the password. */ - fun isAcceptable(normalized: CharArray): Boolean = codePoints(normalized) in MIN_LENGTH..MAX_LENGTH - - /** Whether [normalized] is worth a key derivation when unlocking: not empty, not absurdly long. */ - fun isPlausible(normalized: CharArray): Boolean = normalized.isNotEmpty() && normalized.size <= MAX_LENGTH * 2 - - /** Characters as Unicode code points: a surrogate pair is one. */ - fun codePoints(chars: CharArray): Int = chars.count { !it.isLowSurrogate() } -} - -/** [text] in Unicode normalization form C. */ -internal expect fun normalizeNfc(text: CharArray): CharArray 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 index cbbfbd3d..74ee4759 100644 --- 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 @@ -155,7 +155,7 @@ public class SecurityManager( // 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 (!hasUnlockSlot() && !seedPhraseManager.hasRecoverySeed()) { + if (!authenticationManager.hasPinSlot() && !seedPhraseManager.hasRecoverySeed()) { return InitialStatus.LOSS_CRYPTO } return InitialStatus.AUTH @@ -185,13 +185,10 @@ public class SecurityManager( */ private fun isCommittedWithoutFlag(): Boolean = !seedPhraseManager.hasBootstrapKey() && seedPhraseManager.hasStoredSeed() && - (hasUnlockSlot() || seedPhraseManager.hasRecoverySeed()) && + (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) - /** A PIN or a password slot exists: something other than the recovery phrase can open the vault. */ - private fun hasUnlockSlot(): Boolean = authenticationManager.hasPinSlot() || authenticationManager.hasPasswordSlot() - /** True when first-run setup may (re)create the vault without destroying user data. */ private fun isSafeToCreateVault(): Boolean = !vaultStore.isUnreadable() && !isOnboardingShown() && @@ -254,8 +251,8 @@ public class SecurityManager( /** * What the session unlocked with the recovery phrase still has to decide: [RecoveryStage.MUST_SET_PIN] - * when no PIN or password can be used any more (missing, lost key, locked out), [RecoveryStage.CHOOSE] when - * they can (keep them or set a new PIN), [RecoveryStage.NONE] otherwise. + * 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 @@ -327,7 +324,7 @@ public class SecurityManager( SensitiveDataCleaner.clear(dek) } if (opened) { - // The unlock itself did not touch the PIN, the password or the lockout: whether they still serve + // 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 @@ -337,91 +334,6 @@ public class SecurityManager( return if (opened) VaultUnlockResult.SUCCESS else VaultUnlockResult.DB_ERROR } - /** - * One password attempt from the lock screen; [password] is wiped. Counted and locked out together with the - * PIN, and answered like it ([VaultUnlockResult.PIN_KEY_LOST]: the device-bound key is gone, which takes the - * password with it). There is no wipe or duress password: those stay PIN-only. - */ - public fun unlockWithPassword(password: CharArray): VaultUnlockResult { - val attempt = try { - authenticationManager.unlockWithPassword(password) - } finally { - SensitiveDataCleaner.clear(password) - } - return when (attempt) { - is PinUnlock.Success -> try { - if (unlockWithRawKey(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 - - // The password slot never yields these; refused all the same. - is PinUnlock.Erased -> { - SensitiveDataCleaner.clear(attempt.dek) - VaultUnlockResult.DB_ERROR - } - - is PinUnlock.Decoy -> { - SensitiveDataCleaner.clear(attempt.dek) - VaultUnlockResult.DB_ERROR - } - - PinUnlock.Failed -> VaultUnlockResult.DB_ERROR - } - } - - /** - * Re-authentication with the password of the open vault; counts like an unlock attempt. [password] is - * wiped. Never true in a decoy session. - */ - public fun verifyPassword(password: CharArray): Boolean = try { - authenticationManager.verifyPasswordAuth(password, decoy = isDecoySession()) - } finally { - SensitiveDataCleaner.clear(password) - } - - /** - * Sets (or replaces) the password around the live DEK; [password] is wiped. Never in a decoy session: the - * slot holds the real vault's DEK. The caller checked who is asking. - */ - public fun changePassword(password: CharArray): PasswordChange { - val dek = primarySessionKeyCopy() - return try { - if (dek == null) PasswordChange.FAILED else authenticationManager.changePassword(password, dek) - } finally { - dek?.let(SensitiveDataCleaner::clear) - SensitiveDataCleaner.clear(password) - } - } - - /** Turns the password off; false while it is the only way in. Only in a session of the real vault. */ - public fun removePassword(): Boolean = primarySessionOpen() && authenticationManager.removePassword() - - /** - * Turns the PIN off, which also turns off the wipe PIN and the duress PIN; false while it is the only way in. - * Only in a session of the real vault. - */ - public fun removePin(): Boolean { - val dek = primarySessionKeyCopy() ?: return false - return try { - authenticationManager.removePin(dek) - } finally { - SensitiveDataCleaner.clear(dek) - } - } - - /** Which of the PIN and the password the lock screen and re-authentication can ask for. */ - public fun unlockMethods(): UnlockMethods = UnlockMethods( - pin = authenticationManager.hasPinSlot(), - password = authenticationManager.hasPasswordSlot(), - ) - public fun requestBiometricKey(host: BiometricHost, onResult: (ByteArray?) -> Unit) { biometricManager.unlock(host, onResult) } @@ -743,7 +655,7 @@ public class SecurityManager( * background (SessionLockManager.onUnlocked) or fails. */ public fun isLockable(): Boolean = (isOnboardingShown() || isCommittedWithoutFlag()) && - (hasUnlockSlot() || seedPhraseManager.hasRecoverySeed()) + (authenticationManager.hasPinSlot() || seedPhraseManager.hasRecoverySeed()) public fun isDatabaseUnlocked(): Boolean = vaultDatabase.isDatabaseUnlocked() @@ -835,8 +747,6 @@ public class SecurityManager( isBiometricEnabled = isBiometricEnabled(), isUserCreatedSeedKey = seedPhraseManager.hasRecoverySeed(), hasPinSlot = authenticationManager.hasPinSlot(), - hasPassword = authenticationManager.hasPasswordSlot(), - passwordTurnedOff = authenticationManager.passwordTurnedOff(), wipePinTurnedOff = authenticationManager.wipePinTurnedOff(), hasDuressPin = hasDuressPin(), duressPinTurnedOff = duressPinTurnedOff(), @@ -852,10 +762,6 @@ public data class AuthSettings( val isBiometricEnabled: Boolean, val isUserCreatedSeedKey: Boolean, val hasPinSlot: Boolean = authType == AuthType.PIN, - /** A password is set as another way in (or the only one). */ - val hasPassword: Boolean = false, - /** The device key was reset and the password with it; setting it again brings it back. */ - val passwordTurnedOff: Boolean = false, val wipePinTurnedOff: Boolean = false, /** The real vault has a duress PIN (known only in its own session). */ val hasDuressPin: Boolean = false, @@ -868,12 +774,9 @@ public enum class RecoveryStage { /** Nothing: the session was not unlocked with the phrase, or what it owed is settled. */ NONE, - /** The PIN (or password) still serves: keep it, or set a new PIN. */ + /** The PIN still serves: keep it, or set a new PIN. */ CHOOSE, - /** No PIN or password can be used (missing, lost key, locked out): a new PIN is set before going on. */ + /** The PIN can not be used (missing, lost key, locked out): a new PIN is set before going on. */ MUST_SET_PIN, } - -/** Which of the two ways to unlock with a secret the vault has set. */ -public data class UnlockMethods(val pin: Boolean, val password: Boolean) 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 index 31ce0860..be5fe31d 100644 --- 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 @@ -236,6 +236,12 @@ public class SessionLockManager( */ 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 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 index bf335835..3d285b06 100644 --- 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 @@ -419,84 +419,6 @@ class CryptoGoldenTest { assertEquals(PinUnlock.WrongPin, wrong) } - // --- Password slot --------------------------------------------------------------------- - - @Test - fun theGoldenPasswordSlotOpensWithItsKek() { - val parsed = PasswordSlots.parseSlot(GoldenVectors.passwordSlot)!! - - assertContentEquals(salt, parsed.salt) - assertContentEquals(dek, PasswordSlots.openSlotOrNull(parsed, kek)) - assertNull(PasswordSlots.openSlotOrNull(parsed, wipeKek)) - } - - @Test - fun aSealedPasswordSlotHasTheDocumentedLayoutAndOpensWithAnIndependentImplementation() { - val slot = PasswordSlots.sealSlot(dek, kek, salt) - - assertEquals(1 + 16 + 12 + 32 + 16, slot.size) - assertEquals(1, slot[0].toInt()) - assertContentEquals(salt, slot.copyOfRange(1, 17)) - val aad = "encly/pw/slot/v1".encodeToByteArray() + byteArrayOf(1) + salt - assertContentEquals(dek, Reference.gcmOpen(kek, slot.copyOfRange(17, 29), aad, slot.copyOfRange(29, slot.size))) - assertFalse(slot.contentEquals(PasswordSlots.sealSlot(dek, kek, salt))) - } - - @Test - fun thePasswordKekIsTheHkdfOfThePbkdf2AndDeviceHalves() { - val derived = Hkdf.sha256( - GoldenVectors.passwordSw + GoldenVectors.passwordHw, - salt, - "encly/pw/kek/v1".encodeToByteArray(), - 32, - ) - - assertContentEquals(GoldenVectors.passwordKek, derived) - val chain = PasswordSlots.parseSlot(GoldenVectors.chainPasswordSlot)!! - assertContentEquals(dek, PasswordSlots.openSlotOrNull(chain, derived)) - } - - @Test - fun aPinSlotAndAPasswordSlotAreNotInterchangeable() { - // Different version byte and size rules: neither parser takes the other's slot. - assertNull(PasswordSlots.parseSlot(GoldenVectors.pinSlot)) - assertNull(PinSlots.parseSlot(GoldenVectors.passwordSlot)) - // Even with the version byte changed the AAD keeps them apart. - val relabelled = GoldenVectors.pinSlot.copyOf().also { it[0] = 1 } - assertNull(PasswordSlots.openSlotOrNull(PasswordSlots.parseSlot(relabelled)!!, kek)) - val relabelledBack = GoldenVectors.passwordSlot.copyOf().also { it[0] = 3 } - assertNull(PinSlots.openSlotOrNull(PinSlots.parseSlot(relabelledBack)!!, kek)) - } - - @Test - fun theGoldenPasswordSlotsUnlockThroughTheFullKdfChain() { - val store = VaultStore(InMemoryVaultFileIo()) - store.edit { putBytes("pin.password.slot", GoldenVectors.chainPasswordSlot) } - val manager = AuthenticationManager(store, FixedKeyFactor(hardwareKey), FakeLockoutClock()) - - val composed = manager.unlockWithPassword(UNICODE_PASSWORD.toCharArray()) - // The same word typed with combining marks is the same password (NFC). - val decomposed = manager.unlockWithPassword(UNICODE_PASSWORD_DECOMPOSED.toCharArray()) - val wrong = manager.unlockWithPassword("P\u00e4ssw\u00f6rd".toCharArray()) - - assertContentEquals(dek, (composed as PinUnlock.Success).dek) - assertContentEquals(dek, (decomposed as PinUnlock.Success).dek) - assertEquals(PinUnlock.WrongPin, wrong) - } - - @Test - fun theGoldenAsciiPasswordSlotUnlocksThroughTheFullKdfChain() { - val store = VaultStore(InMemoryVaultFileIo()) - store.edit { putBytes("pin.password.slot", GoldenVectors.chainAsciiPasswordSlot) } - val manager = AuthenticationManager(store, FixedKeyFactor(hardwareKey), FakeLockoutClock()) - - val opened = manager.unlockWithPassword("correct horse battery".toCharArray()) - val wrong = manager.unlockWithPassword("correct horse batterz".toCharArray()) - - assertContentEquals(dek, (opened as PinUnlock.Success).dek) - assertEquals(PinUnlock.WrongPin, wrong) - } - private class FixedKeyFactor(private val key: ByteArray) : PinHardwareFactor { override fun ensureKey(slot: PinKeySlot) = false @@ -552,9 +474,5 @@ class CryptoGoldenTest { 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" - - // "Pässwörd-日本語-🔑" as typed by a keyboard that composes, and as one that sends combining marks. - const val UNICODE_PASSWORD = "P\u00e4ssw\u00f6rd-\u65e5\u672c\u8a9e-\uD83D\uDD11" - const val UNICODE_PASSWORD_DECOMPOSED = "Pa\u0308sswo\u0308rd-\u65e5\u672c\u8a9e-\uD83D\uDD11" } } 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 index 7fa76661..6e2b18a5 100644 --- 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 @@ -118,30 +118,6 @@ internal object GoldenVectors { "fddb368becdab848194f27ec96", ) - // The password slot (see PasswordSlots): fixed dek, kek, salt and IV as for the PIN slots above, computed with - // an AES-GCM, PBKDF2 and HMAC implementation independent of the code under test (Python hashlib and - // cryptography). - val passwordSlot = hex( - "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b58a9c03c53e5da37e57c6d6bd7b79ab45c8f05" + - "ff14a64ca347f3ca7ad54a0125c34d650c3e67ff1019c1abfb5f0f8f7b", - ) - - val passwordSw = hex("beb69746d4c0f5df40e06d7c6fdb64dfc27229d97f99808b5817a528cbf8034a") - - val passwordHw = hex("d3599244ef93353258585089592976a8f93f022b3cdf5e1c26900fcbf1b05ee1") - - val passwordKek = hex("3926a89e5f1e1cd24745f2365ba2dd04fc30b9aa3d433b3c7e4e7fa3f868d68d") - - val chainPasswordSlot = hex( - "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b306ca670770005af0f1c30434a1ac2e0ecacee" + - "6b45033093ff27067dd954beee55ae71e3431a4f21fccb71deec798d5b", - ) - - val chainAsciiPasswordSlot = hex( - "01000306090c0f1215181b1e2124272a2d202122232425262728292a2b3baaef977c1b97622046cd2a9c37769b46b8c5" + - "55b746be4f57beb35043b440e20b7ba0d974f2ff95605c304986d5aa9b", - ) - val storeFile = hex( "454e434c595653330000000200016100000003010203000262630000000058f24fb12b5c0a411161cd12ebdb9fdbf492" + "19ef0aee2e78afa6e5562e370519", 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 index 219779fb..9e2a3340 100644 --- 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 @@ -11,8 +11,8 @@ import kotlin.test.assertTrue * 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 files in [JVM_FILES], which both JVM targets share and which are the only source files that may - * name `java.io` or `java.text`. The module may depend on `:core:common` and `:core:crypto` only. + * 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. @@ -59,7 +59,7 @@ class SecurityBoundaryTest { } @Test - fun onlyTheListedJvmFilesTouchTheJvm() { + 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() @@ -133,7 +133,6 @@ class SecurityBoundaryTest { val JVM_FILES = mapOf( "JvmVaultFileIo.kt" to "java.io and java.nio: the vault file's fsync and atomic replace", "JvmJsonBytes.kt" to "java.io byte-array streams so the plaintext payload can be wiped", - "JvmNormalizer.kt" to "java.text.Normalizer: NFC of a password has no common Kotlin equivalent", ) } } diff --git a/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt b/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt deleted file mode 100644 index 14e57c01..00000000 --- a/core/security/src/iosMain/kotlin/com/pasich/encly/core/security/IosNormalizer.kt +++ /dev/null @@ -1,15 +0,0 @@ -@file:OptIn(ExperimentalForeignApi::class) - -package com.pasich.encly.core.security - -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSString -import platform.Foundation.precomposedStringWithCanonicalMapping - -/* - * The iOS side of Passwords.normalize: Foundation's canonical composition. It works on a String, which cannot - * be wiped; Passwords.normalize only gets here for text outside plain Latin. - */ - -internal actual fun normalizeNfc(text: CharArray): CharArray = - (text.concatToString() as NSString).precomposedStringWithCanonicalMapping.toCharArray() diff --git a/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt b/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt deleted file mode 100644 index 8e86cef2..00000000 --- a/core/security/src/jvmAndAndroidMain/kotlin/com/pasich/encly/core/security/JvmNormalizer.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.pasich.encly.core.security - -import java.nio.CharBuffer -import java.text.Normalizer - -/* - * The JVM and Android side of Passwords.normalize. Text that is already in NFC is copied as it is, so - * only text that really changes is turned into a String by the normalizer. - */ - -internal actual fun normalizeNfc(text: CharArray): CharArray { - val view = CharBuffer.wrap(text) - return if (Normalizer.isNormalized(view, Normalizer.Form.NFC)) { - text.copyOf() - } else { - Normalizer.normalize(view, Normalizer.Form.NFC).toCharArray() - } -} diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt deleted file mode 100644 index a9d24c43..00000000 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/PasswordSlotTest.kt +++ /dev/null @@ -1,472 +0,0 @@ -package com.pasich.encly.core.security - -import com.pasich.encly.core.security.AuthenticationManager.Companion.MAX_ATTEMPTS -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.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.DataInputStream -import java.text.Normalizer - -/** - * The password slot: its own salt and KDF domain over the PIN's chain and device key, the same lockout - * counters, and the rule that one of PIN and password always remains. - */ -class PasswordSlotTest { - private val file = tempVaultFile() - private val store = VaultStore(file) - private val factor = FakePinFactor() - private val clock = FakeLockoutClock() - private val manager = AuthenticationManager(store, factor, clock) - private val dek = ByteArray(32) { (it + 3).toByte() } - - // --- setting a password ------------------------------------------------------------------ - - @Test - fun aPasswordUnlocksTheSameDekAsThePin() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - val byPassword = manager.unlockWithPassword(pw()) - val byPin = manager.unlockWithPin(pin()) - - assertArrayEquals(dek, (byPassword as PinUnlock.Success).dek) - assertArrayEquals(dek, (byPin as PinUnlock.Success).dek) - assertTrue(manager.hasPasswordSlot()) - } - - @Test - fun aVaultWithOnlyAPasswordUnlocksWithIt() { - // The vault is made around a PIN; the PIN is removed once the password stands next to it. - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - assertTrue(manager.removePin(dek)) - - assertFalse(manager.hasPinSlot()) - assertArrayEquals(dek, (manager.unlockWithPassword(pw()) as PinUnlock.Success).dek) - assertEquals(PinUnlock.WrongPin, manager.unlockWithPin(pin())) - assertEquals(AuthStrategy.PIN, manager.isAuthStrategy()) - } - - @Test - fun aWrongPasswordNeverUnwrapsTheDekAndIsCounted() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword("wrong password".toCharArray())) - - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw(""))) - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - @Test - fun aPasswordOfAnotherVaultOrWithoutASlotOpensNothing() { - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) - assertEquals(0L, manager.remainingLockoutMillis()) - } - - @Test - fun thePinDoesNotOpenThePasswordSlotAndTheOtherWayAround() { - // Eight to twelve digits are a valid password and a valid PIN alike: each opens only its own slot. - val digits = "12345678" - assertTrue(manager.configurePin(digits.toCharArray(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword("87654321".toCharArray(), dek)) - - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(digits.toCharArray())) - assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("87654321".toCharArray())) - assertTrue(manager.unlockWithPin(digits.toCharArray()) is PinUnlock.Success) - assertTrue(manager.unlockWithPassword("87654321".toCharArray()) is PinUnlock.Success) - } - - @Test - fun theSlotIsUselessWithoutTheDeviceFactor() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val otherDevice = - AuthenticationManager(VaultStore(file), FakePinFactor().apply { reset(PinKeySlot.A) }, FakeLockoutClock()) - - assertEquals(PinUnlock.WrongPin, otherDevice.unlockWithPassword(pw())) - } - - @Test - fun everyGuessRunsThroughTheDeviceFactorOnce() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val before = factor.calls - - repeat(3) { manager.unlockWithPassword("guess number $it".toCharArray()) } - - assertEquals(before + 3, factor.calls) - } - - @Test - fun aNewPasswordGetsANewSaltAndReplacesTheOldOne() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val first = store.getBytes("pin.password.slot")!! - - assertEquals(PasswordChange.SET, manager.changePassword("another password".toCharArray(), dek)) - val second = store.getBytes("pin.password.slot")!! - - assertEquals(first.size, second.size) - assertNotEquals(first.copyOfRange(1, 17).toList(), second.copyOfRange(1, 17).toList()) - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) - assertTrue(manager.unlockWithPassword("another password".toCharArray()) is PinUnlock.Success) - } - - @Test - fun theSlotIsAsLongAsAPinSlotAndSaysItsVersion() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - val slot = store.getBytes("pin.password.slot")!! - - assertEquals(store.getBytes("pin.slot")!!.size, slot.size) - assertEquals(1, slot[0].toInt()) - assertEquals(3, store.getBytes("pin.slot")!![0].toInt()) - } - - // --- what a password is ------------------------------------------------------------------- - - @Test - fun aPasswordNeedsEightToOneHundredTwentyEightCharacters() { - assertEquals(PasswordChange.INVALID, manager.changePassword("1234567".toCharArray(), dek)) - assertEquals(PasswordChange.INVALID, manager.changePassword("".toCharArray(), dek)) - assertEquals(PasswordChange.INVALID, manager.changePassword("x".repeat(129).toCharArray(), dek)) - assertFalse(manager.hasPasswordSlot()) - - assertEquals(PasswordChange.SET, manager.changePassword("x".repeat(8).toCharArray(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword("y".repeat(128).toCharArray(), dek)) - assertTrue(manager.unlockWithPassword("y".repeat(128).toCharArray()) is PinUnlock.Success) - } - - @Test - fun charactersAreCodePointsNotUtf16Units() { - // Four emoji are eight UTF-16 units but four characters: with four more letters they make eight. - val emoji = "🔑" - assertEquals(PasswordChange.INVALID, manager.changePassword((emoji + emoji + emoji + "abc").toCharArray(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword((emoji.repeat(4) + "abcd").toCharArray(), dek)) - assertTrue(manager.unlockWithPassword((emoji.repeat(4) + "abcd").toCharArray()) is PinUnlock.Success) - } - - @Test - fun theSameWordTypedComposedOrDecomposedIsTheSamePassword() { - val composed = "Pässwörd-日本語" - val decomposed = Normalizer.normalize(composed, Normalizer.Form.NFD) - assertNotEquals(composed.length, decomposed.length) - assertEquals(PasswordChange.SET, manager.changePassword(decomposed.toCharArray(), dek)) - - assertTrue(manager.unlockWithPassword(composed.toCharArray()) is PinUnlock.Success) - assertTrue(manager.unlockWithPassword(decomposed.toCharArray()) is PinUnlock.Success) - assertTrue(manager.verifyPasswordAuth(composed.toCharArray())) - } - - @Test - fun theCallersPasswordIsLeftForTheCallerToWipe() { - val typed = pw() - assertEquals(PasswordChange.SET, manager.changePassword(typed, dek)) - manager.unlockWithPassword(typed) - - assertArrayEquals(pw(), typed) - } - - @Test - fun aPasswordNeedsAFullLengthKey() { - assertEquals(PasswordChange.FAILED, manager.changePassword(pw(), ByteArray(31))) - assertFalse(manager.hasPasswordSlot()) - } - - // --- lockout, shared with the PIN ----------------------------------------------------------- - - @Test - fun wrongPinsAndWrongPasswordsCountTowardsTheSameLockout() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - repeat(MAX_ATTEMPTS - 2) { manager.unlockWithPin("000000".toCharArray()) } - assertEquals(0L, manager.remainingLockoutMillis()) - repeat(2) { manager.unlockWithPassword("not the password".toCharArray()) } - - assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) - assertEquals(PinUnlock.LockedOut, manager.unlockWithPin(pin())) - assertEquals(PinUnlock.LockedOut, manager.unlockWithPassword(pw())) - assertFalse(manager.verifyPasswordAuth(pw())) - } - - @Test - fun aRunningLockoutRefusesTheRightPasswordWithoutCountingIt() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - repeat(MAX_ATTEMPTS) { manager.unlockWithPassword("wrong wrong".toCharArray()) } - - assertEquals(PinUnlock.LockedOut, manager.unlockWithPassword(pw())) - - assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) - } - - @Test - fun theRightPasswordEndsTheMissesLikeTheRightPin() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - repeat(MAX_ATTEMPTS - 1) { manager.unlockWithPin("000000".toCharArray()) } - - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - - repeat(MAX_ATTEMPTS - 1) { manager.unlockWithPassword("wrong wrong".toCharArray()) } - assertEquals(0L, manager.remainingLockoutMillis()) - } - - @Test - fun aLostDeviceKeyIsReportedAndNotCountedAsAGuess() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - factor.lost = true - - repeat(MAX_ATTEMPTS + 2) { assertEquals(PinUnlock.KeyLost, manager.unlockWithPassword(pw())) } - - assertEquals(0L, manager.remainingLockoutMillis()) - } - - @Test - fun aTransientKeystoreFailureIsNotCountedEither() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - factor.failing = true - - repeat(MAX_ATTEMPTS + 2) { assertEquals(PinUnlock.Failed, manager.unlockWithPassword(pw())) } - - factor.failing = false - assertEquals(0L, manager.remainingLockoutMillis()) - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - // --- re-authentication ---------------------------------------------------------------------- - - @Test - fun verifyPasswordAuthIsTrueOnlyForTheRightPassword() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertTrue(manager.verifyPasswordAuth(pw())) - assertFalse(manager.verifyPasswordAuth("wrong password".toCharArray())) - } - - @Test - fun aDecoySessionNeverTestsTheRealPasswordButTheAttemptIsCounted() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertFalse(manager.verifyPasswordAuth(pw(), decoy = true)) - - repeat(MAX_ATTEMPTS - 1) { manager.verifyPasswordAuth(pw(), decoy = true) } - assertEquals(AuthenticationManager.FIRST_LOCKOUT_MS, manager.remainingLockoutMillis()) - } - - // --- one of PIN and password always remains --------------------------------------------------- - - @Test - fun thePasswordCannotBeRemovedWhileItIsTheOnlyWayIn() { - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertFalse(manager.removePassword()) - - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - @Test - fun thePasswordCanBeRemovedWhileThePinStands() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertTrue(manager.removePassword()) - - assertFalse(manager.hasPasswordSlot()) - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) - assertTrue(manager.unlockWithPin(pin()) is PinUnlock.Success) - } - - @Test - fun thePinCannotBeRemovedWithoutAPassword() { - assertTrue(manager.configurePin(pin(), dek)) - - assertFalse(manager.removePin(dek)) - - assertTrue(manager.hasPinSlot()) - assertTrue(manager.unlockWithPin(pin()) is PinUnlock.Success) - } - - @Test - fun removingThePinTurnsOffTheWipeAndDuressPinsAndKeepsTheShapeOfTheStore() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) - assertEquals( - DuressPinChange.SET_NEW_VAULT, - manager.configureDuressPin("246810".toCharArray(), dek) { true }, - ) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val before = store.keys() - "pin.password.slot" - val sizes = before.associateWith { store.getBytes(it)!!.size } - - assertTrue(manager.removePin(dek)) - - assertFalse(manager.hasPinSlot()) - assertEquals(before - "pin.slot", store.keys() - "pin.password.slot") - for (key in before - "pin.slot") assertEquals(key, sizes[key], store.getBytes(key)!!.size) - // The decoy vault keeps its notes: its duress PIN is turned off, not forgotten. - assertTrue(manager.hasDecoyVault(dek)) - assertTrue(manager.duressPinTurnedOff(dek)) - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - @Test - fun aPinAddedAgainStartsAsAFirstPinWithoutAWipeOrDuressPin() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - assertTrue(manager.removePin(dek)) - - assertTrue(manager.configurePin("246810".toCharArray(), dek)) - - // The old wipe PIN opens nothing: it was bound to the salt of the PIN slot that is gone. - assertEquals(PinUnlock.WrongPin, manager.unlockWithPin("987654".toCharArray())) - assertTrue(manager.unlockWithPin("246810".toCharArray()) is PinUnlock.Success) - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - // --- the device key ------------------------------------------------------------------------- - - @Test - fun aNewPinAfterTheDeviceKeyWasResetDropsThePasswordWithANotice() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - factor.lost = true - - assertTrue(manager.configurePin("246810".toCharArray(), dek)) - - assertFalse(manager.hasPasswordSlot()) - assertTrue(manager.passwordTurnedOff()) - assertTrue(manager.unlockWithPin("246810".toCharArray()) is PinUnlock.Success) - // Setting the password again clears the notice. - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - assertFalse(manager.passwordTurnedOff()) - } - - @Test - fun aPasswordSetAfterTheDeviceKeyWasResetDropsThePinSlotThatCanNeverOpen() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) - factor.lost = true - - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - assertFalse(manager.hasPinSlot()) - assertTrue(manager.unlockWithPassword(pw()) is PinUnlock.Success) - } - - @Test - fun aWipePinEraseDropsThePasswordWithTheRestOfTheOldVault() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(WipePinChange.SET, manager.configureWipePin("987654".toCharArray())) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - val erased = manager.unlockWithPin("987654".toCharArray()) - - assertTrue(erased is PinUnlock.Erased) - assertFalse(manager.hasPasswordSlot()) - assertEquals(PinUnlock.WrongPin, manager.unlockWithPassword(pw())) - } - - @Test - fun wipeDropsThePasswordAndTheStateAnOlderBuildWouldLeaveAlone() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - manager.wipe() - - assertFalse(manager.hasPasswordSlot()) - assertFalse(manager.hasPinSlot()) - } - - @Test - fun theStoreFileWithAPasswordSlotIsStillTheFormatAnOlderBuildReads() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val bytes = file.readBytes() - - // Read it the way the earliest build did: java.io, with the slot entry as one more key it does not know. - val input = DataInputStream(ByteArrayInputStream(bytes, 0, bytes.size - 32)) - val magic = ByteArray(8).also { input.readFully(it) } - val entries = (0 until input.readInt()).associate { - val key = input.readUTF() - key to ByteArray(input.readInt()).also { value -> input.readFully(value) } - } - - assertEquals("ENCLYVS3", String(magic, Charsets.US_ASCII)) - assertEquals(0, input.available()) - assertTrue("pin.password.slot" in entries) - assertArrayEquals(store.getBytes("pin.slot"), entries["pin.slot"]) - } - - @Test - fun anOlderBuildThatDropsThePinStateDropsThePasswordSlotWithIt() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - - // What the earlier AuthenticationManager.wipe() did to the store. - assertTrue(store.edit { removePrefix("pin.") }) - - assertFalse(manager.hasPasswordSlot()) - assertFalse(manager.hasPinSlot()) - } - - @Test - fun theMigrationOfOlderVaultsLeavesThePasswordSlotAlone() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - val slot = store.getBytes("pin.password.slot")!! - - assertTrue(manager.ensureWipeSlot()) - - assertArrayEquals(slot, store.getBytes("pin.password.slot")) - } - - // --- after the recovery phrase ---------------------------------------------------------------- - - @Test - fun afterRecoveryAWorkingPinOrPasswordMayBeKept() { - assertTrue(manager.configurePin(pin(), dek)) - assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) - - assertFalse(manager.removePin(dek)) - assertEquals(PasswordChange.SET, manager.changePassword(pw(), dek)) - assertTrue(manager.removePin(dek)) - assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) - } - - @Test - fun afterRecoveryANewPinIsRequiredWhenNothingCanBeUsed() { - assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) - } - - @Test - fun afterRecoveryANewPinIsRequiredWhenTheDeviceKeyIsLostOrALockoutRuns() { - assertTrue(manager.configurePin(pin(), dek)) - factor.lost = true - assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) - - factor.lost = false - factor.reset(PinKeySlot.A) - assertTrue(manager.configurePin(pin(), dek)) - repeat(MAX_ATTEMPTS) { manager.unlockWithPin("000000".toCharArray()) } - assertEquals(RecoveryFollowUp.MUST_SET_PIN, manager.recoveryFollowUp()) - } - - @Test - fun aTransientKeystoreFailureIsNotALostPin() { - assertTrue(manager.configurePin(pin(), dek)) - factor.failing = true - - assertEquals(RecoveryFollowUp.KEEP_OR_CHANGE, manager.recoveryFollowUp()) - } - - private fun pin() = "123456".toCharArray() - - private fun pw(value: String = "correct horse battery") = value.toCharArray() -} 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/SessionLockManagerTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/SessionLockManagerTest.kt index 64d39720..7afb7bba 100644 --- 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 @@ -550,6 +550,18 @@ class SessionLockManagerTest { 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 diff --git a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt b/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt deleted file mode 100644 index de05ae91..00000000 --- a/core/security/src/jvmAndAndroidTest/kotlin/com/pasich/encly/core/security/UnlockMethodsLifecycleTest.kt +++ /dev/null @@ -1,319 +0,0 @@ -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.eqValue -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: the password as a way in next to the PIN, and - * what a recovery-phrase unlock asks of the user afterwards (keep the PIN, or set a new one). - */ -class UnlockMethodsLifecycleTest { - 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) - } - - // --- the password at the lock screen ------------------------------------------------------ - - @Test - fun thePasswordUnlocksTheVaultOnItsOwn() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - manager.lock() - - assertEquals(UnlockMethods(pin = true, password = true), manager.unlockMethods()) - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPassword(PASSWORD.toCharArray())) - assertEquals(InitialStatus.MAIN, manager.securityStatus) - assertEquals(RecoveryStage.NONE, manager.recoveryStage()) - manager.lock() - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(PIN.toCharArray())) - } - - @Test - fun aWrongPasswordIsAnInvalidCredentialAndALockoutIsReported() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - manager.lock() - - repeat(AuthenticationManager.MAX_ATTEMPTS) { - assertEquals( - VaultUnlockResult.INVALID_CREDENTIAL, - manager.unlockWithPassword("not it at all".toCharArray()), - ) - } - - assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPassword(PASSWORD.toCharArray())) - assertEquals(VaultUnlockResult.LOCKED_OUT, manager.unlockWithPin(PIN.toCharArray())) - assertTrue(manager.pinLockoutRemainingMillis() > 0L) - } - - @Test - fun aLostDeviceKeyIsReportedForThePasswordToo() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - manager.lock() - factor.lost = true - - assertEquals(VaultUnlockResult.PIN_KEY_LOST, manager.unlockWithPassword(PASSWORD.toCharArray())) - assertEquals(VaultUnlockResult.PIN_KEY_LOST, manager.unlockWithPin(PIN.toCharArray())) - } - - @Test - fun theCallersPasswordIsWipedByTheUnlock() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - manager.lock() - val typed = PASSWORD.toCharArray() - - manager.unlockWithPassword(typed) - - assertTrue(typed.all { it == '\u0000' }) - } - - @Test - fun aVaultWithOnlyAPasswordStillLocksAndRoutesToTheLockScreen() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - assertTrue(manager.removePin()) - manager.lock() - - assertTrue(manager.isLockable()) - assertEquals(UnlockMethods(pin = false, password = true), manager.unlockMethods()) - assertEquals(AuthStrategy.PIN, manager.authStrategy()) - assertEquals(InitialStatus.AUTH, manager.resolveInitialStatus()) - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPassword(PASSWORD.toCharArray())) - } - - // --- Settings: add, change, remove ------------------------------------------------------------ - - @Test - fun aLockedVaultHasNoPasswordToChangeOrRemove() { - assertEquals(PasswordChange.FAILED, manager.changePassword(PASSWORD.toCharArray())) - assertFalse(manager.removePassword()) - assertFalse(manager.removePin()) - } - - @Test - fun thePasswordIsReportedToSettings() { - openWithPin() - assertFalse(manager.getSettingsAuth().hasPassword) - - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - - val settings = manager.getSettingsAuth() - assertTrue(settings.hasPassword) - assertTrue(settings.hasPinSlot) - assertFalse(settings.passwordTurnedOff) - } - - @Test - fun theVerifiedPasswordConfirmsTheSessionLikeThePin() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - - assertTrue(manager.verifyPassword(PASSWORD.toCharArray())) - assertFalse(manager.verifyPassword("something else".toCharArray())) - assertTrue(manager.verifyPin(PIN.toCharArray())) - } - - @Test - fun theLastWayInCanNotBeRemoved() { - openWithPin() - assertFalse("no password to fall back on", manager.removePin()) - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - assertTrue(manager.removePassword()) - assertFalse(manager.getSettingsAuth().hasPassword) - assertTrue(manager.getSettingsAuth().hasPinSlot) - } - - @Test - fun aShortPasswordIsInvalidAndLeavesNoSlot() { - openWithPin() - - assertEquals(PasswordChange.INVALID, manager.changePassword("short".toCharArray())) - assertFalse(manager.getSettingsAuth().hasPassword) - } - - @Test - fun theCallersNewPasswordIsWiped() { - openWithPin() - val typed = PASSWORD.toCharArray() - - manager.changePassword(typed) - - assertTrue(typed.all { it == '\u0000' }) - } - - // --- a decoy session never touches the real vault's methods ------------------------------------ - - @Test - fun aDecoySessionRefusesEverythingAboutThePasswordAndThePin() { - openWithPin() - `when`(database.wipeIfClosed(VaultKind.DECOY)).thenReturn(true) - `when`(database.unlockDatabase(anyByteArray(), anyBoolean(), eqValue(VaultKind.DECOY))).thenReturn(true) - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - assertEquals(DuressPinChange.SET_NEW_VAULT, manager.configureDuressPin(DURESS.toCharArray())) - manager.lock() - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithPin(DURESS.toCharArray())) - assertTrue(manager.isDecoySession()) - - assertEquals(PasswordChange.FAILED, manager.changePassword("a new password".toCharArray())) - assertFalse(manager.removePassword()) - assertFalse(manager.removePin()) - assertFalse( - "the real password opens nothing from inside a decoy session", - manager.verifyPassword(PASSWORD.toCharArray()), - ) - assertEquals(RecoveryStage.NONE, manager.recoveryStage()) - } - - // --- 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 aWorkingPasswordCountsAsMuchAsAWorkingPin() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - assertTrue(manager.removePin()) - manager.lock() - - assertEquals(VaultUnlockResult.SUCCESS, manager.unlockWithSeed(phrase.copyOf())) - - assertEquals(RecoveryStage.CHOOSE, manager.recoveryStage()) - } - - @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 aLostDeviceKeyForcesANewPinAfterRecoveryAndTakesThePasswordWithIt() { - openWithPin() - assertEquals(PasswordChange.SET, manager.changePassword(PASSWORD.toCharArray())) - 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())) - val settings = manager.getSettingsAuth() - assertFalse(settings.hasPassword) - assertTrue(settings.passwordTurnedOff) - 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()) - } - - 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 PASSWORD = "correct horse battery" - } -} 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 index 0f537427..a79a4f1f 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/nativeos/NativeScreenLock.kt @@ -2,6 +2,7 @@ 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 @@ -12,10 +13,17 @@ 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 @@ -212,11 +220,57 @@ private class WindowsScreenLockProbe { * 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 -> @@ -224,14 +278,19 @@ private class LogindLockSignals : OsLockSignals { } }, subscribe({ DBusConnectionBuilder.forSessionBus() }) { connection -> - SCREEN_SAVERS.forEach { iface -> + sessionBus = connection + SCREEN_SAVERS.map { it.iface }.forEach { iface -> connection.on(iface, "ActiveChanged") { signal -> onChange(signal.parameters.firstOrNull() as? Boolean ?: false) } } }, ) - return AutoCloseable { connections.forEach { runCatching(it::close) } } + return AutoCloseable { + systemBus = null + sessionBus = null + connections.forEach { runCatching(it::close) } + } } private fun subscribe(builder: () -> DBusConnectionBuilder, register: (DBusConnection) -> Unit): DBusConnection? = @@ -253,13 +312,60 @@ private class LogindLockSignals : OsLockSignals { } 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( - "org.freedesktop.ScreenSaver", - "org.gnome.ScreenSaver", - "org.cinnamon.ScreenSaver", - "org.mate.ScreenSaver", + 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/security/DesktopSessionLock.kt b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt index e9636782..2ca1e3e7 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/security/DesktopSessionLock.kt @@ -19,6 +19,13 @@ import javax.swing.SwingUtilities 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 { } @@ -52,6 +59,24 @@ class SleepDetector(private val clocks: () -> Clocks?, private val minSleepMilli } } +/** + * 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 @@ -131,7 +156,8 @@ class DesktopDeviceLockWatcher( } } - override fun isDeviceLocked(): Boolean = locked + // 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 diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt index ea4953ec..eedc2cdf 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/SessionTest.kt @@ -10,6 +10,7 @@ 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 @@ -245,6 +246,45 @@ class DesktopDeviceLockWatcherTest { 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() } @@ -553,3 +593,48 @@ class SwingMainThreadTimerTest { 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/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index 0761ac24..d16db08d 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -13,7 +13,6 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -72,7 +71,6 @@ class BackupFlowsTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) `when`(security.verifyPin(anyCharArray())).thenAnswer { String(it.getArgument(0)) == PIN } `when`(security.hasRecoverySeed()).thenReturn(true) `when`(security.hasBackupKey()).thenReturn(true) @@ -146,35 +144,6 @@ class BackupFlowsTest { verify(security, never()).copyBackupRootKey() } - @Test - fun aVaultWithOnlyAPasswordIsAskedForItAndAWrongOneNeverSeals() { - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) - `when`(security.verifyPassword(anyCharArray())).thenAnswer { String(it.getArgument(0)) == PASSWORD } - - viewModel.start(BackupAction.EXPORT) - assertEquals(UnlockMethods(pin = false, password = true), (state().step as BackupStep.Reauth).methods) - viewModel.reauthFlow.submitPassword("not the password".toCharArray()) - - waitFor { (it as? BackupStep.Reauth)?.error != null } - verify(security, never()).copyBackupRootKey() - - viewModel.reauthFlow.submitPassword(PASSWORD.toCharArray()) - - waitFor { it == BackupStep.PickExportTarget } - verify(security, never()).verifyPin(anyCharArray()) - } - - @Test - fun aPasswordDuringALockoutIsNotCheckedAndSaysNothingWrong() { - `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) - - viewModel.start(BackupAction.EXPORT) - viewModel.reauthFlow.submitPassword(PASSWORD.toCharArray()) - - verify(security, never()).verifyPassword(anyCharArray()) - assertNull((state().step as BackupStep.Reauth).error) - } - @Test fun aLockedVaultCannotBeExported() { `when`(security.copyBackupRootKey()).thenReturn(null) @@ -516,7 +485,6 @@ class BackupFlowsTest { private companion object { const val PIN = "482915" - const val PASSWORD = "correct horse battery" const val TIMEOUT_MS = 10_000L const val POLL_MS = 10L } 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 index 6bce974b..d0b57239 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModelTest.kt @@ -8,7 +8,6 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -47,7 +46,6 @@ class BackupViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) sessionLock = SessionLockManager(security, screenshots = screenshots) viewModel = BackupViewModel( backupManager = BackupManager( 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 index c15bbd13..a2d0d43a 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModelTest.kt @@ -13,7 +13,6 @@ 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.core.security.UnlockMethods import com.pasich.encly.data.backup.VaultDataStore import com.pasich.encly.data.backup.VaultSnapshot import com.pasich.encly.data.export.MarkdownDocuments @@ -63,7 +62,6 @@ class MarkdownExportViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) security = mock(SecurityManager::class.java) - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) `when`(security.verifyPin(anyCharArray())).thenReturn(true) documents = mock(MarkdownDocuments::class.java) doAnswer { invocation -> 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/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 30a7ee96..3cbf583a 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -18,7 +18,6 @@ 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.UnlockMethods import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.core.security.bip39.Bip39 import com.pasich.encly.data.backup.BackupDocuments @@ -108,8 +107,7 @@ internal class TestApp { sessionLock.onStart(mock(androidx.lifecycle.LifecycleOwner::class.java)) withAuth(recoveryPhrase = false) `when`(security.wipeAndReset()).thenReturn(true) - // A vault with a PIN and no password, and a session that owes nothing after the recovery phrase. - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = false)) + // A session that owes nothing after the recovery phrase. `when`(security.recoveryStage()).thenReturn(RecoveryStage.NONE) } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt deleted file mode 100644 index df5b1606..00000000 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/UnlockMethodsScreensTest.kt +++ /dev/null @@ -1,447 +0,0 @@ -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.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.lock_password_subtitle -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_password -import com.pasich.encly.core.resources.lock_use_pin -import com.pasich.encly.core.resources.lock_use_recovery_phrase -import com.pasich.encly.core.resources.lock_wrong_password -import com.pasich.encly.core.resources.password_change -import com.pasich.encly.core.resources.password_current_wrong -import com.pasich.encly.core.resources.password_failed -import com.pasich.encly.core.resources.password_mismatch -import com.pasich.encly.core.resources.password_pin_only_note -import com.pasich.encly.core.resources.password_remove -import com.pasich.encly.core.resources.password_remove_confirm -import com.pasich.encly.core.resources.password_removed -import com.pasich.encly.core.resources.password_save -import com.pasich.encly.core.resources.password_saved -import com.pasich.encly.core.resources.password_set -import com.pasich.encly.core.resources.password_too_short -import com.pasich.encly.core.resources.pin_digits_count -import com.pasich.encly.core.resources.pin_remove -import com.pasich.encly.core.resources.pin_remove_confirm -import com.pasich.encly.core.resources.pin_removed -import com.pasich.encly.core.resources.recovery_choice_body_password -import com.pasich.encly.core.resources.recovery_choice_body_pin -import com.pasich.encly.core.resources.recovery_keep_password -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.AuthSettings -import com.pasich.encly.core.security.AuthType -import com.pasich.encly.core.security.PasswordChange -import com.pasich.encly.core.security.RecoveryStage -import com.pasich.encly.core.security.UnlockMethods -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.presentation.screen.settings.PasswordScreen -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.times -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` - -/** - * The PIN of 6 to 12 digits and the password as a second way in: the lock screen, the choice after the recovery - * phrase and Settings → Security → Password. The PIN screens never submit by themselves; a button does. - */ -@OptIn(ExperimentalTestApi::class) -class UnlockMethodsScreensTest : ComposeScreenTest() { - private val app by lazy { TestApp() } - - private fun showLock(methods: UnlockMethods = UnlockMethods(pin = true, password = false)) { - `when`(app.security.unlockMethods()).thenReturn(methods) - `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) - - private fun typePassword(text: String) { - rule.onNode(hasSetTextAction()).performTextInput(text) - } - - // --- 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)) - } - - // --- the password on the lock screen ------------------------------------------------------------- - - @Test - fun withoutAPasswordThereIsNoSwitch() { - showLock() - - assertEquals(0, countText(str(Res.string.lock_use_password))) - } - - @Test - fun aVaultWithBothOffersToSwitchBetweenThem() { - showLock(UnlockMethods(pin = true, password = true)) - rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() - - rule.onNodeWithText(str(Res.string.lock_password_subtitle)).assertIsDisplayed() - rule.onNodeWithText(str(Res.string.lock_use_pin)).performClick() - - rule.onNodeWithText(str(Res.string.lock_subtitle)).assertIsDisplayed() - } - - @Test - fun thePasswordUnlocksOnItsOwn() { - var sent = "" - `when`(app.security.unlockWithPassword(anyCharArray())).thenAnswer { - sent = String(it.getArgument(0)) - VaultUnlockResult.SUCCESS - } - showLock(UnlockMethods(pin = true, password = true)) - rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() - rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() - - typePassword("correct horse") - rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsEnabled().performClick() - - waitFor { currentRoute() == NavRoutes.HomeRoute.name } - assertEquals("correct horse", sent) - verify(app.security, never()).unlockWithPin(anyCharArray()) - } - - @Test - fun aShortPasswordCanNotBeSent() { - showLock(UnlockMethods(pin = true, password = true)) - rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() - - typePassword("1234567") - - rule.onNodeWithText(str(Res.string.lock_unlock)).assertIsNotEnabled() - verify(app.security, never()).unlockWithPassword(anyCharArray()) - } - - @Test - fun aWrongPasswordSaysSoAndStays() { - `when`(app.security.unlockWithPassword(anyCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - showLock(UnlockMethods(pin = true, password = true)) - rule.onNodeWithText(str(Res.string.lock_use_password)).performClick() - typePassword("not the password") - - rule.onNodeWithText(str(Res.string.lock_unlock)).performClick() - - waitForText(str(Res.string.lock_wrong_password)) - assertEquals(NavRoutes.LockRoute.name, currentRoute()) - } - - @Test - fun aVaultWithOnlyAPasswordOpensOnTheFormWithoutAPinSwitch() { - showLock(UnlockMethods(pin = false, password = true)) - - rule.onNodeWithText(str(Res.string.lock_password_subtitle)).assertIsDisplayed() - assertEquals(0, countText(str(Res.string.lock_use_pin))) - } - - // --- 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() - } - - @Test - fun aVaultWithOnlyAPasswordKeepsItsPassword() { - `when`(app.security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) - `when`(app.security.recoveryStage()).thenReturn(RecoveryStage.CHOOSE) - setNavScreen( - viewModels(app.lock()), - route = NavRoutes.RecoveryChoiceRoute.name, - ) { nav -> RecoveryChoiceScreen(nav) } - - rule.onNodeWithText(str(Res.string.recovery_choice_body_password)).assertIsDisplayed() - rule.onNodeWithText(str(Res.string.recovery_keep_password)).assertIsDisplayed() - } - - // --- Settings → Security → Password ---------------------------------------------------------------- - - private fun showPassword(hasPassword: Boolean, hasPin: Boolean = true) { - `when`(app.security.unlockMethods()).thenReturn(UnlockMethods(pin = hasPin, password = hasPassword)) - `when`(app.security.getSettingsAuth()).thenReturn( - AuthSettings(AuthType.PIN, false, true, hasPinSlot = hasPin, hasPassword = hasPassword), - ) - `when`(app.security.verifyPin(anyCharArray())).thenReturn(true) - `when`(app.security.verifyPassword(anyCharArray())).thenReturn(true) - setNavScreen( - viewModels(app.securitySettings()), - route = NavRoutes.PasswordRoute.name, - start = NavRoutes.SecuritySettingsRoute.name, - ) { nav -> PasswordScreen(nav) } - navigateTo(NavRoutes.PasswordRoute.name) - } - - @Test - fun theWipeAndDuressPinsAreSaidToStayPinOnly() { - showPassword(hasPassword = false) - enterPin("123456") - - rule.onNodeWithText(str(Res.string.password_pin_only_note)).assertIsDisplayed() - rule.onNodeWithText(str(Res.string.password_set)).assertIsDisplayed() - } - - @Test - fun theOwnerIsAskedForThePinBeforeAnythingChanges() { - showPassword(hasPassword = false) - - assertEquals(0, countText(str(Res.string.password_set))) - enterPin("123456") - - verify(app.security).verifyPin(anyCharArray()) - rule.onNodeWithText(str(Res.string.password_set)).assertIsDisplayed() - } - - @Test - fun aVaultWithoutAPinIsAskedForItsPassword() { - showPassword(hasPassword = true, hasPin = false) - - typePassword("correct horse") - rule.onNodeWithText(str(Res.string.action_continue)).performClick() - - waitForText(str(Res.string.password_change)) - verify(app.security).verifyPassword(anyCharArray()) - verify(app.security, never()).verifyPin(anyCharArray()) - } - - @Test - fun aNewPasswordIsTypedTwiceAndSaved() { - var saved = "" - `when`(app.security.changePassword(anyCharArray())).thenAnswer { - saved = String(it.getArgument(0)) - PasswordChange.SET - } - showPassword(hasPassword = false) - enterPin("123456") - rule.onNodeWithText(str(Res.string.password_set)).performClick() - - val fields = rule.onAllNodes(hasSetTextAction()) - fields[0].performTextInput("a long enough one") - fields[1].performTextInput("a long enough one") - rule.onNodeWithText(str(Res.string.password_save)).assertIsEnabled().performClick() - - waitForText(str(Res.string.password_saved)) - assertEquals("a long enough one", saved) - } - - @Test - fun passwordsThatDoNotMatchAreNotSaved() { - showPassword(hasPassword = false) - enterPin("123456") - rule.onNodeWithText(str(Res.string.password_set)).performClick() - - val fields = rule.onAllNodes(hasSetTextAction()) - fields[0].performTextInput("a long enough one") - fields[1].performTextInput("a long enough two") - rule.onNodeWithText(str(Res.string.password_save)).performClick() - - waitForText(str(Res.string.password_mismatch)) - verify(app.security, never()).changePassword(anyCharArray()) - } - - @Test - fun aPasswordThatIsTooShortCanNotBeSaved() { - showPassword(hasPassword = false) - enterPin("123456") - rule.onNodeWithText(str(Res.string.password_set)).performClick() - - val fields = rule.onAllNodes(hasSetTextAction()) - fields[0].performTextInput("short") - fields[1].performTextInput("short") - - rule.onNodeWithText(str(Res.string.password_save)).assertIsNotEnabled() - } - - @Test - fun theCoreRefusingAPasswordIsShown() { - `when`(app.security.changePassword(anyCharArray())).thenReturn(PasswordChange.INVALID) - showPassword(hasPassword = false) - enterPin("123456") - rule.onNodeWithText(str(Res.string.password_set)).performClick() - val fields = rule.onAllNodes(hasSetTextAction()) - fields[0].performTextInput("a long enough one") - fields[1].performTextInput("a long enough one") - - rule.onNodeWithText(str(Res.string.password_save)).performClick() - - waitForText(str(Res.string.password_too_short)) - } - - @Test - fun aFailedSaveIsReported() { - `when`(app.security.changePassword(anyCharArray())).thenReturn(PasswordChange.FAILED) - showPassword(hasPassword = false) - enterPin("123456") - rule.onNodeWithText(str(Res.string.password_set)).performClick() - val fields = rule.onAllNodes(hasSetTextAction()) - fields[0].performTextInput("a long enough one") - fields[1].performTextInput("a long enough one") - - rule.onNodeWithText(str(Res.string.password_save)).performClick() - - waitForText(str(Res.string.password_failed)) - } - - @Test - fun thePasswordCanBeRemovedWhileThePinStands() { - `when`(app.security.removePassword()).thenReturn(true) - showPassword(hasPassword = true) - enterPin("123456") - - rule.onNodeWithText(str(Res.string.password_remove)).performClick() - rule.onNodeWithText(str(Res.string.password_remove_confirm)).performClick() - - waitForText(str(Res.string.password_removed)) - verify(app.security, times(1)).removePassword() - } - - @Test - fun thePinCanOnlyBeRemovedAfterTypingThePasswordAgain() { - `when`(app.security.removePin()).thenReturn(true) - showPassword(hasPassword = true) - enterPin("123456") - - rule.onNodeWithText(str(Res.string.pin_remove)).performClick() - rule.onNodeWithText(str(Res.string.pin_remove_confirm)).assertIsNotEnabled() - verify(app.security, never()).removePin() - typePassword("correct horse") - rule.onNodeWithText(str(Res.string.pin_remove_confirm)).performClick() - - waitForText(str(Res.string.pin_removed)) - verify(app.security).verifyPassword(anyCharArray()) - verify(app.security).removePin() - } - - @Test - fun aWrongPasswordKeepsThePin() { - showPassword(hasPassword = true) - enterPin("123456") - rule.onNodeWithText(str(Res.string.pin_remove)).performClick() - `when`(app.security.verifyPassword(anyCharArray())).thenReturn(false) - typePassword("not the password") - - rule.onNodeWithText(str(Res.string.pin_remove_confirm)).performClick() - - waitForText(str(Res.string.password_current_wrong)) - verify(app.security, never()).removePin() - } - - @Test - fun noRemovalIsOfferedWithoutThePinToFallBackOn() { - showPassword(hasPassword = true, hasPin = false) - typePassword("correct horse") - rule.onNodeWithText(str(Res.string.action_continue)).performClick() - waitForText(str(Res.string.password_change)) - - assertEquals(0, countText(str(Res.string.password_remove))) - assertEquals(0, countText(str(Res.string.pin_remove))) - } -} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt index 43ea8df7..8d45d17c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/TextFields.kt @@ -31,7 +31,6 @@ 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.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.pasich.encly.core.resources.Res import com.pasich.encly.core.resources.field_correct @@ -71,7 +70,6 @@ fun EnclyTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, singleLine: Boolean = true, - visualTransformation: VisualTransformation = VisualTransformation.None, ) { val interactionSource = remember { MutableInteractionSource() } val focused by interactionSource.collectIsFocusedAsState() @@ -95,7 +93,6 @@ fun EnclyTextField( cursorBrush = SolidColor(colors.primary), keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, - visualTransformation = visualTransformation, interactionSource = interactionSource, modifier = fieldModifier.fillMaxWidth(), decorationBox = { inner -> 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 index 379e2909..7f54cdf7 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -41,7 +41,6 @@ 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.PasswordScreen import com.pasich.encly.presentation.screen.settings.SecuritySettingsScreen import com.pasich.encly.presentation.screen.settings.SettingsScreen import com.pasich.encly.presentation.screen.settings.WipePinScreen @@ -198,10 +197,6 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR NavigationPage(navController, NavRoutes.SettingsRoute) { PinCodeConfigScreen(navController) } } - animationScreens(NavRoutes.PasswordRoute.name) { - NavigationPage(navController, NavRoutes.SettingsRoute) { PasswordScreen(navController) } - } - animationScreens(NavRoutes.RecoveryChoiceRoute.name) { CenteredContent { RecoveryChoiceScreen(navController) } } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt index 144ff69e..a994247c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavRoutes.kt @@ -17,7 +17,6 @@ enum class NavRoutes { LegacyVaultRoute, StorageUnreadableRoute, PinCodeConfig, - PasswordRoute, RecoveryChoiceRoute, WipePinRoute, DuressPinRoute, 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 index 2d731862..3341d10a 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/FaqScreen.kt @@ -35,8 +35,6 @@ 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_password_a -import com.pasich.encly.core.resources.faq_password_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 @@ -104,7 +102,6 @@ private val faqSections = listOf( 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_password_q to Res.string.faq_password_a, Res.string.faq_screenshots_q to Res.string.faq_screenshots_a, Res.string.faq_background_q to Res.string.faq_background_a, ), 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 index e7d4c20a..e2336d90 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockFormState.kt @@ -8,12 +8,11 @@ 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_password 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.PasswordInput import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.viewmodel.PinUnlockResult @@ -21,26 +20,17 @@ 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 password, the recovery phrase, and the last + * 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, password and phrase forms. +@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) - /** The password form is shown instead of the PIN pad (the user switched, or the vault has no PIN). */ - var usePassword by mutableStateOf(false) - - /** The password typed so far; a String in the text field, dropped when taken or cleared (see [PasswordInput]). */ - val password = PasswordInput() - - var passwordError by mutableStateOf(null) - private set - /** 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 @@ -89,43 +79,6 @@ internal class LockFormState { /** Forgets the typed digits. */ fun clearPin() = pinDigits.clear() - /** The password, taken out of the form to be checked. The caller wipes the copy. */ - fun takePassword(): CharArray = password.take() - - /** Any edit of the password clears the last error. */ - fun onPasswordEdited() { - passwordError = null - } - - /** The PIN pad and the password form swap places; what was typed in the one is dropped. */ - fun switchTo(usePassword: Boolean) { - clearPin() - password.clear() - pinError = null - passwordError = null - this.usePassword = usePassword - } - - fun onPasswordResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { - when (result) { - PinUnlockResult.WRONG_PIN -> { - passwordError = Res.string.lock_wrong_password - lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) - } - - PinUnlockResult.DB_ERROR -> passwordError = Res.string.error_database_open - - PinUnlockResult.LOCKED_OUT -> lockoutSeconds = lockoutSecondsLeft(lockoutRemainingMillis) - - PinUnlockResult.KEY_LOST -> { - passwordError = Res.string.lock_pin_key_lost - phraseError = Res.string.lock_pin_key_lost - } - - PinUnlockResult.SUCCESS, PinUnlockResult.BACKGROUNDED -> Unit - } - } - fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { when (result) { PinUnlockResult.WRONG_PIN -> { @@ -136,6 +89,8 @@ internal class LockFormState { 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. @@ -153,12 +108,12 @@ internal class LockFormState { phraseError = null } - /** System back: from the recovery form to the PIN pad or password form; those themselves stay. */ + /** 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 (or the password form); the typed words are dropped. */ + /** Back to the PIN pad; the typed words are dropped. */ fun leaveRecovery() { useRecovery = false phrase.clear() @@ -170,6 +125,7 @@ internal class LockFormState { 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 } } 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 index 8803e09d..bc27ee00 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockForms.kt @@ -13,8 +13,6 @@ 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_password_label -import com.pasich.encly.core.resources.lock_password_subtitle 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 @@ -22,17 +20,13 @@ 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_password -import com.pasich.encly.core.resources.lock_use_pin import com.pasich.encly.core.resources.lock_use_recovery_phrase import com.pasich.encly.core.security.SensitiveDataCleaner -import com.pasich.encly.core.security.UnlockMethods 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.PasswordField import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold @@ -44,26 +38,12 @@ 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, the recovery phrase, and which of the - * PIN and the password the vault has ([methods]). - */ -internal data class LockCapabilities( - val biometricEnabled: Boolean, - val recoveryAvailable: Boolean, - val methods: UnlockMethods, -) { - /** - * The other way in, as a link under the form on show: null when the vault has one way in only, else whether it - * is the password (true, from the PIN pad) or the PIN (false, from the password form). - */ - fun otherMethod(form: LockFormState): Boolean? = if (methods.pin && methods.password) !form.usePassword else null -} +/** 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 three checks of a secret the lock screen can send; each wipes what it is given. */ +/** 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 password: (password: CharArray, onResult: (PinUnlockResult) -> Unit) -> Unit, val seed: (phrase: CharArray, onResult: (SeedUnlockResult) -> Unit) -> Unit, ) @@ -79,7 +59,7 @@ internal class LockActions( val onPromptBiometric: () -> Unit, ) -/** The form on show: the recovery phrase, the password, or the PIN pad. */ +/** The form on show: the recovery phrase or the PIN pad. */ @Composable internal fun LockForms( form: LockFormState, @@ -99,8 +79,6 @@ internal fun LockForms( // 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 if (form.usePassword) { - PasswordLockContent(form, capabilities, busy, actions) } else { PinLockContent(form, capabilities, busy, actions) } @@ -145,89 +123,12 @@ private fun PinLockContent(form: LockFormState, capabilities: LockCapabilities, onClick = submit, ), ) - OtherMethodLinks(form, capabilities, busy) - } -} - -/** The links under the PIN pad: the password (when the vault has one), and the recovery phrase. */ -@Composable -private fun OtherMethodLinks(form: LockFormState, capabilities: LockCapabilities, busy: Boolean) { - capabilities.otherMethod(form)?.let { toPassword -> - EnclyTextButton( - text = stringResource(if (toPassword) Res.string.lock_use_password else Res.string.lock_use_pin), - onClick = { form.switchTo(usePassword = toPassword) }, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.gutter), - enabled = !busy, - ) - } - if (capabilities.recoveryAvailable) RecoveryLink(onUseRecovery = { form.useRecovery = true }) -} - -/** The password form: a masked field, the Unlock button pinned above the keyboard, and the same links. */ -@Composable -private fun PasswordLockContent( - form: LockFormState, - capabilities: LockCapabilities, - busy: Boolean, - actions: LockActions, -) { - PinLockoutTicker(form.lockedOut, actions.lockoutRemainingMillis) { form.lockoutSeconds = it } - DisposableEffect(form) { onDispose { form.password.clear() } } - ReleaseFocusWhile(busy) - val canSubmit = form.password.canSubmit && !form.lockedOut && !busy - val submit = { - if (canSubmit) { - submitSecret(form.takePassword(), actions.checks.password, actions) { r, ms -> - form.onPasswordResult(r, ms) - } - } - } - val shownError = form.passwordError - PinEntryScaffold( - title = stringResource(Res.string.lock_title), - subtitle = when { - form.lockedOut -> pinLockoutText(form.lockoutSeconds) - shownError != null -> stringResource(shownError) - else -> stringResource(Res.string.lock_password_subtitle) - }, - subtitleIsError = form.lockedOut || shownError != null, - icon = EnclyIcons.Key, - compact = true, - footer = { - EnclyButton(text = stringResource(Res.string.lock_unlock), onClick = submit, enabled = canSubmit) - capabilities.otherMethod(form)?.let { toPassword -> - EnclyTextButton( - text = stringResource(if (toPassword) Res.string.lock_use_password else Res.string.lock_use_pin), - onClick = { form.switchTo(usePassword = toPassword) }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - ) - } - if (capabilities.recoveryAvailable) { - EnclyTextButton( - text = stringResource(Res.string.lock_use_recovery_phrase), - onClick = { form.useRecovery = true }, - modifier = Modifier.fillMaxWidth(), - enabled = !busy, - ) - } - }, - ) { - PasswordField( - input = form.password, - label = stringResource(Res.string.lock_password_label), - enabled = !form.lockedOut && !busy, - onEdit = form::onPasswordEdited, - onDone = submit, - modifier = Modifier - .padding(horizontal = EnclyTheme.spacing.gutter) - .padding(top = EnclyTheme.spacing.l), - ) + if (capabilities.recoveryAvailable) RecoveryLink(onUseRecovery = { form.useRecovery = true }) } } /** - * Sends [secret] (a PIN or a password) to be checked by [authenticate], unless a lockout runs. The ViewModel wipes it + * 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( @@ -318,7 +219,7 @@ private fun RecoveryLink(onUseRecovery: () -> Unit) { } /** - * The loading view covers a form but not the keyboard: while a secret is checked (the phrase, the password) the + * 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 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 index 90708cdb..b70fa6a9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/LockScreen.kt @@ -95,9 +95,7 @@ fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockV 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 methods = remember { viewModel.unlockMethods() } - // A vault whose PIN was removed opens on the password form. - val form = remember { LockFormState().also { it.usePassword = !methods.pin && methods.password } } + 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. @@ -137,12 +135,10 @@ fun LockScreen(exits: LockExits, modifier: Modifier = Modifier, viewModel: LockV capabilities = LockCapabilities( biometricEnabled = biometricEnabled && host != null, recoveryAvailable = viewModel.recoveryAvailable(), - methods = methods, ), actions = LockActions( checks = LockChecks( pin = viewModel::authenticatePin, - password = viewModel::authenticatePassword, seed = viewModel::authenticateSeed, ), lockoutRemainingMillis = viewModel::lockoutRemainingMillis, 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 index 40356853..8088b933 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -38,10 +38,6 @@ 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.lock_password_label -import com.pasich.encly.core.resources.password_current_subtitle -import com.pasich.encly.core.resources.password_current_title -import com.pasich.encly.core.resources.password_current_wrong 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 @@ -62,10 +58,6 @@ 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.PasswordInput -import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold -import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit -import com.pasich.encly.presentation.screen.pincode.PasswordStepText import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions @@ -94,12 +86,9 @@ enum class PinAnimationState { * 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, val verifyWithPassword: Boolean = false) { +private class PinChangeState(val isReset: Boolean) { var step by mutableIntStateOf(if (isReset) 1 else 0) - /** The password typed at step 0 of a vault that has no PIN to ask for (see [verifyWithPassword]). */ - val password = PasswordInput() - /** The new PIN typed at step 1, until the confirmation is compared with it. */ private var firstPin: CharArray? = null @@ -112,15 +101,9 @@ private class PinChangeState(val isReset: Boolean, val verifyWithPassword: Boole /** Changes on every wrong entry, so the dots shake again. */ var shakeKey by mutableIntStateOf(0) - /** Digits are refused while the current PIN (or password) is locked out. */ + /** Digits are refused while the current PIN is locked out. */ val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) - /** The password of step 0 is sent to be checked. */ - fun onPasswordSubmit(viewModel: SecuritySettingsViewModel) { - if (!password.canSubmit || !keysEnabled) return - viewModel.verifyCurrentPassword(password.take()) { ok -> onCurrentPinChecked(ok, viewModel) } - } - /** A digit on the keypad; refused during a lockout. */ fun typeDigit(digit: Int) { if (keysEnabled) input.add(digit) @@ -166,7 +149,6 @@ private class PinChangeState(val isReset: Boolean, val verifyWithPassword: Boole /** Typed or kept digits do not outlive the screen. */ fun clear() { input.clear() - password.clear() firstPin?.let(SensitiveDataCleaner::clear) firstPin = null } @@ -184,7 +166,7 @@ private class PinChangeState(val isReset: Boolean, val verifyWithPassword: Boole } else -> { - errorText = if (verifyWithPassword) Res.string.password_current_wrong else Res.string.pin_current_wrong + errorText = Res.string.pin_current_wrong shakeKey++ } } @@ -208,13 +190,7 @@ fun PinCodeConfigScreen( ) { // 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(), - // A vault with no PIN is asked for its password before a PIN is added. - verifyWithPassword = !securityViewModel.hasPinSlot(), - ) - } + val pinState = remember { PinChangeState(isReset = !securityViewModel.requiresCurrentPin()) } BackHandler(enabled = pinState.isReset) { } if (pinState.isReset && LocalSessionSharedAcrossWindows.current) { @@ -256,7 +232,6 @@ fun PinCodeConfigScreen( step = currentStep, pinState = pinState, onSubmitPin = { pinState.onPinComplete(securityViewModel) }, - onSubmitPassword = { pinState.onPasswordSubmit(securityViewModel) }, ) } } else { @@ -267,18 +242,9 @@ fun PinCodeConfigScreen( } } -/** The page of [step]: the password of a vault with no PIN, or the keypad of the PIN steps. */ +/** The page of [step]: the keypad of the PIN steps. */ @Composable -private fun PinStepContent( - step: Int, - pinState: PinChangeState, - onSubmitPin: () -> Unit, - onSubmitPassword: () -> Unit, -) { - if (step == 0 && pinState.verifyWithPassword) { - CurrentPasswordContent(pinState, onSubmit = onSubmitPassword) - return - } +private fun PinStepContent(step: Int, pinState: PinChangeState, onSubmitPin: () -> Unit) { MainPinContent( text = pinStepText(step, pinState), entered = pinState.input.length, @@ -364,29 +330,6 @@ private class PinEntryState( val onSubmit: () -> Unit, ) -/** Step 0 for a vault with no PIN: its password is asked for before a PIN is added. */ -@Composable -private fun CurrentPasswordContent(state: PinChangeState, onSubmit: () -> Unit) { - val lockedOut = state.lockoutSeconds > 0L - val error = state.errorText?.let { stringResource(it) } - PasswordStepScaffold( - text = PasswordStepText( - title = stringResource(Res.string.password_current_title), - subtitle = when { - lockedOut -> pinLockoutText(state.lockoutSeconds) - error != null -> error - else -> stringResource(Res.string.password_current_subtitle) - }, - label = stringResource(Res.string.lock_password_label), - subtitleIsError = lockedOut || error != null, - ), - input = state.password, - submit = PasswordStepSubmit(stringResource(Res.string.action_continue), onSubmit), - enabled = !lockedOut, - onEdit = { state.errorText = null }, - ) -} - @Composable private fun MainPinContent( text: PinStepText, 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 index 129e9dec..029c9337 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/RecoveryChoiceScreen.kt @@ -3,15 +3,12 @@ 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.runtime.remember 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_password 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_password 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 @@ -28,7 +25,7 @@ import org.jetbrains.compose.resources.stringResource private const val CHOICE_SETTLED_POLL_MS = 1_000L /** - * After the recovery phrase unlocked a vault whose PIN (or password) still works: keep it, which is the default, or + * 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]). @@ -39,7 +36,6 @@ fun RecoveryChoiceScreen( modifier: Modifier = Modifier, viewModel: LockViewModel = metroViewModel(), ) { - val methods = remember { viewModel.unlockMethods() } BackHandler(enabled = true) { } // Another window may settle the choice first: this one goes on into the app with it. LaunchedEffect(viewModel) { @@ -53,16 +49,12 @@ fun RecoveryChoiceScreen( } PinEntryScaffold( title = stringResource(Res.string.recovery_choice_title), - subtitle = stringResource( - if (methods.pin) Res.string.recovery_choice_body_pin else Res.string.recovery_choice_body_password, - ), + subtitle = stringResource(Res.string.recovery_choice_body_pin), icon = EnclyIcons.Key, modifier = modifier, footer = { EnclyButton( - text = stringResource( - if (methods.pin) Res.string.recovery_keep_pin else Res.string.recovery_keep_password, - ), + text = stringResource(Res.string.recovery_keep_pin), onClick = { viewModel.keepAfterRecovery() navController.goHome() 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 index acb32155..ac96e70b 100644 --- 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 @@ -1,13 +1,11 @@ package com.pasich.encly.presentation.screen.backup 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.getValue import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -18,17 +16,9 @@ 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.core.resources.lock_password_label -import com.pasich.encly.core.resources.lock_password_subtitle -import com.pasich.encly.core.resources.lock_use_password -import com.pasich.encly.core.resources.lock_use_pin 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.PasswordInput -import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold -import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit -import com.pasich.encly.presentation.screen.pincode.PasswordStepText import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions @@ -42,17 +32,15 @@ import com.pasich.encly.ui.theme.EnclyTheme import org.jetbrains.compose.resources.stringResource /** - * Full-screen PIN or password 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. A vault with both a PIN - * and a password asks for the PIN and offers the password under it; with only one, that one. + * 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 usePassword by remember { mutableStateOf(!step.methods.pin && step.methods.password) } var lockoutSeconds by remember { mutableLongStateOf(0L) } - // Keyed on the step too: a wrong secret (a new step) may just have started a lockout. + // 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 } @@ -61,74 +49,35 @@ internal fun ReauthDialog(reauth: ReauthFlow, step: BackupStep.Reauth, onCancel: val subtitle = when { lockedOut -> pinLockoutText(lockoutSeconds) error != null -> error - usePassword -> stringResource(Res.string.lock_password_subtitle) else -> stringResource(Res.string.backup_reauth_subtitle) } - val entry = ReauthEntry( - subtitle = subtitle, - subtitleIsError = lockedOut || error != null, - hasError = error != null, - lockedOut = lockedOut, - // The other way in, offered when the vault has both. - onSwitch = { usePassword = !usePassword }.takeIf { step.methods.pin && step.methods.password }, - onCancel = onCancel, - ) Dialog( onDismissRequest = onCancel, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - if (usePassword) ReauthPasswordEntry(reauth, entry) else ReauthPinEntry(reauth, step, entry) + ReauthPinEntry( + reauth = reauth, + step = step, + entry = ReauthEntry( + subtitle = subtitle, + subtitleIsError = lockedOut || error != null, + hasError = error != null, + lockedOut = lockedOut, + onCancel = onCancel, + ), + ) } } -/** What the two entries of [ReauthDialog] share: the sub-heading, a running lockout and where the buttons go. */ +/** 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 onSwitch: (() -> Unit)?, val onCancel: () -> Unit, ) -@Composable -private fun ReauthPasswordEntry(reauth: ReauthFlow, entry: ReauthEntry) { - // Never a String once submitted; dropped when the entry goes (see PasswordInput). - val password = remember { PasswordInput() } - DisposableEffect(password) { onDispose { password.clear() } } - PasswordStepScaffold( - text = PasswordStepText( - title = stringResource(Res.string.backup_reauth_title), - subtitle = entry.subtitle, - label = stringResource(Res.string.lock_password_label), - subtitleIsError = entry.subtitleIsError, - ), - input = password, - submit = PasswordStepSubmit(stringResource(Res.string.action_continue)) { - reauth.submitPassword(password.take()) - }, - enabled = !entry.lockedOut, - modifier = Modifier.fillMaxSize(), - footerExtra = { - entry.onSwitch?.let { switch -> - EnclyTextButton( - text = stringResource(Res.string.lock_use_pin), - onClick = { - password.clear() - switch() - }, - modifier = Modifier.fillMaxWidth(), - ) - } - EnclyTextButton( - text = stringResource(Res.string.cancel), - onClick = entry.onCancel, - modifier = Modifier.fillMaxWidth(), - ) - }, - ) -} - @Composable private fun ReauthPinEntry(reauth: ReauthFlow, step: BackupStep.Reauth, entry: ReauthEntry) { val host = LocalBiometricHost.current @@ -163,16 +112,6 @@ private fun ReauthPinEntry(reauth: ReauthFlow, step: BackupStep.Reauth, entry: R onClick = { if (input.canSubmit) reauth.submitPin(input.take()) }, ), ) - entry.onSwitch?.let { switch -> - EnclyTextButton( - text = stringResource(Res.string.lock_use_password), - onClick = { - input.clear() - switch() - }, - modifier = Modifier.padding(top = EnclyTheme.spacing.s), - ) - } EnclyTextButton( text = stringResource(Res.string.cancel), onClick = entry.onCancel, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt deleted file mode 100644 index 758349a3..00000000 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordInput.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.pasich.encly.presentation.screen.pincode - -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import com.pasich.encly.core.security.MAX_PASSWORD_LENGTH -import com.pasich.encly.core.security.MIN_PASSWORD_LENGTH -import com.pasich.encly.presentation.designsystem.EnclyTextField -import com.pasich.encly.presentation.designsystem.FieldState - -/** - * A password being typed. A text field keeps its text as a String, which cannot be wiped (the recovery words - * are no different): it is held here only while the form is up, dropped when the password is taken for the - * check, and the check wipes the CharArray it is given. - */ -@Stable -internal class PasswordInput { - var text by mutableStateOf("") - - /** The characters typed, as Unicode code points (a surrogate pair is one). */ - val length: Int get() = text.count { !it.isLowSurrogate() } - - /** Long enough to be a password. Nothing is submitted by itself: the user presses the button. */ - val canSubmit: Boolean get() = length in MIN_PASSWORD_LENGTH..MAX_PASSWORD_LENGTH - - /** The typed password, taken out of the form; the text is dropped. The caller wipes the copy. */ - fun take(): CharArray = text.toCharArray().also { clear() } - - fun clear() { - text = "" - } -} - -/** A masked password field over [input]; [onDone] is the keyboard's Done action. */ -@Composable -internal fun PasswordField( - input: PasswordInput, - label: String, - modifier: Modifier = Modifier, - fieldModifier: Modifier = Modifier, - enabled: Boolean = true, - error: String? = null, - imeAction: ImeAction = ImeAction.Done, - onEdit: () -> Unit = {}, - onDone: () -> Unit = {}, -) { - EnclyTextField( - value = input.text, - onValueChange = { - input.text = it.take(MAX_PASSWORD_LENGTH * 2) - onEdit() - }, - label = label, - modifier = modifier, - fieldModifier = fieldModifier, - enabled = enabled, - state = if (error != null) FieldState.Error(error) else FieldState.Default, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - autoCorrectEnabled = false, - imeAction = imeAction, - ), - keyboardActions = KeyboardActions(onDone = { onDone() }, onGo = { onDone() }), - visualTransformation = PasswordVisualTransformation(), - ) -} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt deleted file mode 100644 index c6d1242a..00000000 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/pincode/PasswordStepScaffold.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.pasich.encly.presentation.screen.pincode - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.pasich.encly.presentation.designsystem.EnclyButton -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.ui.theme.EnclyTheme - -/** - * A step that asks for the password of the vault (to confirm it is the owner): the title and sub-heading of every - * PIN screen, a masked field and, pinned above the keyboard, the button that sends it. Nothing is sent by itself. - */ -@Composable -internal fun PasswordStepScaffold( - text: PasswordStepText, - input: PasswordInput, - submit: PasswordStepSubmit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - onEdit: () -> Unit = {}, - footerExtra: @Composable ColumnScope.() -> Unit = {}, -) { - PinEntryScaffold( - title = text.title, - subtitle = text.subtitle, - subtitleIsError = text.subtitleIsError, - icon = EnclyIcons.Key, - compact = true, - modifier = modifier, - footer = { - EnclyButton(text = submit.text, onClick = submit.onClick, enabled = enabled && input.canSubmit) - footerExtra() - }, - ) { - PasswordField( - input = input, - label = text.label, - enabled = enabled, - onEdit = onEdit, - onDone = { if (enabled && input.canSubmit) submit.onClick() }, - modifier = Modifier - .padding(horizontal = EnclyTheme.spacing.gutter) - .padding(top = EnclyTheme.spacing.l), - ) - } -} - -/** - * The words of a [PasswordStepScaffold]: its title, the sub-heading (an error or a lockout countdown when - * [subtitleIsError]) and the field's label. - */ -internal class PasswordStepText( - val title: String, - val subtitle: String, - val label: String, - val subtitleIsError: Boolean = false, -) - -/** The button of a [PasswordStepScaffold]. */ -internal class PasswordStepSubmit(val text: String, val onClick: () -> Unit) 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 index d6237b5c..440bf4e5 100644 --- 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 @@ -160,7 +160,6 @@ private fun DuressPinOptions(info: DuressPinInfo, actions: DuressPinActions, mod style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - PinOnlyNote() Text( text = info.statusText(), style = MaterialTheme.typography.titleSmall, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt deleted file mode 100644 index c8d9b5fe..00000000 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordScreen.kt +++ /dev/null @@ -1,460 +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.PaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets -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.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.BackHandler -import androidx.compose.ui.text.input.ImeAction -import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.cancel -import com.pasich.encly.core.resources.lock_password_label -import com.pasich.encly.core.resources.password_change -import com.pasich.encly.core.resources.password_confirm_label -import com.pasich.encly.core.resources.password_current_subtitle -import com.pasich.encly.core.resources.password_current_title -import com.pasich.encly.core.resources.password_current_wrong -import com.pasich.encly.core.resources.password_edit_title -import com.pasich.encly.core.resources.password_failed -import com.pasich.encly.core.resources.password_intro -import com.pasich.encly.core.resources.password_mismatch -import com.pasich.encly.core.resources.password_new_label -import com.pasich.encly.core.resources.password_pin_only_note -import com.pasich.encly.core.resources.password_remove -import com.pasich.encly.core.resources.password_remove_confirm -import com.pasich.encly.core.resources.password_remove_confirm_body -import com.pasich.encly.core.resources.password_remove_confirm_title -import com.pasich.encly.core.resources.password_removed -import com.pasich.encly.core.resources.password_save -import com.pasich.encly.core.resources.password_saved -import com.pasich.encly.core.resources.password_set -import com.pasich.encly.core.resources.password_title -import com.pasich.encly.core.resources.password_too_short -import com.pasich.encly.core.resources.password_turned_off -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_current_wrong -import com.pasich.encly.core.resources.pin_remove -import com.pasich.encly.core.resources.pin_remove_confirm -import com.pasich.encly.core.resources.pin_remove_confirm_body -import com.pasich.encly.core.resources.pin_remove_confirm_title -import com.pasich.encly.core.resources.pin_remove_failed -import com.pasich.encly.core.resources.pin_removed -import com.pasich.encly.core.security.PasswordChange -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.EnclySnackbarHost -import com.pasich.encly.presentation.designsystem.EnclyTextButton -import com.pasich.encly.presentation.designsystem.EnclyTopBar -import com.pasich.encly.presentation.screen.pincode.PasswordField -import com.pasich.encly.presentation.screen.pincode.PasswordInput -import com.pasich.encly.presentation.screen.pincode.PasswordStepScaffold -import com.pasich.encly.presentation.screen.pincode.PasswordStepSubmit -import com.pasich.encly.presentation.screen.pincode.PasswordStepText -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 org.jetbrains.compose.resources.StringResource -import org.jetbrains.compose.resources.stringResource - -/** What the password page asks of the Security ViewModel, so the ViewModel itself stays in [PasswordScreen]. */ -private class PasswordActions( - val verifyPin: (CharArray, (Boolean) -> Unit) -> Unit, - val verifyPassword: (CharArray, (Boolean) -> Unit) -> Unit, - val lockoutMillis: () -> Long, - val setPassword: (CharArray, (PasswordChange) -> Unit) -> Unit, - val removePassword: ((Boolean) -> Unit) -> Unit, - val removePin: ((Boolean) -> Unit) -> Unit, -) - -private enum class PasswordStep { VERIFY, OPTIONS, EDIT, REMOVE_PIN } - -/** - * The state of Settings → Security → Password: who is asking is checked first (the PIN, or the password when the - * vault has no PIN), then the options page, then the new password typed twice. The typed secrets are wiped once - * used and do not outlive the screen (see [PinBuffer], [PasswordInput]). - */ -private class PasswordFlowState(val verifyWithPin: Boolean) { - var step by mutableStateOf(PasswordStep.VERIFY) - val pin = PinBuffer() - val current = PasswordInput() - val newPassword = PasswordInput() - val confirm = PasswordInput() - var errorText by mutableStateOf(null) - var lockoutSeconds by mutableLongStateOf(0L) - - /** Changes on every wrong entry, so the dots shake again. */ - var shakeKey by mutableIntStateOf(0) - var message by mutableStateOf(null) - val snackbarHostState = SnackbarHostState() - - val lockedOut: Boolean get() = lockoutSeconds > 0L - - fun clear() { - pin.clear() - current.clear() - newPassword.clear() - confirm.clear() - } - - /** Back from the edit page (or the PIN removal) to the options; false when there is nothing to go back to here. */ - fun back(): Boolean { - if (step != PasswordStep.EDIT && step != PasswordStep.REMOVE_PIN) return false - newPassword.clear() - confirm.clear() - current.clear() - errorText = null - step = PasswordStep.OPTIONS - return true - } - - /** The PIN removal ended: announce [removed] on the options page. */ - fun pinRemoved(removed: Boolean) { - message = if (removed) Res.string.pin_removed else Res.string.pin_remove_failed - errorText = null - step = PasswordStep.OPTIONS - } - - /** The answer of the check of who is asking. */ - fun onVerified(ok: Boolean, lockoutMillis: Long, wrong: StringResource) { - val lockout = lockoutSecondsLeft(lockoutMillis) - errorText = null - when { - ok -> step = PasswordStep.OPTIONS - - // A locked-out secret is refused even when right: say so, not "wrong". - lockout > 0L -> { - lockoutSeconds = lockout - shakeKey++ - } - - else -> { - errorText = wrong - shakeKey++ - } - } - } - - /** The new password was refused: stay on the edit page with [error]. */ - fun refused(error: StringResource) { - errorText = error - confirm.clear() - } - - fun saved() { - newPassword.clear() - confirm.clear() - errorText = null - message = Res.string.password_saved - step = PasswordStep.OPTIONS - } -} - -/** - * Settings → Security → Password: set, change or remove the password, and remove the PIN. The PIN (or, with no PIN, the - * password) is asked for first, as for every security change. At least one of the two always remains. - */ -@Composable -fun PasswordScreen( - navController: NavHostController, - modifier: Modifier = Modifier, - securityViewModel: SecuritySettingsViewModel = metroViewModel(), -) { - val state = remember { PasswordFlowState(verifyWithPin = securityViewModel.hasPinSlot()) } - val securityState by securityViewModel.uiState.collectAsStateWithLifecycle() - val actions = remember(securityViewModel) { - PasswordActions( - verifyPin = securityViewModel::verifyCurrentPin, - verifyPassword = securityViewModel::verifyCurrentPassword, - lockoutMillis = securityViewModel::pinLockoutRemainingMillis, - setPassword = securityViewModel::setPassword, - removePassword = securityViewModel::removePassword, - removePin = securityViewModel::removePin, - ) - } - PinLockoutTicker(state.lockedOut, actions.lockoutMillis) { state.lockoutSeconds = it } - DisposableEffect(state) { onDispose { state.clear() } } - BackHandler(enabled = state.step == PasswordStep.EDIT || state.step == PasswordStep.REMOVE_PIN) { state.back() } - PinFlowMessageEffect(state.message, state.snackbarHostState, onShow = { state.message = null }) - - Scaffold( - containerColor = MaterialTheme.colorScheme.surface, - topBar = { - EnclyTopBar( - title = stringResource(Res.string.password_title), - onBack = { if (!state.back()) navController.popBackStack() }, - ) - }, - snackbarHost = { EnclySnackbarHost(state.snackbarHostState) }, - modifier = modifier.fillMaxSize(), - ) { paddingValues -> - val content = Modifier.padding(paddingValues).consumeWindowInsets(paddingValues) - when (state.step) { - PasswordStep.VERIFY -> VerifyStep(state, actions, content) - - PasswordStep.OPTIONS -> PasswordOptions( - state = state, - hasPassword = securityState.hasPassword, - hasPin = securityState.hasPinSlot, - turnedOff = securityState.passwordTurnedOff, - actions = actions, - modifier = content, - ) - - PasswordStep.EDIT -> PasswordEdit(state, actions, content) - - PasswordStep.REMOVE_PIN -> RemovePinStep(state, actions, content) - } - } -} - -@Composable -private fun VerifyStep(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { - val error = state.errorText?.let { stringResource(it) } - val lockout = if (state.lockedOut) pinLockoutText(state.lockoutSeconds) else null - if (state.verifyWithPin) { - val submit = { - if (state.pin.canSubmit && !state.lockedOut) { - actions.verifyPin(state.pin.take()) { ok -> - state.onVerified(ok, actions.lockoutMillis(), Res.string.pin_current_wrong) - } - } - } - PinEntryScaffold( - title = stringResource(Res.string.pin_change_current_title), - subtitle = lockout ?: error ?: stringResource(Res.string.pin_change_current_subtitle), - subtitleIsError = lockout != null || error != null, - compact = true, - bodyAtBottom = true, - modifier = modifier, - ) { - PinEntry( - entered = state.pin.length, - error = error != null && state.pin.length == 0, - shakeKey = state.shakeKey, - enabled = !state.lockedOut, - actions = PinEntryActions( - onDigit = { digit -> - state.errorText = null - state.pin.add(digit) - }, - onBackspace = state.pin::deleteLast, - ), - submit = PinSubmit( - text = stringResource(Res.string.action_continue), - enabled = state.pin.canSubmit, - onClick = submit, - ), - ) - } - } else { - val submit = { - actions.verifyPassword(state.current.take()) { ok -> - state.onVerified(ok, actions.lockoutMillis(), Res.string.password_current_wrong) - } - } - PasswordStepScaffold( - text = PasswordStepText( - title = stringResource(Res.string.password_current_title), - subtitle = lockout ?: error ?: stringResource(Res.string.password_current_subtitle), - label = stringResource(Res.string.lock_password_label), - subtitleIsError = lockout != null || error != null, - ), - input = state.current, - submit = PasswordStepSubmit(stringResource(Res.string.action_continue), submit), - enabled = !state.lockedOut, - onEdit = { state.errorText = null }, - modifier = modifier, - ) - } -} - -@Composable -private fun PasswordEdit(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { - val spacing = EnclyTheme.spacing - val canSave = state.newPassword.canSubmit && state.confirm.canSubmit - val save = { - if (canSave) { - if (state.newPassword.text != state.confirm.text) { - state.refused(Res.string.password_mismatch) - } else { - state.confirm.clear() - actions.setPassword(state.newPassword.take()) { result -> - when (result) { - PasswordChange.SET -> state.saved() - PasswordChange.INVALID -> state.refused(Res.string.password_too_short) - PasswordChange.FAILED -> state.refused(Res.string.password_failed) - } - } - } - } - } - EnclyScrollColumn( - verticalArrangement = Arrangement.spacedBy(spacing.m), - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), - ) { - Text( - text = stringResource(Res.string.password_edit_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - ) - PasswordField( - input = state.newPassword, - label = stringResource(Res.string.password_new_label), - imeAction = ImeAction.Next, - onEdit = { state.errorText = null }, - ) - PasswordField( - input = state.confirm, - label = stringResource(Res.string.password_confirm_label), - error = state.errorText?.let { stringResource(it) }, - onEdit = { state.errorText = null }, - onDone = save, - ) - EnclyButton( - text = stringResource(Res.string.password_save), - onClick = save, - enabled = canSave, - modifier = Modifier.padding(top = spacing.s), - ) - } -} - -@Composable -private fun PasswordOptions( - state: PasswordFlowState, - hasPassword: Boolean, - hasPin: Boolean, - turnedOff: Boolean, - actions: PasswordActions, - modifier: Modifier = Modifier, -) { - val spacing = EnclyTheme.spacing - var confirmingRemove by rememberSaveable { mutableStateOf(false) } - if (confirmingRemove) { - RemovePasswordDialog( - onConfirm = { - confirmingRemove = false - actions.removePassword { ok -> - state.message = if (ok) Res.string.password_removed else Res.string.password_failed - } - }, - onDismiss = { confirmingRemove = false }, - ) - } - EnclyScrollColumn( - verticalArrangement = Arrangement.spacedBy(spacing.m), - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = spacing.gutter, vertical = spacing.s), - ) { - if (turnedOff && !hasPassword) { - EnclyCallout(text = stringResource(Res.string.password_turned_off), tone = CalloutTone.WARNING) - } - Text( - text = stringResource(Res.string.password_intro), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - EnclyCallout(text = stringResource(Res.string.password_pin_only_note)) - Column( - verticalArrangement = Arrangement.spacedBy(spacing.xxs), - modifier = Modifier.fillMaxWidth().padding(top = spacing.s), - ) { - EnclyButton( - text = stringResource(if (hasPassword) Res.string.password_change else Res.string.password_set), - onClick = { state.step = PasswordStep.EDIT }, - ) - // The last way in stays: each of the two can go only while the other is set. - if (hasPassword && hasPin) { - RemoveButton(Res.string.password_remove) { confirmingRemove = true } - RemoveButton(Res.string.pin_remove) { state.step = PasswordStep.REMOVE_PIN } - } - } - } -} - -@Composable -private fun RemoveButton(text: StringResource, onClick: () -> Unit) { - EnclyTextButton( - text = stringResource(text), - onClick = onClick, - destructive = true, - modifier = Modifier.fillMaxWidth(), - ) -} - -@Composable -private fun RemovePasswordDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - EnclyDialog( - title = stringResource(Res.string.password_remove_confirm_title), - text = stringResource(Res.string.password_remove_confirm_body), - onDismissRequest = onDismiss, - confirm = DialogAction(stringResource(Res.string.password_remove_confirm), onConfirm, destructive = true), - dismiss = DialogAction(stringResource(Res.string.cancel), onDismiss), - ) -} - -/** - * Turning the PIN off needs the password typed again: it proves the password opens the vault before the PIN, the - * other way in, is gone. The wipe and duress PINs go with the PIN. - */ -@Composable -private fun RemovePinStep(state: PasswordFlowState, actions: PasswordActions, modifier: Modifier = Modifier) { - val error = state.errorText?.let { stringResource(it) } - val lockout = if (state.lockedOut) pinLockoutText(state.lockoutSeconds) else null - val submit = { - actions.verifyPassword(state.current.take()) { ok -> - if (ok) { - actions.removePin(state::pinRemoved) - } else { - state.onVerified(false, actions.lockoutMillis(), Res.string.password_current_wrong) - } - } - } - PasswordStepScaffold( - text = PasswordStepText( - title = stringResource(Res.string.pin_remove_confirm_title), - subtitle = lockout ?: error ?: stringResource(Res.string.pin_remove_confirm_body), - label = stringResource(Res.string.lock_password_label), - subtitleIsError = lockout != null || error != null, - ), - input = state.current, - submit = PasswordStepSubmit(stringResource(Res.string.pin_remove_confirm), submit), - enabled = !state.lockedOut, - onEdit = { state.errorText = null }, - modifier = modifier, - ) -} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt deleted file mode 100644 index 5de85d85..00000000 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PasswordSections.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.pasich.encly.presentation.screen.settings - -import androidx.compose.foundation.layout.padding -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.auth_method_pin_desc -import com.pasich.encly.core.resources.auth_method_pin_none -import com.pasich.encly.core.resources.auth_method_pin_title -import com.pasich.encly.core.resources.password_pin_only_note -import com.pasich.encly.core.resources.password_row_off -import com.pasich.encly.core.resources.password_row_on -import com.pasich.encly.core.resources.password_row_turned_off -import com.pasich.encly.core.resources.password_title -import com.pasich.encly.presentation.designsystem.EnclyIcons -import com.pasich.encly.presentation.designsystem.EnclyNavigationRow -import com.pasich.encly.ui.theme.EnclyTheme -import org.jetbrains.compose.resources.stringResource - -/** "PIN": change it, or add one to a vault that has only a password. */ -@Composable -internal fun PinRow(hasPin: Boolean, onClick: () -> Unit) { - EnclyNavigationRow( - title = stringResource(Res.string.auth_method_pin_title), - supporting = stringResource( - if (hasPin) Res.string.auth_method_pin_desc else Res.string.auth_method_pin_none, - ), - icon = EnclyIcons.Key, - onClick = onClick, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) -} - -/** "Password": another way to unlock; after a key reset it says that it was turned off. */ -@Composable -internal fun PasswordRow(hasPassword: Boolean, turnedOff: Boolean, onClick: () -> Unit) { - EnclyNavigationRow( - title = stringResource(Res.string.password_title), - supporting = stringResource( - when { - hasPassword -> Res.string.password_row_on - turnedOff -> Res.string.password_row_turned_off - else -> Res.string.password_row_off - }, - ), - icon = EnclyIcons.Lock, - onClick = onClick, - modifier = Modifier.padding(horizontal = EnclyTheme.spacing.s), - ) -} - -/** The wipe PIN and the duress PIN can only be PINs: a password is never one of them. */ -@Composable -internal fun PinOnlyNote() { - Text( - text = stringResource(Res.string.password_pin_only_note), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt index 8572d414..009bd264 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/settings/PinFlow.kt @@ -213,7 +213,7 @@ internal fun PinFlowScaffold( /** Shows [message] once as a snackbar; [onShow] clears it. */ @Composable -internal fun PinFlowMessageEffect(message: StringResource?, 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. 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 index dcf41a73..6212ffc2 100644 --- 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 @@ -21,6 +21,8 @@ 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 @@ -38,6 +40,7 @@ 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 @@ -196,12 +199,12 @@ private fun SecurityRows( modifier = Modifier.padding(top = EnclyTheme.spacing.s), ) EnclyGroup { - PinRow(hasPin = securityState.hasPinSlot, onClick = { actions.onOpen(NavRoutes.PinCodeConfig) }) - EnclyGroupDivider() - PasswordRow( - hasPassword = securityState.hasPassword, - turnedOff = securityState.passwordTurnedOff, - onClick = { actions.onOpen(NavRoutes.PasswordRoute) }, + 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() 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 index 119fd154..71ae3463 100644 --- 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 @@ -152,7 +152,6 @@ private fun WipePinOptions(info: WipePinInfo, actions: WipePinActions, modifier: style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - PinOnlyNote() Column( verticalArrangement = Arrangement.spacedBy(spacing.xxs), modifier = Modifier.fillMaxWidth().padding(top = spacing.s), diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index eb4c58c8..2db29eba 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlows.kt @@ -12,12 +12,10 @@ 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_password 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.core.security.UnlockMethods import com.pasich.encly.data.backup.BackupDocuments import com.pasich.encly.data.backup.BackupManager import com.pasich.encly.data.backup.BackupPhraseSetup @@ -50,16 +48,14 @@ sealed interface BackupStep { data object Idle : BackupStep /** - * Re-authentication (PIN or password, or biometric when enabled) before any export or import. [failures] - * counts wrong secrets, so each one shakes the dots, even with the same error. + * Re-authentication (PIN, or biometric when enabled) before any export or import. [failures] + * counts wrong PINs, so each one shakes the dots, even with the same error. */ data class Reauth( val action: BackupAction, val biometric: Boolean, val error: StringResource? = null, val failures: Int = 0, - /** Which secrets the vault has: the dialog asks for the PIN, or the password, or lets the user pick. */ - val methods: UnlockMethods = UnlockMethods(pin = true, password = false), ) : BackupStep /** Export needs a recovery phrase and this vault has none: explain and offer to create it. */ @@ -183,7 +179,7 @@ internal fun withNormalizedPhrase(backupManager: BackupManager, input: CharA } /** - * Re-authentication before any export or import: the PIN or the password, or biometric when enabled. + * Re-authentication before any export or import: the PIN, or biometric when enabled. * [onAuthenticated] continues the flow that was started. */ class ReauthFlow( @@ -221,24 +217,6 @@ class ReauthFlow( } } - /** [submitPin] for a password; counted with the PIN's attempts and locked out with them. */ - fun submitPassword(password: CharArray) { - val step = state.step as? BackupStep.Reauth - if (step == null || pinLockoutRemainingMillis() > 0L) { - SensitiveDataCleaner.clear(password) - if (step != null) state.go(step.copy(error = null)) - return - } - state.launchBusy { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPassword(password) } - if (ok) { - onAuthenticated(step.action) - } else { - state.go(step.copy(error = Res.string.lock_wrong_password, failures = step.failures + 1)) - } - } - } - fun withBiometric(host: ScreenBiometricHost) { val step = state.step as? BackupStep.Reauth ?: return securityManager.confirmBiometric(host.biometric) { ok -> diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt index 7e61fb0f..e1777ce8 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/BackupViewModel.kt @@ -77,7 +77,7 @@ class BackupViewModel( this.action = action reauthGeneration = sessionLockManager.sessionGeneration val biometric = securityManager.canReauthWithBiometric() - state.go(BackupStep.Reauth(action, biometric, methods = securityManager.unlockMethods())) + state.go(BackupStep.Reauth(action, biometric)) } private suspend fun proceed(action: BackupAction) { 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 index 4848a6ab..95b5e010 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModel.kt @@ -9,7 +9,6 @@ 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.UnlockMethods import com.pasich.encly.core.security.VaultUnlockResult import com.pasich.encly.platform.ScreenBiometricHost import dev.zacsweers.metro.AppScope @@ -30,13 +29,15 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * The result of a PIN or a password attempt (WRONG_PIN: the wrong secret of either kind). + * 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 key is gone; only the recovery phrase (or fingerprint) unlocks. + * 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 } -enum class SeedUnlockResult { SUCCESS, WRONG_SEED, DB_ERROR, BACKGROUNDED } +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 @@ -83,9 +84,6 @@ class LockViewModel( */ fun recoveryStage(): RecoveryStage = securityManager.recoveryStage() - /** Which of the PIN and the password this vault has; the lock screen offers each that is set. */ - fun unlockMethods(): UnlockMethods = securityManager.unlockMethods() - /** Whether the session is closed (again); an unlock reveal then must not open Home. */ fun isSessionLocked(): Boolean = sessionLockManager.locked.value @@ -110,23 +108,12 @@ class LockViewModel( } // 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 [password], which is wiped. Counted and locked out together with the PIN. */ - fun authenticatePassword(password: CharArray, onResult: (PinUnlockResult) -> Unit) { - launchUnlock(onResult) { - val result = withContext(Dispatchers.Default) { - when (securityManager.unlockWithPassword(password)) { - 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 - } - } - publish(result == PinUnlockResult.SUCCESS, result, PinUnlockResult.BACKGROUNDED) + publish( + result == PinUnlockResult.SUCCESS, + result, + PinUnlockResult.BACKGROUNDED, + PinUnlockResult.DEVICE_LOCKED, + ) } } @@ -147,7 +134,12 @@ class LockViewModel( SensitiveDataCleaner.clear(chars) } } - publish(result == SeedUnlockResult.SUCCESS, result, SeedUnlockResult.BACKGROUNDED) + publish( + result == SeedUnlockResult.SUCCESS, + result, + SeedUnlockResult.BACKGROUNDED, + SeedUnlockResult.DEVICE_LOCKED, + ) } } @@ -175,7 +167,7 @@ class LockViewModel( SensitiveDataCleaner.clear(dek) } } - publish(ok, ok, false) + publish(ok, ok, false, false) } } } @@ -206,9 +198,10 @@ class LockViewModel( 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]. + * 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): T = when { + fun publish(succeeded: Boolean, result: T, backgrounded: T, deviceLocked: T): T = when { !succeeded -> result !owner.isActive -> { @@ -218,6 +211,8 @@ class LockViewModel( sessionLockManager.onUnlocked(lockRequests) -> result + sessionLockManager.isDeviceLocked() -> deviceLocked + else -> backgrounded } } 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 index e48576ff..1a53b93c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -123,7 +123,7 @@ class MarkdownExportViewModel( 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, methods = securityManager.unlockMethods())) + state.go(BackupStep.Reauth(BackupAction.EXPORT_MARKDOWN, biometric)) } private fun proceed(action: BackupAction) { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt index df75b66f..6b8e6d01 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModel.kt @@ -15,7 +15,6 @@ import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.KeyboardPrivacy -import com.pasich.encly.core.security.PasswordChange import com.pasich.encly.core.security.PinChange import com.pasich.encly.core.security.ScreenshotAllowance import com.pasich.encly.core.security.SecurityManager @@ -86,8 +85,6 @@ class SecuritySettingsViewModel( biometricEnable = authSettings.isBiometricEnabled, biometricStatus = securityManager.biometricStatus(), hasPinSlot = authSettings.hasPinSlot, - hasPassword = authSettings.hasPassword, - passwordTurnedOff = authSettings.passwordTurnedOff, wipePinTurnedOff = authSettings.wipePinTurnedOff, hasDuressPin = authSettings.hasDuressPin, duressPinTurnedOff = authSettings.duressPinTurnedOff, @@ -104,53 +101,6 @@ class SecuritySettingsViewModel( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() - /** The vault has a PIN to ask for; without one (only a password) re-authentication asks for the password. */ - fun hasPinSlot(): Boolean = securityManager.unlockMethods().pin - - /** Checks [password] against the vault, counted with the PIN's attempts; [password] is wiped. */ - fun verifyCurrentPassword(password: CharArray, onResult: (Boolean) -> Unit) { - viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPassword(password) } - onResult(ok) - } - } - - /** Makes [password] the vault's password, replacing any earlier one; [password] is wiped. */ - fun setPassword(password: CharArray, onResult: (PasswordChange) -> Unit) { - viewModelScope.launch { - val result = withContext(Dispatchers.Default) { securityManager.changePassword(password) } - if (result == PasswordChange.SET) { - _uiState.value = _uiState.value.copy(hasPassword = true, passwordTurnedOff = false) - } - onResult(result) - } - } - - /** Turns the password off; false while it is the only way in. */ - fun removePassword(onResult: (Boolean) -> Unit) { - viewModelScope.launch { - val ok = withContext(platformIoDispatcher) { securityManager.removePassword() } - if (ok) _uiState.value = _uiState.value.copy(hasPassword = false, passwordTurnedOff = false) - onResult(ok) - } - } - - /** - * Turns the PIN off (and with it the wipe and duress PINs); false while it is the only way in. The password - * was just checked by the screen, which proves it still opens the vault. - */ - fun removePin(onResult: (Boolean) -> Unit) { - viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.removePin() } - if (ok) { - _uiState.value = _uiState.value.copy(hasPinSlot = false) - // The wipe and duress PINs went with it; the decoy vault's notes stay for a new duress PIN. - refresh() - } - onResult(ok) - } - } - /** Checks [pin] against the vault; [pin] is wiped. */ fun verifyCurrentPin(pin: CharArray, onResult: (Boolean) -> Unit) { viewModelScope.launch { @@ -285,10 +235,6 @@ class SecuritySettingsViewModel( val biometricStatus: BiometricStatus = BiometricStatus.UNAVAILABLE, /** A PIN slot exists, so a wipe PIN can be set next to it. */ val hasPinSlot: Boolean = false, - /** A password is set, as another way in or the only one. */ - val hasPassword: Boolean = false, - /** The device key was reset and the password with it; setting it again brings it back. */ - val passwordTurnedOff: Boolean = false, /** The PIN key was reset and any wipe PIN with it; Settings asks to set it again. */ val wipePinTurnedOff: Boolean = false, /** The real vault has a duress PIN. */ 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 index cb3ec1dd..1ddeaa37 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/LockFormStateTest.kt @@ -1,8 +1,8 @@ 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_password 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 @@ -74,6 +74,16 @@ class LockFormStateTest { 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) @@ -132,71 +142,4 @@ class LockFormStateTest { assertEquals(0, form.pinLength) assertEquals(8, form.submittedLength) } - - @Test - fun aWrongPasswordShowsItsErrorAndKeepsThePinPadClean() { - form.usePassword = true - form.password.text = "not the password" - - form.onPasswordResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 0L) - - assertEquals(Res.string.lock_wrong_password, form.passwordError) - assertNull(form.pinError) - form.onPasswordEdited() - assertNull(form.passwordError) - } - - @Test - fun aPasswordFailureStartsTheSameLockoutCountdown() { - form.onPasswordResult(PinUnlockResult.WRONG_PIN, lockoutRemainingMillis = 30_000L) - - assertEquals(30L, form.lockoutSeconds) - assertTrue(form.lockedOut) - } - - @Test - fun aLostKeyExplainsItOnThePasswordFormAndTheRecoveryForm() { - form.onPasswordResult(PinUnlockResult.KEY_LOST, lockoutRemainingMillis = 0L) - - assertEquals(Res.string.lock_pin_key_lost, form.passwordError) - assertEquals(Res.string.lock_pin_key_lost, form.phraseError) - } - - @Test - fun switchingMethodsDropsWhatWasTyped() { - typePin("123456") - form.switchTo(usePassword = true) - - assertTrue(form.usePassword) - assertEquals(0, form.pinLength) - - form.password.text = "correct horse" - form.switchTo(usePassword = false) - - assertFalse(form.usePassword) - assertEquals("", form.password.text) - } - - @Test - fun thePasswordIsTakenOutOfTheFormToBeChecked() { - form.password.text = "correct horse" - assertTrue(form.password.canSubmit) - - val taken = form.takePassword() - - assertEquals("correct horse", String(taken)) - assertEquals("", form.password.text) - assertFalse(form.password.canSubmit) - } - - @Test - fun aPasswordNeedsEightCharactersCountedAsCodePoints() { - form.password.text = "1234567" - assertFalse(form.password.canSubmit) - - form.password.text = "1234567\uD83D\uDD11" - - assertEquals(8, form.password.length) - assertTrue(form.password.canSubmit) - } } 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 index 5e42257c..835039b8 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/LockViewModelTest.kt @@ -7,10 +7,10 @@ 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.UnlockMethods import com.pasich.encly.core.security.VaultUnlockResult import com.pasich.encly.testutil.TestScreen import com.pasich.encly.testutil.answerCallback @@ -113,6 +113,29 @@ class LockViewModelTest { 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) @@ -137,51 +160,6 @@ class LockViewModelTest { verify(security).keepUnlockMethodsAfterRecovery() } - @Test - fun theLockScreenAsksWhichMethodsTheVaultHas() { - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = true, password = true)) - - assertEquals(UnlockMethods(pin = true, password = true), viewModel.unlockMethods()) - } - - @Test - fun theRightPasswordUnlocks() = runTest { - `when`(security.unlockWithPassword(PASSWORD.toCharArray())).thenReturn(VaultUnlockResult.SUCCESS) - - assertEquals(PinUnlockResult.SUCCESS, password()) - assertFalse(viewModel.busy.value) - assertFalse(sessionLock.locked.value) - verify(security, never()).unlockWithPin(anyCharArray()) - } - - @Test - fun aPasswordResultIsToldApartLikeAPinResult() = runTest { - `when`(security.unlockWithPassword(PASSWORD.toCharArray())) - .thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) - .thenReturn(VaultUnlockResult.LOCKED_OUT) - .thenReturn(VaultUnlockResult.PIN_KEY_LOST) - .thenReturn(VaultUnlockResult.DB_ERROR) - - assertEquals(PinUnlockResult.WRONG_PIN, password()) - assertEquals(PinUnlockResult.LOCKED_OUT, password()) - assertEquals(PinUnlockResult.KEY_LOST, password()) - assertEquals(PinUnlockResult.DB_ERROR, password()) - } - - @Test - fun aPasswordUnlockThatLandsInTheBackgroundIsClosedAgain() = runTest { - `when`(security.isLockable()).thenReturn(true) - doAnswer { - sessionLock.lockNow() - VaultUnlockResult.SUCCESS - }.`when`(security).unlockWithPassword(PASSWORD.toCharArray()) - - assertEquals(PinUnlockResult.BACKGROUNDED, password()) - - verify(security).lock() - assertTrue(sessionLock.locked.value) - } - @Test fun aWrongPinAndABrokenVaultAreToldApart() = runTest { `when`(security.unlockWithPin(PIN.toCharArray())).thenReturn(VaultUnlockResult.INVALID_CREDENTIAL) @@ -458,12 +436,6 @@ class LockViewModelTest { return result.await() } - private suspend fun password(): PinUnlockResult { - val result = CompletableDeferred() - viewModel.authenticatePassword(PASSWORD.toCharArray()) { result.complete(it) } - return result.await() - } - private suspend fun seed(words: CharArray): SeedUnlockResult { val result = CompletableDeferred() viewModel.authenticateSeed(words) { result.complete(it) } @@ -478,7 +450,6 @@ class LockViewModelTest { private companion object { const val PIN = "482915" - const val PASSWORD = "correct horse battery" 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 diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt index d0bb7dd6..a1f95a7f 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/SecuritySettingsViewModelTest.kt @@ -12,11 +12,9 @@ import com.pasich.encly.core.security.AutoLock import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.DuressPinChange import com.pasich.encly.core.security.KeyboardPrivacy -import com.pasich.encly.core.security.PasswordChange 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.UnlockMethods import com.pasich.encly.core.security.WipePinChange import com.pasich.encly.testutil.InMemoryKeyValueStore import com.pasich.encly.testutil.TestScreen @@ -210,116 +208,6 @@ class SecuritySettingsViewModelTest { assertEquals(LOCKOUT_MS, viewModel.pinLockoutRemainingMillis()) } - @Test - fun thePasswordStateIsReadFromTheVaultAndPasswordOnlyHasNoPinToAskFor() = runTest { - `when`(security.getSettingsAuth()).thenReturn( - AuthSettings( - AuthType.PIN, - false, - false, - hasPinSlot = false, - hasPassword = true, - passwordTurnedOff = false, - ), - ) - `when`(security.unlockMethods()).thenReturn(UnlockMethods(pin = false, password = true)) - - viewModel.refresh() - - val state = viewModel.uiState.first { it.hasPassword } - assertFalse(state.hasPinSlot) - assertFalse(viewModel.hasPinSlot()) - } - - @Test - fun aPasswordTurnedOffByAKeyResetIsReported() = runTest { - `when`(security.getSettingsAuth()).thenReturn( - AuthSettings(AuthType.PIN, false, false, passwordTurnedOff = true), - ) - - viewModel.refresh() - - assertTrue(viewModel.uiState.first { it.passwordTurnedOff }.passwordTurnedOff) - } - - @Test - fun theCurrentPasswordIsCheckedAndWipedLikeAPin() = runTest { - `when`(security.verifyPassword(anyCharArray())).thenReturn(true) - val typed = "correct horse".toCharArray() - val result = CompletableDeferred() - - viewModel.verifyCurrentPassword(typed) { result.complete(it) } - - assertTrue(result.await()) - verify(security).verifyPassword(anyCharArray()) - } - - @Test - fun aNewPasswordIsHandedOnAndMarksThePasswordAsSet() = runTest { - `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.SET) - val result = CompletableDeferred() - - viewModel.setPassword("correct horse".toCharArray()) { result.complete(it) } - - assertEquals(PasswordChange.SET, result.await()) - assertTrue(viewModel.uiState.value.hasPassword) - assertFalse(viewModel.uiState.value.passwordTurnedOff) - } - - @Test - fun aRefusedPasswordLeavesTheStateAlone() = runTest { - `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.INVALID) - val result = CompletableDeferred() - - viewModel.setPassword("short".toCharArray()) { result.complete(it) } - - assertEquals(PasswordChange.INVALID, result.await()) - assertFalse(viewModel.uiState.value.hasPassword) - } - - @Test - fun removingThePasswordUpdatesTheState() = runTest { - `when`(security.changePassword(anyCharArray())).thenReturn(PasswordChange.SET) - `when`(security.removePassword()).thenReturn(true) - viewModel.setPassword("correct horse".toCharArray()) {} - val result = CompletableDeferred() - - viewModel.removePassword { result.complete(it) } - - assertTrue(result.await()) - assertFalse(viewModel.uiState.value.hasPassword) - } - - @Test - fun theLastWayInIsReportedAsNotRemoved() = runTest { - `when`(security.removePassword()).thenReturn(false) - `when`(security.removePin()).thenReturn(false) - val password = CompletableDeferred() - val pin = CompletableDeferred() - - viewModel.removePassword { password.complete(it) } - viewModel.removePin { pin.complete(it) } - - assertFalse(password.await()) - assertFalse(pin.await()) - } - - @Test - fun removingThePinReloadsWhatItTookWithIt() = runTest { - `when`(security.removePin()).thenReturn(true) - `when`(security.getSettingsAuth()).thenReturn( - AuthSettings(AuthType.PIN, false, false, hasPinSlot = false, hasPassword = true, duressPinTurnedOff = true), - ) - val result = CompletableDeferred() - - viewModel.removePin { result.complete(it) } - - assertTrue(result.await()) - val state = viewModel.uiState.first { it.duressPinTurnedOff } - assertFalse(state.hasPinSlot) - assertFalse(state.hasDuressPin) - } - @Test fun aWipePinIsHandedOnAndClearsTheTurnedOffNotice() = runTest { `when`(security.getSettingsAuth()).thenReturn( From be1b9cba59f24834dcb144dfbc9440d8a9d6bae3 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 19:16:31 +0200 Subject: [PATCH 58/75] fix(editor): cut and paste keeps blocks, faster lists and tables, locked 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 --- editor/api/android/editor.api | 7 + editor/api/desktop/editor.api | 7 + editor/api/editor.klib.api | 4 + .../encly/editor/ui/CopiedPasteUiTest.kt | 80 ++++++ .../encly/editor/ui/TablesAndCodeUiTest.kt | 92 +++++- .../encly/editor/codec/BlockConverter.kt | 10 +- .../pasich/encly/editor/codec/BlockJson.kt | 2 +- .../pasich/encly/editor/model/TableData.kt | 10 +- .../encly/editor/state/BlockEditorState.kt | 23 ++ .../pasich/encly/editor/state/BlockTools.kt | 6 +- .../pasich/encly/editor/state/ListEditing.kt | 25 +- .../pasich/encly/editor/state/ListNesting.kt | 5 +- .../pasich/encly/editor/state/PasteEditing.kt | 94 +++++-- .../encly/editor/state/TextRangeSelection.kt | 61 +++- .../pasich/encly/editor/ui/BlockActions.kt | 7 + .../encly/editor/ui/BlockActionsImpl.kt | 3 + .../encly/editor/ui/EditorController.kt | 7 + .../encly/editor/ui/blocks/BlockInput.kt | 45 ++- .../encly/editor/ui/blocks/ListBlock.kt | 43 +-- .../encly/editor/ui/blocks/ListReorder.kt | 2 +- .../encly/editor/ui/blocks/TableBlock.kt | 174 ++++++++---- .../encly/editor/codec/BlockJsonFormatTest.kt | 61 ++++ .../editor/codec/TablesAndCodeCodecTest.kt | 11 +- .../encly/editor/model/TableDataTest.kt | 10 +- .../encly/editor/state/CopiedBlocksTest.kt | 202 +++++++++++++ .../editor/state/EditorFeatureFixesTest.kt | 266 ++++++++++++++++++ .../editor/state/TablesAndCodeStateTest.kt | 13 +- .../pasich/encly/editor/ui/StateController.kt | 4 + .../encly/editor/ui/blocks/CellValuesTest.kt | 45 +++ .../editor/persistence/NotePersistence.kt | 3 +- .../editnote/EditNoteViewModelController.kt | 12 +- .../screen/editnote/EditorSelection.kt | 39 ++- .../viewmodel/EditNoteViewModel.kt | 16 ++ .../editor/persistence/NotePersistenceTest.kt | 11 + .../EditNoteViewModelSelectionTest.kt | 28 ++ 35 files changed, 1276 insertions(+), 152 deletions(-) create mode 100644 editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/CopiedPasteUiTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/CopiedBlocksTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/state/EditorFeatureFixesTest.kt create mode 100644 editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/blocks/CellValuesTest.kt diff --git a/editor/api/android/editor.api b/editor/api/android/editor.api index 90bd7e85..2e24bf83 100644 --- a/editor/api/android/editor.api +++ b/editor/api/android/editor.api @@ -483,6 +483,7 @@ public final class com/pasich/encly/editor/state/BlockEditorState { 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 @@ -679,6 +680,10 @@ public final class com/pasich/encly/editor/state/NoteTemplatesKt { 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 @@ -884,6 +889,7 @@ public final class com/pasich/encly/editor/state/TextRangeSelection { } 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; @@ -1105,6 +1111,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 diff --git a/editor/api/desktop/editor.api b/editor/api/desktop/editor.api index 90bd7e85..2e24bf83 100644 --- a/editor/api/desktop/editor.api +++ b/editor/api/desktop/editor.api @@ -483,6 +483,7 @@ public final class com/pasich/encly/editor/state/BlockEditorState { 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 @@ -679,6 +680,10 @@ public final class com/pasich/encly/editor/state/NoteTemplatesKt { 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 @@ -884,6 +889,7 @@ public final class com/pasich/encly/editor/state/TextRangeSelection { } 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; @@ -1105,6 +1111,7 @@ public abstract interface class com/pasich/encly/editor/ui/EditorController { 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 diff --git a/editor/api/editor.klib.api b/editor/api/editor.klib.api index 4363fcd3..5a8f4f2e 100644 --- a/editor/api/editor.klib.api +++ b/editor/api/editor.klib.api @@ -278,6 +278,7 @@ abstract interface com.pasich.encly.editor.ui/EditorController { // com.pasich.e 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] @@ -481,6 +482,7 @@ final class com.pasich.encly.editor.state/BlockEditorState { // com.pasich.encly 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] @@ -1240,6 +1242,7 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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] @@ -1255,6 +1258,7 @@ final fun (com.pasich.encly.editor.state/BlockEditorState).com.pasich.encly.edit 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] 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/TablesAndCodeUiTest.kt b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt index d1830cda..0e232f1a 100644 --- a/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt +++ b/editor/src/androidHostTest/kotlin/com/pasich/encly/editor/ui/TablesAndCodeUiTest.kt @@ -20,6 +20,7 @@ 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 @@ -60,6 +61,8 @@ class TablesAndCodeUiTest { 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) { @@ -140,19 +143,98 @@ class TablesAndCodeUiTest { fields()[4].assertIsFocused() } + private fun shiftTab(index: Int) { + fields()[index].performKeyInput { + keyDown(Key.ShiftLeft) + pressKey(Key.Tab) + keyUp(Key.ShiftLeft) + } + idle() + } + @Test - fun shiftTabInTheFirstCellLeavesItToTheFocusSystem() { + fun shiftTabInTheFirstCellStaysInTheTable() { show(table(listOf("a", "b"))) fields()[0].performClick() - fields()[0].performKeyInput { - keyDown(Key.ShiftLeft) + 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.ShiftLeft) + keyUp(Key.CtrlLeft) } idle() - assertEquals(1, state.blocks.size) + 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 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 index 4f65a641..897a9c35 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockConverter.kt @@ -13,16 +13,20 @@ 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. Blank - * blocks at the end (the placeholder paragraph a fresh note starts with) are dropped. + * 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.hasContent() }.filter { it.hasContent() || it is Block.TextBlock } + 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 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 index 18544823..35082aa4 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/codec/BlockJson.kt @@ -179,7 +179,7 @@ internal object BlockJson { } return Block.ListBlock( items = MutableStateFlow( - if (stored == null) listOf(ItemListBlock("")) else stored.normalizedLevels(listType), + if (stored.isNullOrEmpty()) listOf(ItemListBlock("")) else stored.normalizedLevels(listType), ), blockType = listType, // Optional, absent in notes written before checklists could be locked. 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 index e21bd6cd..fd242286 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/model/TableData.kt @@ -12,14 +12,20 @@ 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. + * 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.firstOrNull()?.let { it.cells.size } ?: 0 + 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 = 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 index 3f56083a..a2e11cb2 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockEditorState.kt @@ -63,6 +63,9 @@ public class BlockEditorState( /** 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. */ @@ -90,12 +93,29 @@ public class BlockEditorState( 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 @@ -280,3 +300,6 @@ private fun BlockSelection.keepFocus(blocks: List, blockId: String) { 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/BlockTools.kt b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt index f6da2199..7e8fc6ce 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/BlockTools.kt @@ -28,7 +28,11 @@ public fun BlockEditorState.focusWorkingBlock() { 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 } - selection.focusAt(target, cursorToEnd = true, caret = caret) + // 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() } 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 index db248fe6..be00c7f8 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListEditing.kt @@ -16,7 +16,11 @@ import kotlinx.coroutines.flow.MutableStateFlow */ 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 listPaste(item, lineBreak) + 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]. */ @@ -29,17 +33,26 @@ internal fun BlockEditorState.itemAt(listId: String, itemId: String): ListItemAt 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 { +/** + * 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 = lineBreak.lines.drop(1) - val kept = lineBreak.head + lineBreak.lines.first() + 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 + lineBreak.tail) + 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) 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 index a9c03879..5d666f82 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt @@ -10,13 +10,14 @@ 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. +// 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) ?: return null + 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) } 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 index 95c60c9a..5e724b91 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/PasteEditing.kt @@ -36,39 +36,85 @@ internal fun BlockEditorState.pasteLines(index: Int, block: TextualBlock, lineBr // 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) + mapped.isEmpty() || mapped.singleOrNull() is Block.TextBlock -> + pasteParagraph(PasteSite(index, block, lineBreak.head, lineBreak.tail), pasted) - else -> pasteBlocks(index, block, lineBreak, pasted, mapped) + 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( - index: Int, - block: TextualBlock, - lineBreak: LineBreak, - pasted: List, -): String { +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 = lineBreak.head + text + lineBreak.tail + val kept = head + text + tail batch { setValue(blocks[index].id, block.text, kept) } - selection.focusAt(index, caret = lineBreak.head.length + text.length) + selection.focusAt(index, caret = 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 +/** + * 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 lead = when (first) { + val before = when (first) { null -> head - else -> head + pasted.first().takeWhile { it.isWhitespace() } + first.text.value.flattenFor(block) + else -> head + lead + first.text.value.flattenFor(block) } var rest = if (first == null) mapped else mapped.drop(1) var caret = 0 @@ -80,7 +126,7 @@ private fun BlockEditorState.pasteBlocks( if (last == null) { rest = rest + block.sameKind(tail) } else { - val joined = last.text.value + pasted.last().takeLastWhile { it.isWhitespace() } + val joined = last.text.value + trail caret = joined.length rest = rest.dropLast(1) + block.sameKind(joined + tail) } @@ -92,13 +138,13 @@ private fun BlockEditorState.pasteBlocks( replaceBlock(index, rest.first()) rest.drop(1).forEachIndexed { i, added -> addBlock(index + 1 + i, added) } } else { - setValue(blocks[index].id, block.text, lead) + 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 lead + return if (replaceCurrent) "" else before } /** A heading is one line: line breaks of pasted text become spaces there. */ 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 index 2f3cdce2..dc7f4ee5 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/TextRangeSelection.kt @@ -99,9 +99,13 @@ public class TextRangeSelection(private val blocks: List) { else -> null } - /** Whether the selection runs from one field into another. */ + /** + * 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() = resolved()?.let { it.first != it.last || it.firstItem != it.lastItem } == true + 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 @@ -222,10 +226,45 @@ public class TextRangeSelection(private val blocks: List) { } } - /** The selection in the order of the note with its offsets kept inside the text; null without one. */ - internal fun resolved(): ResolvedRange? { + /** + * 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 ends?.let { placed(it.ordered(), it.head) } + ?: 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. */ @@ -351,9 +390,12 @@ public fun BlockEditorState.textRangePlainText(): String = textRangeBlocks().pla * 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() ?: return false + val r = textRange.resolved() + if (r == null || touchesLockedList(r)) return false val plan = RangePlan(this, r, replacement) batch { plan.apply(this) } textRange.clear() @@ -366,6 +408,13 @@ public fun BlockEditorState.replaceTextRange(replacement: String): Boolean { 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 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 index 35038611..682d0415 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActions.kt @@ -35,6 +35,13 @@ internal interface BlockActions { */ 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 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 index 0695d289..e7cf5ddc 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/BlockActionsImpl.kt @@ -59,6 +59,9 @@ internal class BlockActionsImpl( 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) 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 index 568c6aa7..bdb7add8 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/ui/EditorController.kt @@ -103,6 +103,13 @@ public interface EditorController { /** 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 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 index 652b8b1e..b145d67a 100644 --- 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 @@ -67,6 +67,8 @@ internal class BlockInput( 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) @@ -91,18 +93,32 @@ internal class BlockInput( } } + // 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)) } - // The selection across blocks 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. The field itself keeps its text. Enter only deletes. - private fun TextFieldBuffer.replaceRange(inserted: String) { - revertAllChanges() - val typed = inserted.takeUnless { it == "\n" || it == "\r" }.orEmpty() - defer { actions().replaceTextRange(typed) } - } + 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) @@ -116,6 +132,21 @@ internal class BlockInput( } } +/** + * 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 = "/" 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 index 6e605039..67434724 100644 --- 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 @@ -223,6 +223,12 @@ internal fun ListBlock( 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. @@ -263,22 +269,23 @@ internal fun ListBlock( } } } + 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 = marker.forItem(numbers[itemIndex], item.kindIn(block.blockType)), + marker = itemMarker, field = field, editor = editor, - context = ItemContext( - drag = drag, - moves = moves[itemIndex], - editable = !isLocked && !textLocked, - reorderable = items.size > 1, - onMove = { id, by -> actions.onListItemMove(id, by) }, - onShift = { id, deeper -> actions.onListItemShift(id, deeper) }, - ), - modifier = Modifier.dragged(drag, item.id), + context = context, + modifier = rowModifier, fieldModifier = itemModifier, ) } @@ -314,13 +321,15 @@ 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, - levelIndent = markerColumn(font), - ) + 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. */ 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 index 3a54b8b6..940187af 100644 --- 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 @@ -95,7 +95,7 @@ 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 class ItemMoves(val up: Boolean, val down: Boolean, val indent: Boolean, val outdent: Boolean) +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 { 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 index b09179e3..8232a97b 100644 --- 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 @@ -44,7 +44,11 @@ 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 @@ -77,6 +81,8 @@ 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 @@ -166,7 +172,15 @@ internal fun TableBlock( edit.focusCellId?.let { focus.pending = CellFocus(it, Int.MAX_VALUE) } } } - val context = CellContext(block, blockActions, focus, commands, defer) + 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())) { @@ -179,7 +193,7 @@ internal fun TableBlock( cell = cell, isHeader = table.header && rowIndex == 0, context = context, - onValue = { text -> change(block.table.value.withCell(cell.id, text), true) }, + onValue = onCell, isLocked = isLocked, ) } @@ -231,8 +245,37 @@ private class CellContext( 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) { @@ -289,22 +332,24 @@ private fun TableCellField( cell: TableCell, isHeader: Boolean, context: CellContext, - onValue: (String) -> Unit, + 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 = { block.table.value.cell(cell.id)?.value }, - modelChanges = remember(block, cell.id) { block.table.map { it.cell(cell.id)?.value }.distinctUntilChanged() }, - onEdit = onValue, + 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) { context.commands.enter(cell.id) } } + 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 }) { @@ -379,9 +424,12 @@ private fun cellTextStyle(isHeader: Boolean): TextStyle { } /** - * The cell's keys: Tab and Shift+Tab move to the next and previous cell (Tab in the last cell adds a row), and 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. + * 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, @@ -393,54 +441,84 @@ private fun Modifier.cellKeys( val line = if (event.isArrow()) caretLine(highlight.geometry(), state.text.length, selection.end) else null val commands = context.commands val actions = context.blockActions - when { - event.key == Key.Tab && event.type == KeyEventType.KeyDown -> - if (event.isShiftPressed) { - commands.step(cellId, CellStep.PREVIOUS, caret = Int.MAX_VALUE) - } else { - true.also { commands.tab(cellId) } - } - - event.key == Key.Tab -> true - - else -> 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, + 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. + * 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 onEnter: () -> Unit) : - InputTransformation { +private class CellInput( + private val defer: (() -> Unit) -> Unit, + private val actions: () -> BlockActions, + private val onEnter: () -> Unit, +) : InputTransformation { override fun TextFieldBuffer.transformInput() { - val new = asCharSequence().toString() - if (new.none { it in LINE_BREAK_CHARS }) return val old = originalText.toString() + val new = asCharSequence().toString() + if (old == new) return val diff = textDiff(old, new, if (selection.collapsed) selection.start else -1) - if (LineBreak.of(old, diff)?.isEnter == true) { - revertAllChanges() - defer(onEnter) - } else { - replace(0, length, cellText(new)) - placeCursorAtEnd() + 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/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt index 1bc20a20..3a8003d7 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/BlockJsonFormatTest.kt @@ -444,4 +444,65 @@ class BlockJsonFormatTest { 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/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt index 69631d86..e898b848 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/codec/TablesAndCodeCodecTest.kt @@ -96,14 +96,15 @@ class TablesAndCodeCodecTest { } @Test - fun blankCodeAndTablesAreNotStoredButOneBetweenOtherBlocksIsKept() { - val blank = listOf(Block.CodeBlock(), Block.TableBlock()) - assertEquals("[]", BlockConverter.blocksToJson(blank)) + 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 kept = BlockConverter.blocksToJson( + val between = BlockConverter.blocksToJson( listOf(Block.CodeBlock(MutableStateFlow("x")), Block.TableBlock(), Block.TextBlock(MutableStateFlow("t"))), ) - assertEquals(listOf("code:x", "text:t"), describe(BlockConverter.jsonToBlocks(kept))) + assertEquals(listOf("code:x", "table:|/|", "text:t"), describe(BlockConverter.jsonToBlocks(between))) } // --- Markdown import -------------------------------------------------------------------------- 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 index f4836223..c07a0a72 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/model/TableDataTest.kt @@ -3,6 +3,7 @@ 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 @@ -24,8 +25,13 @@ class TableDataTest { } @Test - fun aTableWithoutRowsHasNoColumns() { - assertEquals(0, TableData(emptyList()).columnCount) + 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 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/TablesAndCodeStateTest.kt b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt index 66f6512a..a9e0525b 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/state/TablesAndCodeStateTest.kt @@ -380,7 +380,7 @@ class TablesAndCodeStateTest { } @Test - fun aRangeThatReachesATableTakesTheWholeTableWhetherItsEndIsInOrAround() { + fun aRangeTakesATableWholeWhenItCoversItAndLeavesItAloneWhenItEndsInsideIt() { val before = text("before") val grid = table(listOf("a", "b"), listOf("c", "d")) val after = text("after") @@ -392,21 +392,24 @@ class TablesAndCodeStateTest { assertTrue(editor.replaceTextRange("")) assertEquals(listOf("befter"), editor.blocks.map { (it as Block.TextBlock).text.value }) - // An end inside a cell: the table goes whole, the text on the other side is cut as usual. + // 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(1, editor.textRangeBlocks().count { it is Block.TableBlock }) + assertEquals(0, editor.textRangeBlocks().count { it is Block.TableBlock }) assertTrue(editor.replaceTextRange("")) - assertEquals(listOf("text"), editor.blocks.map { (it as Block.TextBlock).text.value }) + 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("head "), editor.blocks.map { (it as Block.TextBlock).text.value }) + assertEquals(listOf(first.id, third.id), editor.blocks.map { it.id }) + assertEquals("head ", first.text.value) } @Test 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 index 1da2e667..9e01c472 100644 --- a/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt +++ b/editor/src/commonTest/kotlin/com/pasich/encly/editor/ui/StateController.kt @@ -43,6 +43,7 @@ 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 @@ -155,6 +156,9 @@ internal class StateController(val state: BlockEditorState) : EditorController { 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 = 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/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 index edc75f7b..be964b59 100644 --- 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 @@ -389,7 +389,8 @@ class NotePersistence( /** 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() } + 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 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 index 2fe8b8bb..c4db948b 100644 --- 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 @@ -48,13 +48,12 @@ internal class EditNoteViewModelController( override fun replaceTextRange(replacement: String): Boolean = viewModel.replaceTextRange(replacement) - override fun copyTextRange(markdown: Boolean): Boolean { - val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown) - return text.isNotEmpty() && clipboard.copy(clipLabel, text) - } + override fun copyTextRange(markdown: Boolean): Boolean = + copyBlocks(clipboard, viewModel, viewModel.textRangeBlocks(), markdown, clipLabel) override fun cutTextRange(): Boolean = - viewModel.canEditBlocks && copyTextRange(markdown = true) && viewModel.replaceTextRange("") + viewModel.canEditBlocks && viewModel.canReplaceTextRange() && copyTextRange(markdown = true) && + viewModel.replaceTextRange("") override fun indexOfBlock(block: Block): Int = viewModel.indexOfBlock(block) @@ -93,6 +92,9 @@ internal class EditNoteViewModelController( 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 = 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 index a5300357..f2add26e 100644 --- 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 @@ -26,6 +26,23 @@ import org.jetbrains.compose.resources.stringResource 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 @@ -38,16 +55,16 @@ internal fun copyPickedBlocks( label: String, confirmation: String, ): String? { - val text = selectedBlocksText(viewModel.selectedBlocks.picked(), markdown) - val copied = text.isNotEmpty() && clipboard.copy(label, text) + 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: Markdown (so pasting them back into a note rebuilds the blocks) goes to the clipboard, and - * only when it got there are the blocks removed, as one undo step. Does nothing in a note that cannot be changed. - * Returns the confirmation to show, or null. + * 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, @@ -56,8 +73,7 @@ internal fun cutPickedBlocks( confirmation: String, ): String? { if (!viewModel.canEditBlocks) return null - val text = selectedBlocksText(viewModel.selectedBlocks.picked(), markdown = true) - val copied = text.isNotEmpty() && clipboard.copy(label, text) + val copied = copyBlocks(clipboard, viewModel, viewModel.selectedBlocks.picked(), markdown = true, label) if (copied) viewModel.deleteSelectedBlocks() return confirmation.takeIf { copied && !clipboard.systemConfirmsCopy } } @@ -102,8 +118,7 @@ internal fun EditorTextRangeBar( val locked by viewModel.lockEditor.collectAsState() val actions = remember(viewModel, clipboard, label, copied, copiedMarkdown, locked) { fun copy(markdown: Boolean, message: String) { - val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown) - if (text.isNotEmpty() && clipboard.copy(label, text)) { + if (copyBlocks(clipboard, viewModel, viewModel.textRangeBlocks(), markdown, label)) { viewModel.textRange.clear() message.takeIf { !clipboard.systemConfirmsCopy }?.let(onMessage) } @@ -113,8 +128,10 @@ internal fun EditorTextRangeBar( onCopy = { copy(markdown = false, message = copied) }, onCopyMarkdown = { copy(markdown = true, message = copiedMarkdown) }, onCut = { - val text = selectedBlocksText(viewModel.textRangeBlocks(), markdown = true) - if (text.isNotEmpty() && clipboard.copy(label, text)) viewModel.replaceTextRange("") + // 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("") }, ) 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 index 02f5c3bb..fb62d220 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -44,6 +44,7 @@ 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 @@ -59,6 +60,7 @@ 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 @@ -536,6 +538,14 @@ class EditNoteViewModel( 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) @@ -681,6 +691,12 @@ class EditNoteViewModel( /** 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 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 index a545f93f..f3446917 100644 --- 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 @@ -161,6 +161,17 @@ class NotePersistenceTest { 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) 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 index 8933bfa8..c6a2c77d 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt @@ -244,6 +244,34 @@ class EditNoteViewModelSelectionTest { 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 From 2658a04dfc2b149fcd34dd0c691255f8312da61a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 19:16:58 +0200 Subject: [PATCH 59/75] chore(release): 3.0.0 --- CHANGELOG.md | 17 +++++------------ .../io.github.pasichdev.Encly.metainfo.xml | 2 +- .../metadata/android/ar/changelogs/20400.txt | 9 --------- .../metadata/android/ar/changelogs/30000.txt | 8 ++++++++ .../metadata/android/de-DE/changelogs/20400.txt | 9 --------- .../metadata/android/de-DE/changelogs/30000.txt | 8 ++++++++ .../metadata/android/en-US/changelogs/20400.txt | 9 --------- .../metadata/android/en-US/changelogs/30000.txt | 8 ++++++++ .../metadata/android/es-ES/changelogs/20400.txt | 9 --------- .../metadata/android/es-ES/changelogs/30000.txt | 8 ++++++++ .../metadata/android/fr-FR/changelogs/20400.txt | 9 --------- .../metadata/android/fr-FR/changelogs/30000.txt | 8 ++++++++ .../metadata/android/it-IT/changelogs/20400.txt | 9 --------- .../metadata/android/it-IT/changelogs/30000.txt | 8 ++++++++ .../metadata/android/nl-NL/changelogs/20400.txt | 9 --------- .../metadata/android/nl-NL/changelogs/30000.txt | 8 ++++++++ .../metadata/android/pl-PL/changelogs/20400.txt | 9 --------- .../metadata/android/pl-PL/changelogs/30000.txt | 8 ++++++++ .../metadata/android/pt-PT/changelogs/20400.txt | 9 --------- .../metadata/android/pt-PT/changelogs/30000.txt | 8 ++++++++ .../metadata/android/uk/changelogs/20400.txt | 9 --------- .../metadata/android/uk/changelogs/30000.txt | 8 ++++++++ version.properties | 4 ++-- 23 files changed, 88 insertions(+), 105 deletions(-) delete mode 100644 fastlane/metadata/android/ar/changelogs/20400.txt create mode 100644 fastlane/metadata/android/ar/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/de-DE/changelogs/20400.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/en-US/changelogs/20400.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/es-ES/changelogs/20400.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/fr-FR/changelogs/20400.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/it-IT/changelogs/20400.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/nl-NL/changelogs/20400.txt create mode 100644 fastlane/metadata/android/nl-NL/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/pl-PL/changelogs/20400.txt create mode 100644 fastlane/metadata/android/pl-PL/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/pt-PT/changelogs/20400.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/30000.txt delete mode 100644 fastlane/metadata/android/uk/changelogs/20400.txt create mode 100644 fastlane/metadata/android/uk/changelogs/30000.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d2d776..5e63ed16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ IzzyOnDroid) and used as the GitHub Release notes. ## [Unreleased] +## [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)). - - 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)). - 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)). @@ -23,17 +26,6 @@ IzzyOnDroid) and used as the GitHub Release notes. - 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)). - -### Changed - -- 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)). - -## [2.4.0] - 2026-11-07 - -versionCode 20400. - -### Added - - 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)). - Auto-lock after 5 or 10 minutes ([#98](https://github.com/pasichDev/Encly/issues/98)). @@ -42,6 +34,7 @@ versionCode 20400. ### Changed +- 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)). diff --git a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml index 6c242562..1b12efe9 100644 --- a/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml +++ b/desktopApp/packaging/flatpak/io.github.pasichdev.Encly.metainfo.xml @@ -41,7 +41,7 @@ keyboard - + diff --git a/fastlane/metadata/android/ar/changelogs/20400.txt b/fastlane/metadata/android/ar/changelogs/20400.txt deleted file mode 100644 index effe8721..00000000 --- a/fastlane/metadata/android/ar/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• عاد فتح القفل ببصمة الإصبع إلى العمل بعد أن يقفل Encly في الخلفية. -• تحتفظ الملاحظات الجديدة بتاريخ إنشائها. -• جديد: إخفاء نص الملاحظات في القوائم (الإعدادات ← الأمان). -• جديد: السماح بلقطات الشاشة حتى يُقفل التطبيق. -• قفل تلقائي بعد 5 أو 10 دقائق. -• تفرغ سلة المحذوفات تلقائيًا بعد 28 يومًا. -• يمرّر البحث في الملاحظة إلى كل نتيجة؛ والضغط مطولًا على الحذف يمرّ عبر القوائم؛ وتُفتح الملاحظات بسرعة أكبر. -• أُعيد بناء التطبيق على نواة جديدة؛ وتبقى خزنتك كما هي. diff --git a/fastlane/metadata/android/ar/changelogs/30000.txt b/fastlane/metadata/android/ar/changelogs/30000.txt new file mode 100644 index 00000000..4c500721 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• متوفر الآن أيضًا على Windows وmacOS وLinux وiPhone. +• رمز PIN من 6 إلى 12 رقمًا؛ يبقى الرمز بعد الفتح بعبارة الاسترداد. +• تحديد النص عبر عدة فقرات؛ جداول وكتل برمجية وقوائم متداخلة وسحب العناصر. +• شريط أدوات قابل للتخصيص؛ إخفاء نص الملاحظات في القوائم. +• العربية والتخطيط من اليمين إلى اليسار؛ الإعدادات ضمن النسخة الاحتياطية. +• البصمة تعمل بعد القفل في الخلفية. +• تُفرَّغ سلة المحذوفات بعد 28 يومًا. diff --git a/fastlane/metadata/android/de-DE/changelogs/20400.txt b/fastlane/metadata/android/de-DE/changelogs/20400.txt deleted file mode 100644 index e248520b..00000000 --- a/fastlane/metadata/android/de-DE/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Fingerabdruck-Entsperrung klappt wieder nach Sperre im Hintergrund. -• Neue Notizen behalten ihr Erstelldatum. -• Neu: Notiztext in Listen ausblenden (Einstellungen → Sicherheit). -• Neu: Screenshots erlauben, bis die App sperrt. -• Automatische Sperre nach 5 oder 10 Minuten. -• Der Papierkorb leert sich nach 28 Tagen. -• Suche scrollt zu jedem Treffer; Löschen läuft durch Listen; Notizen öffnen schneller. -• Neuer Kern; dein Tresor bleibt unverändert. 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..d9e15bcf --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Jetzt auch für Windows, macOS, Linux und iPhone. +• 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. +• 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/en-US/changelogs/20400.txt b/fastlane/metadata/android/en-US/changelogs/20400.txt deleted file mode 100644 index 115f8556..00000000 --- a/fastlane/metadata/android/en-US/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Fingerprint unlock works again after Encly locks in the background. -• New notes keep their creation date. -• New: hide note text in lists (Settings → Security). -• New: allow screenshots until the app locks. -• Auto-lock after 5 or 10 minutes. -• The trash empties itself after 28 days. -• Find in note scrolls to each match; holding delete runs through lists; notes open faster. -• Rebuilt on a new core; your vault stays as it is. 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..7399ef36 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Now also on Windows, macOS, Linux and iPhone. +• 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. +• 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/es-ES/changelogs/20400.txt b/fastlane/metadata/android/es-ES/changelogs/20400.txt deleted file mode 100644 index f8f28e54..00000000 --- a/fastlane/metadata/android/es-ES/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• El desbloqueo con huella vuelve a funcionar tras el bloqueo en segundo plano. -• Las notas nuevas conservan su fecha de creación. -• Nuevo: ocultar el texto de las notas en las listas (Ajustes → Seguridad). -• Nuevo: permitir capturas hasta que la app se bloquee. -• Bloqueo tras 5 o 10 minutos. -• La papelera se vacía sola a los 28 días. -• Buscar desplaza a cada resultado; las notas abren más rápido. -• Nuevo núcleo; tu bóveda sigue igual. 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..968c2ac5 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Ahora también en Windows, macOS, Linux y iPhone. +• 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. +• 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/fr-FR/changelogs/20400.txt b/fastlane/metadata/android/fr-FR/changelogs/20400.txt deleted file mode 100644 index f5b8fdda..00000000 --- a/fastlane/metadata/android/fr-FR/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Le déverrouillage par empreinte refonctionne après un verrouillage en arrière-plan. -• Les nouvelles notes gardent leur date de création. -• Nouveau : masquer le texte des notes dans les listes (Paramètres → Sécurité). -• Nouveau : autoriser les captures jusqu'au verrouillage. -• Verrouillage après 5 ou 10 minutes. -• La corbeille se vide après 28 jours. -• La recherche défile vers chaque résultat ; notes plus rapides à ouvrir. -• Nouveau moteur ; votre coffre reste intact. 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..ed43625c --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Aussi sur Windows, macOS, Linux et iPhone. +• 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. +• 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/it-IT/changelogs/20400.txt b/fastlane/metadata/android/it-IT/changelogs/20400.txt deleted file mode 100644 index 46603fe6..00000000 --- a/fastlane/metadata/android/it-IT/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Lo sblocco con impronta funziona di nuovo dopo il blocco in background. -• Le nuove note mantengono la data di creazione. -• Novità: nascondi il testo delle note negli elenchi (Impostazioni → Sicurezza). -• Novità: consenti gli screenshot fino al blocco. -• Blocco dopo 5 o 10 minuti. -• Il cestino si svuota da solo dopo 28 giorni. -• La ricerca scorre a ogni risultato; le note si aprono più in fretta. -• Nuovo motore; la tua cassaforte resta com'è. 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..e0498b87 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Ora anche su Windows, macOS, Linux e iPhone. +• 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. +• 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/nl-NL/changelogs/20400.txt b/fastlane/metadata/android/nl-NL/changelogs/20400.txt deleted file mode 100644 index 2cb6284b..00000000 --- a/fastlane/metadata/android/nl-NL/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Ontgrendelen met vingerafdruk werkt weer na vergrendeling op de achtergrond. -• Nieuwe notities houden hun aanmaakdatum. -• Nieuw: notitietekst in lijsten verbergen (Instellingen → Beveiliging). -• Nieuw: screenshots toestaan tot de app vergrendelt. -• Vergrendelen na 5 of 10 minuten. -• De prullenbak leegt zichzelf na 28 dagen. -• Zoeken scrolt naar elke treffer; notities openen sneller. -• Nieuwe basis; je kluis blijft zoals hij is. 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..054089ac --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Nu ook op Windows, macOS, Linux en iPhone. +• Pincode van 6–12 cijfers; pincode blijft na ontgrendelen met de herstelzin. +• Tekst over alinea's selecteren; tabellen, codeblokken, geneste lijsten, items slepen. +• 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/pl-PL/changelogs/20400.txt b/fastlane/metadata/android/pl-PL/changelogs/20400.txt deleted file mode 100644 index 03c7b062..00000000 --- a/fastlane/metadata/android/pl-PL/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Odblokowanie odciskiem znów działa po blokadzie w tle. -• Nowe notatki zachowują datę utworzenia. -• Nowość: ukryj tekst notatek na listach (Ustawienia → Bezpieczeństwo). -• Nowość: zezwól na zrzuty ekranu do blokady. -• Blokada po 5 lub 10 minutach. -• Kosz opróżnia się sam po 28 dniach. -• Wyszukiwanie przewija do każdego wyniku; notatki otwierają się szybciej. -• Nowy rdzeń; twój sejf pozostaje bez zmian. 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..090efddf --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Teraz także na Windows, macOS, Linux i iPhone. +• 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. +• 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/pt-PT/changelogs/20400.txt b/fastlane/metadata/android/pt-PT/changelogs/20400.txt deleted file mode 100644 index b5ebf105..00000000 --- a/fastlane/metadata/android/pt-PT/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• O desbloqueio por impressão digital volta a funcionar após bloqueio em segundo plano. -• As notas novas mantêm a data de criação. -• Novo: ocultar o texto das notas nas listas (Configurações → Segurança). -• Novo: permitir capturas de ecrã até a app bloquear. -• Bloqueio após 5 ou 10 minutos. -• O lixo esvazia-se após 28 dias. -• A pesquisa desloca até cada resultado; as notas abrem mais depressa. -• Novo núcleo; o seu cofre fica igual. 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..9259af26 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Agora também em Windows, macOS, Linux e iPhone. +• 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. +• 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/uk/changelogs/20400.txt b/fastlane/metadata/android/uk/changelogs/20400.txt deleted file mode 100644 index 93dc79a3..00000000 --- a/fastlane/metadata/android/uk/changelogs/20400.txt +++ /dev/null @@ -1,9 +0,0 @@ -Encly 2.4.0 -• Розблокування відбитком знову працює після блокування у фоні. -• Нові нотатки зберігають дату створення. -• Нове: приховати текст нотаток у списках (Налаштування → Безпека). -• Нове: дозволити скриншоти до блокування. -• Автоблокування через 5 або 10 хвилин. -• Кошик очищається сам через 28 днів. -• Пошук у нотатці прокручує до збігу; delete проходить списки; нотатки відкриваються швидше. -• Нове ядро; ваше сховище не змінюється. diff --git a/fastlane/metadata/android/uk/changelogs/30000.txt b/fastlane/metadata/android/uk/changelogs/30000.txt new file mode 100644 index 00000000..18444001 --- /dev/null +++ b/fastlane/metadata/android/uk/changelogs/30000.txt @@ -0,0 +1,8 @@ +Encly 3.0.0 +• Тепер і на Windows, macOS, Linux та iPhone. +• PIN від 6 до 12 цифр; PIN лишається після входу фразою відновлення. +• Виділення тексту через кілька абзаців; таблиці, блоки коду, вкладені списки, перетягування пунктів. +• Власний тулбар; можна сховати текст нотаток у списках. +• Арабська і інтерфейс справа наліво; налаштування в бекапі. +• Відбиток працює після блокування у фоні; нотатки зберігають дату створення. +• Кошик очищається сам через 28 днів. diff --git a/version.properties b/version.properties index a70f476c..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=4 +VERSION_MAJOR=3 +VERSION_MINOR=0 VERSION_PATCH=0 From 66748359c7d734cec4d4bf28b740d410956ee206 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 20:33:39 +0200 Subject: [PATCH 60/75] feat: sort notes by title, a shorter unlock animation 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. --- CHANGELOG.md | 2 + .../commonMain/strings/values-ar/strings.xml | 4 ++ .../commonMain/strings/values-de/strings.xml | 4 ++ .../commonMain/strings/values-es/strings.xml | 4 ++ .../commonMain/strings/values-fr/strings.xml | 4 ++ .../commonMain/strings/values-it/strings.xml | 4 ++ .../commonMain/strings/values-nl/strings.xml | 4 ++ .../commonMain/strings/values-pl/strings.xml | 4 ++ .../commonMain/strings/values-pt/strings.xml | 4 ++ .../commonMain/strings/values-uk/strings.xml | 4 ++ .../src/commonMain/strings/values/strings.xml | 4 ++ .../encly/core/backup/BackupSettings.kt | 9 ++- .../presentation/effects/UnlockRevealTest.kt | 59 +++++++++++++++++++ .../encly/domain/enums/NoteSortOption.kt | 42 +++++++++++++ .../encly/domain/enums/TitleCollation.kt | 10 ++++ .../presentation/effects/UnlockReveal.kt | 49 +++++++++------ .../encly/domain/enums/NoteSortOptionTest.kt | 42 +++++++++++++ .../com/pasich/encly/SharedBoundaryTest.kt | 1 + .../encly/domain/enums/TitleCollation.ios.kt | 7 +++ .../encly/domain/enums/TitleCollation.kt | 10 ++++ .../encly/domain/enums/TitleCollationTest.kt | 37 ++++++++++++ .../usecase/note/ObserveNotesUseCaseTest.kt | 48 +++++++++++---- 22 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt create mode 100644 shared/src/iosMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.ios.kt create mode 100644 shared/src/jvmAndAndroidMain/kotlin/com/pasich/encly/domain/enums/TitleCollation.kt create mode 100644 shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/enums/TitleCollationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e63ed16..6c32abd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,12 +28,14 @@ versionCode 30000. - 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)). diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index 0eaf3e03..8648b223 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -269,6 +269,10 @@ الإنشاء ↑ حسب تاريخ الإنشاء (الأحدث أولًا) الإنشاء ↓ + حسب العنوان (أ إلى ي) + العنوان أ–ي + حسب العنوان (ي إلى أ) + العنوان ي–أ حسب آخر تعديل (الأقدم أولًا) التعديل ↑ حسب آخر تعديل (الأحدث أولًا) diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index ae45f871..ee0263a6 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index 83b9b82c..a06ff7c4 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index 05f56701..0d0dbc33 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 4779661a..d69e47e6 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index 7bf648ed..a2e43f4e 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index 59223781..efe17675 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index dfb6d37b..b3e282a5 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -292,6 +292,10 @@ 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) diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index 2809643f..ce1299b7 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -292,6 +292,10 @@ Ств ↑ За датою створення (спадання) Ств ↓ + За назвою (А–Я) + Назва А–Я + За назвою (Я–А) + Назва Я–А За датою редагування (зростання) Ред ↑ За датою редагування (спадання) diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index eca674a7..a61b88df 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -309,6 +309,10 @@ 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) 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 index 50ec0911..9712b150 100644 --- 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 @@ -35,7 +35,14 @@ public object BackupSettings { 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") + 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", 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 index 2e8aeaf9..bb80022c 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/UnlockRevealTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/UnlockRevealTest.kt @@ -1,5 +1,7 @@ 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 @@ -153,4 +155,61 @@ class UnlockRevealTest : ComposeScreenTest() { 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/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt index c6fa7670..288f2913 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/enums/NoteSortOption.kt @@ -5,6 +5,10 @@ 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 @@ -33,6 +37,16 @@ enum class NoteSortOption(val labelRes: StringResource, val shortLabelRes: Strin 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. */ @@ -41,6 +55,34 @@ enum class NoteSortOption(val labelRes: StringResource, val shortLabelRes: Strin 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) + } + } + } } /** 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt index 2ce43717..2ee531bf 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/UnlockReveal.kt @@ -28,19 +28,19 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.util.lerp import com.pasich.encly.shared.resources.Res import com.pasich.encly.shared.resources.ic_launcher_foreground -import kotlinx.coroutines.delay 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 +/** + * 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 /** ...then the notes list slides in from the end side (the right, left to right) and pushes that colour out. */ -private const val UNLOCK_PUSH_MS = 520 - -/** 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 +internal const val UNLOCK_PUSH_MS = 250 /** The glyph is gone by this share of the growth. */ private const val GLYPH_OUT = 0.25f @@ -55,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 @@ -141,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(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() } 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 index fe186802..813e117c 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/enums/NoteSortOptionTest.kt @@ -26,4 +26,46 @@ class NoteSortOptionTest { 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/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt index 8feaf109..738e984a 100644 --- a/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt +++ b/shared/src/desktopTest/kotlin/com/pasich/encly/SharedBoundaryTest.kt @@ -172,6 +172,7 @@ class SharedBoundaryTest { "com/pasich/encly/utils/LocalizedDateJvm.kt" to "java.text date formats, the same on both JVM targets", "com/pasich/encly/data/importer/OutOfMemory.kt" to "OutOfMemoryError, a JVM class with no common name", "com/pasich/encly/core/di/PlatformIoDispatcher.kt" to "Dispatchers.IO, which common code cannot name", + "com/pasich/encly/domain/enums/TitleCollation.kt" to "java.text.Collator, the locale-aware title order", ) /** The Android actuals. */ 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/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/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/usecase/note/ObserveNotesUseCaseTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/ObserveNotesUseCaseTest.kt index 74af05f1..b727cca8 100644 --- 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 @@ -76,6 +76,22 @@ class ObserveNotesUseCaseTest { 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) @@ -154,17 +170,23 @@ class ObserveNotesUseCaseTest { 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, - ) + private fun noteWithTag( + id: Long, + date: Long, + tag: Tag? = null, + value: String = "", + pinned: Boolean = false, + title: String = "n$id", + ) = NoteWithTag( + note = Note( + id = id, + title = title, + value = value, + date = date, + dateCreate = date, + tagId = tag?.id, + isPinned = pinned, + ), + tag = tag, + ) } From e28e620903657a481d5e44dfe8277754b5a947e9 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Sat, 7 Nov 2026 20:55:39 +0200 Subject: [PATCH 61/75] docs(editor): KDoc links Dokka could not resolve --- .../kotlin/com/pasich/encly/editor/state/ListNesting.kt | 2 +- .../kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 5d666f82..2414feec 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/ListNesting.kt @@ -13,7 +13,7 @@ import com.pasich.encly.editor.model.withSiblingsOfKind // 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. */ +/** 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? { 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 index 186b9f0e..92416b65 100644 --- a/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt +++ b/editor/src/commonMain/kotlin/com/pasich/encly/editor/state/SelectedBlocksEdits.kt @@ -39,7 +39,7 @@ public fun BlockEditorState.pickedPlainText(): String = pickedBlocks.picked().pl /** * [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 + * 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() } From 5836d0e45cb9c62be70c0319c9b264b269d7a9ff Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 9 Nov 2026 14:14:21 +0200 Subject: [PATCH 62/75] perf: the note transition no longer fills the GPU cache, one fingerprint 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. --- .../commonMain/strings/values-ar/strings.xml | 1 + .../commonMain/strings/values-de/strings.xml | 1 + .../commonMain/strings/values-es/strings.xml | 1 + .../commonMain/strings/values-fr/strings.xml | 1 + .../commonMain/strings/values-it/strings.xml | 1 + .../commonMain/strings/values-nl/strings.xml | 1 + .../commonMain/strings/values-pl/strings.xml | 1 + .../commonMain/strings/values-pt/strings.xml | 1 + .../commonMain/strings/values-uk/strings.xml | 1 + .../src/commonMain/strings/values/strings.xml | 1 + .../effects/ContainerTransform.kt | 61 ++++++++++++++--- .../screen/onboarding/OnboardingActions.kt | 4 ++ .../screen/onboarding/OnboardingFooter.kt | 11 ++- .../screen/onboarding/OnboardingScreen.kt | 1 + .../viewmodel/OnboardingViewModel.kt | 36 ++++++++-- .../presentation/effects/ContainerFadeTest.kt | 63 +++++++++++++++++ .../viewmodel/OnboardingFailuresTest.kt | 68 +++++++++++++++++++ 17 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ContainerFadeTest.kt diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index 8648b223..c30a7dd7 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -476,6 +476,7 @@ تم تعيين رمز PIN يُطلب عند كل فتح لـ Encly فتح القفل ببصمة الإصبع مفعّل + جرّب البصمة مرة أخرى غيّره من الإعدادات، الأمان تم التحقق من عبارة الاسترداد احتفظ بالورقة في مكان آمن diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index ee0263a6..9e4fffda 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -488,6 +488,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index a06ff7c4..f1f08cd9 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -491,6 +491,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index 0d0dbc33..044e0184 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -491,6 +491,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index d69e47e6..1d93cc09 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -491,6 +491,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index a2e43f4e..91e32f65 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -488,6 +488,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index efe17675..870bb083 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -494,6 +494,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index b3e282a5..3f5c73dc 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -491,6 +491,7 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index ce1299b7..b013e67d 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -494,6 +494,7 @@ PIN задано Запитується щоразу, коли відкривається Encly Розблокування відбитком увімкнено + Спробувати відбиток ще раз Змінити можна в Налаштуваннях, розділ «Безпека» Фразу відновлення перевірено Зберігайте папірець у надійному місці diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index a61b88df..b7700f90 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -506,6 +506,7 @@ 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 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 index 81084c77..07f16dff 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ContainerTransform.kt @@ -9,8 +9,6 @@ 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 @@ -21,7 +19,9 @@ 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 @@ -80,12 +80,15 @@ private fun rememberContainerBounds( val windowSize = LocalWindowInfo.current.containerSize val clip = remember(corner, windowSize) { cornerClip(corner, windowSize) } val state = with(sharedScope) { rememberSharedContentState(key = "note-$noteId") } - return with(sharedScope) { + val bounds = 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, + // 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 @@ -96,12 +99,54 @@ private fun rememberContainerBounds( clipInOverlayDuringTransition = clip, ) } + return if (end == ContainerEnd.EDITOR) bounds.then(containerFade(visibility)) else bounds } -private val ContainerFadeIn = - tween(CONTAINER_FADE_IN, delayMillis = CONTAINER_FADE_IN_DELAY, easing = LinearOutSlowInEasing) +/** + * 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 +} -private val ContainerFadeOut = tween(CONTAINER_FADE_OUT, easing = FastOutLinearInEasing) +/** 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 diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/onboarding/OnboardingActions.kt index 1163db42..6719bb91 100644 --- a/shared/src/commonMain/kotlin/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/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 index 1af3174d..ffc90f9d 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -67,8 +68,14 @@ internal fun footerSpec( onSecondary = actions.onShowWordsAgain, ) - OnboardingStep.READY -> - FooterSpec(Res.string.onboarding_ready_open, actions.onOpenNotebook, !state.biometricPending, finishing) + 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, 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 index ae950abd..1a2126d8 100644 --- 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 @@ -111,6 +111,7 @@ fun OnboardingScreen( onRestore = onRestore, onLanguage = { languageDialog = true }, onOpenNotebook = { setupViewModel.finishSetup(onFinished) }, + onRetryBiometric = { host?.let(viewModel::retryBiometric) }, ) LaunchedEffect(state.biometricPending) { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt index 8b037ebd..a78f6ab4 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingViewModel.kt @@ -101,6 +101,9 @@ class OnboardingViewModel( ) 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 @@ -353,18 +356,41 @@ class OnboardingViewModel( 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. */ + /** + * 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(host.biometric) { 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. */ @@ -508,6 +534,8 @@ class OnboardingViewModel( // 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/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/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt index 4de7cae7..a485c08a 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/OnboardingFailuresTest.kt @@ -36,6 +36,7 @@ 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` @@ -165,6 +166,73 @@ class OnboardingFailuresTest { 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() From bc640c227bd94d87344542bdf175994996df5221 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 9 Nov 2026 14:38:16 +0200 Subject: [PATCH 63/75] perf: screen transitions without full-window layers, fewer and smaller 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. --- config/detekt/detekt.yml | 2 + .../pasich/encly/data/database/LeanDriver.kt | 42 +++++++ .../data/database/SecureDatabaseManager.kt | 20 +++- .../encly/data/database/DesktopPoolTest.kt | 81 +++++++++++++ .../effects/ScreenTransitionTest.kt | 88 ++++++++++++++ .../encly/presentation/effects/Animation.kt | 108 ++++++++++++++++-- .../presentation/effects/AnimationScreens.kt | 60 +++++++--- .../presentation/effects/ScreenMotions.kt | 20 ++++ .../encly/presentation/navigation/NavHost.kt | 4 +- .../screen/PinCodeConfigScreen.kt | 22 ++-- .../screen/editnote/EditNoteLocalProvider.kt | 4 +- .../screen/onboarding/OnboardingScreen.kt | 15 +-- .../kotlin/com/pasich/encly/ui/theme/Theme.kt | 2 + .../kotlin/com/pasich/encly/ui/theme/Type.kt | 16 +++ .../presentation/effects/ScreenFadeTest.kt | 107 +++++++++++++++++ 15 files changed, 543 insertions(+), 48 deletions(-) create mode 100644 core/database/src/commonMain/kotlin/com/pasich/encly/data/database/LeanDriver.kt create mode 100644 core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopPoolTest.kt create mode 100644 shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/effects/ScreenTransitionTest.kt create mode 100644 shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/ScreenMotions.kt create mode 100644 shared/src/commonTest/kotlin/com/pasich/encly/presentation/effects/ScreenFadeTest.kt diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 08f0842b..b5b1f33d 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -59,6 +59,8 @@ Compose: - LocalFontStyle - LocalSimpleEdit - LocalFontStyles + # 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 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 index cb3bf0ea..5ea62b91 100644 --- 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 @@ -50,11 +50,17 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat */ 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 @@ -112,9 +118,13 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat 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(newDriver) - .setQueryCoroutineContext(Dispatchers.IO) + .setDriver(lean) + .setQueryCoroutineContext(Dispatchers.IO.limitedParallelism(QUERY_PARALLELISM)) .build() openedDatabase = opened @@ -124,6 +134,7 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat database = opened driver = newDriver + leanDriver = lean openKey = dek.copyOf() isUnlocked = true openFile = file @@ -134,6 +145,7 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat openedDatabase?.close() openedDriver?.close() database = null + leanDriver = null isUnlocked = false openFile = null lease = null @@ -164,6 +176,7 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat // 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) @@ -201,6 +214,9 @@ public class SecureDatabaseManager(private val platform: DatabasePlatform) : Dat } } +/** 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). 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/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/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt index c0154a81..e4bcd7db 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/Animation.kt @@ -2,14 +2,23 @@ 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.fadeIn -import androidx.compose.animation.fadeOut +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 @@ -26,18 +35,22 @@ 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 }, -) + fadeIn(tween(AXIS_FADE_IN, delayMillis = AXIS_FADE_IN_DELAY)) +) 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 }, -) + fadeOut(tween(AXIS_FADE_OUT)) +) /** * Duration of the card growing into the editor. A little longer than a screen slide: the card travels @@ -57,14 +70,95 @@ 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. */ -fun fadeScreenEnter(): EnterTransition = - fadeIn(tween(CONTAINER_FADE_IN, delayMillis = CONTAINER_FADE_IN_DELAY, easing = LinearOutSlowInEasing)) +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 +} -fun fadeScreenExit(): ExitTransition = fadeOut(tween(CONTAINER_FADE_IN, easing = FastOutLinearInEasing)) +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 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 index 183002f9..98460e8f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/effects/AnimationScreens.kt @@ -4,29 +4,36 @@ 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 -/** The enter transition for a swap of the destinations whose routes are [from] and [to]. */ -fun AnimatedContentTransitionScope<*>.enterFor(from: String?, to: String?, forward: Boolean): EnterTransition = - when (screenMotion(from, to)) { - ScreenMotion.NONE -> EnterTransition.None - ScreenMotion.FADE -> fadeScreenEnter() - ScreenMotion.AXIS -> axisEnter(forward) - } +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 exit transition for a swap of the destinations whose routes are [from] and [to]. */ -fun AnimatedContentTransitionScope<*>.exitFor(from: String?, to: String?, forward: Boolean): ExitTransition = - when (screenMotion(from, to)) { - ScreenMotion.NONE -> ExitTransition.None - ScreenMotion.FADE -> fadeScreenExit() - ScreenMotion.AXIS -> axisExit(forward) - } +/** 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 @@ -45,11 +52,26 @@ fun NavGraphBuilder.animationScreens( // 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(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) }, + enterTransition = { enterFor(forward = true) }, + exitTransition = { exitFor(forward = true) }, + popEnterTransition = { enterFor(forward = false) }, + popExitTransition = { exitFor(forward = false) }, content = { entry -> - CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) { 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/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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt index 7f54cdf7..f47c953c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/navigation/NavHost.kt @@ -66,10 +66,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String = NavR // 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) + enterFor(forward = false) }, predictivePopExitTransition = { - exitFor(initialState.destination.route, targetState.destination.route, forward = false) + exitFor(forward = false) }, ) { animationScreens(NavRoutes.HomeRoute.name) { 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 index 8088b933..522696e9 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/PinCodeConfigScreen.kt @@ -6,8 +6,6 @@ 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.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -57,6 +55,8 @@ 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 @@ -74,6 +74,7 @@ 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 { @@ -223,16 +224,19 @@ fun PinCodeConfigScreen( AnimatedContent( targetState = pinState.step, transitionSpec = { - (slideIntoContainer(SlideDirection.Start) + fadeIn()) - .togetherWith(slideOutOfContainer(SlideDirection.Start) + fadeOut()) + slideIntoContainer(SlideDirection.Start) + .togetherWith(slideOutOfContainer(SlideDirection.Start)) }, label = "StepAnimation", ) { currentStep -> - PinStepContent( - step = currentStep, - pinState = pinState, - onSubmitPin = { pinState.onPinComplete(securityViewModel) }, - ) + // 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() diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt index a8bb64f2..1a887fb2 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/editnote/EditNoteLocalProvider.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.sp import com.pasich.encly.domain.model.FontStyleType import com.pasich.encly.domain.model.NoteLineSpacing import com.pasich.encly.editor.model.BlockType -import com.pasich.encly.ui.theme.fontSet +import com.pasich.encly.ui.theme.sharedFontSet import com.pasich.encly.utils.FontSizeUtils /** @@ -94,7 +94,7 @@ private fun rememberFontStylesOptimized( fontStyle: FontStyleType = LocalFontStyle.current, ): FontStyles { // The editor uses the app-wide font set (Settings → Appearance → Font). - val fonts = fontStyle.fontSet() + val fonts = fontStyle.sharedFontSet() return remember(baseFontSize, fonts) { try { FontStyles( 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 index 1a2126d8..166b92af 100644 --- 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 @@ -4,8 +4,6 @@ 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.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -54,6 +52,8 @@ 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 @@ -65,9 +65,8 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel import org.jetbrains.compose.resources.stringResource 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 +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 @@ -239,14 +238,16 @@ private fun StepContent( slideIntoContainer( towards = direction, animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), - ) + fadeIn(tween(FADE_IN_MS, FADE_IN_DELAY_MS)) togetherWith slideOutOfContainer( + ) togetherWith slideOutOfContainer( towards = direction, animationSpec = tween(SLIDE_MS, easing = FastOutSlowInEasing), - ) + fadeOut(tween(FADE_OUT_MS)) + ) }, 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) 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 index fb670910..165b8c0b 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Theme.kt @@ -56,12 +56,14 @@ fun EnclyTheme(settings: ThemeSettings, dark: Boolean, content: @Composable () - 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( 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 index 5867f332..7f176680 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Type.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/ui/theme/Type.kt @@ -74,6 +74,22 @@ data class FontSet(val display: FontFamily, val body: FontFamily, val plex: Font } } +/** 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 { 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")) + } +} From 40ffa8c162f456d3d32645769266518f95069189 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Mon, 9 Nov 2026 16:03:44 +0200 Subject: [PATCH 64/75] docs: the Weblate components, Import included --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4caa80a..6794e845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -122,7 +122,7 @@ notes from other apps). The only string left in the Android resources is the lau **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 @@ -138,7 +138,7 @@ only change the text. If a Weblate pull request is open, say so in yours; Weblat `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`); the components must point there after the move from `app/src/main/res/`. +`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, From ca7ea96fb39eee2563e1c59e3728f3f04dfc50bd Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 11:40:43 +0200 Subject: [PATCH 65/75] docs: release notes of 3.0.0 for android only Refs: #92 --- CHANGELOG.md | 7 +++++-- fastlane/metadata/android/ar/changelogs/30000.txt | 1 - fastlane/metadata/android/de-DE/changelogs/30000.txt | 1 - fastlane/metadata/android/en-US/changelogs/30000.txt | 1 - fastlane/metadata/android/es-ES/changelogs/30000.txt | 1 - fastlane/metadata/android/fr-FR/changelogs/30000.txt | 1 - fastlane/metadata/android/it-IT/changelogs/30000.txt | 1 - fastlane/metadata/android/nl-NL/changelogs/30000.txt | 1 - fastlane/metadata/android/pl-PL/changelogs/30000.txt | 1 - fastlane/metadata/android/pt-PT/changelogs/30000.txt | 1 - fastlane/metadata/android/uk/changelogs/30000.txt | 1 - 11 files changed, 5 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c32abd6..50175143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ 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. @@ -18,8 +23,6 @@ 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)). -- 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)). - 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)). diff --git a/fastlane/metadata/android/ar/changelogs/30000.txt b/fastlane/metadata/android/ar/changelogs/30000.txt index 4c500721..6781c055 100644 --- a/fastlane/metadata/android/ar/changelogs/30000.txt +++ b/fastlane/metadata/android/ar/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• متوفر الآن أيضًا على Windows وmacOS وLinux وiPhone. • رمز PIN من 6 إلى 12 رقمًا؛ يبقى الرمز بعد الفتح بعبارة الاسترداد. • تحديد النص عبر عدة فقرات؛ جداول وكتل برمجية وقوائم متداخلة وسحب العناصر. • شريط أدوات قابل للتخصيص؛ إخفاء نص الملاحظات في القوائم. diff --git a/fastlane/metadata/android/de-DE/changelogs/30000.txt b/fastlane/metadata/android/de-DE/changelogs/30000.txt index d9e15bcf..b466f404 100644 --- a/fastlane/metadata/android/de-DE/changelogs/30000.txt +++ b/fastlane/metadata/android/de-DE/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Jetzt auch für Windows, macOS, Linux und iPhone. • 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. • Eigene Werkzeugleiste; Notiztext in Listen ausblenden. diff --git a/fastlane/metadata/android/en-US/changelogs/30000.txt b/fastlane/metadata/android/en-US/changelogs/30000.txt index 7399ef36..bfca0582 100644 --- a/fastlane/metadata/android/en-US/changelogs/30000.txt +++ b/fastlane/metadata/android/en-US/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Now also on Windows, macOS, Linux and iPhone. • 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. • Arrange your own toolbar; hide note text in lists. diff --git a/fastlane/metadata/android/es-ES/changelogs/30000.txt b/fastlane/metadata/android/es-ES/changelogs/30000.txt index 968c2ac5..42adde92 100644 --- a/fastlane/metadata/android/es-ES/changelogs/30000.txt +++ b/fastlane/metadata/android/es-ES/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Ahora también en Windows, macOS, Linux y iPhone. • 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. • Barra de herramientas a tu gusto; ocultar el texto de las notas. diff --git a/fastlane/metadata/android/fr-FR/changelogs/30000.txt b/fastlane/metadata/android/fr-FR/changelogs/30000.txt index ed43625c..2ed7ebe5 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/30000.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Aussi sur Windows, macOS, Linux et iPhone. • 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. • Barre d'outils personnalisable ; masquer le texte des notes. diff --git a/fastlane/metadata/android/it-IT/changelogs/30000.txt b/fastlane/metadata/android/it-IT/changelogs/30000.txt index e0498b87..7358d0f8 100644 --- a/fastlane/metadata/android/it-IT/changelogs/30000.txt +++ b/fastlane/metadata/android/it-IT/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Ora anche su Windows, macOS, Linux e iPhone. • 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. • Barra degli strumenti personalizzabile; nascondi il testo delle note. diff --git a/fastlane/metadata/android/nl-NL/changelogs/30000.txt b/fastlane/metadata/android/nl-NL/changelogs/30000.txt index 054089ac..9277bd4b 100644 --- a/fastlane/metadata/android/nl-NL/changelogs/30000.txt +++ b/fastlane/metadata/android/nl-NL/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Nu ook op Windows, macOS, Linux en iPhone. • Pincode van 6–12 cijfers; pincode blijft na ontgrendelen met de herstelzin. • Tekst over alinea's selecteren; tabellen, codeblokken, geneste lijsten, items slepen. • Eigen werkbalk; notitietekst in lijsten verbergen. diff --git a/fastlane/metadata/android/pl-PL/changelogs/30000.txt b/fastlane/metadata/android/pl-PL/changelogs/30000.txt index 090efddf..11c62714 100644 --- a/fastlane/metadata/android/pl-PL/changelogs/30000.txt +++ b/fastlane/metadata/android/pl-PL/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Teraz także na Windows, macOS, Linux i iPhone. • 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. • Własny pasek narzędzi; ukrywanie tekstu notatek na listach. diff --git a/fastlane/metadata/android/pt-PT/changelogs/30000.txt b/fastlane/metadata/android/pt-PT/changelogs/30000.txt index 9259af26..fc5e79c0 100644 --- a/fastlane/metadata/android/pt-PT/changelogs/30000.txt +++ b/fastlane/metadata/android/pt-PT/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Agora também em Windows, macOS, Linux e iPhone. • 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. • Barra de ferramentas à sua medida; ocultar o texto das notas. diff --git a/fastlane/metadata/android/uk/changelogs/30000.txt b/fastlane/metadata/android/uk/changelogs/30000.txt index 18444001..cf403044 100644 --- a/fastlane/metadata/android/uk/changelogs/30000.txt +++ b/fastlane/metadata/android/uk/changelogs/30000.txt @@ -1,5 +1,4 @@ Encly 3.0.0 -• Тепер і на Windows, macOS, Linux та iPhone. • PIN від 6 до 12 цифр; PIN лишається після входу фразою відновлення. • Виділення тексту через кілька абзаців; таблиці, блоки коду, вкладені списки, перетягування пунктів. • Власний тулбар; можна сховати текст нотаток у списках. From bd9e13a4cb773682cd0e3094f2cc6fb08e71ca5d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 13:49:20 +0200 Subject: [PATCH 66/75] fix: a task or note never shows twice while its state changes 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. --- core/database/api/android/database.api | 4 ++-- core/database/api/database.klib.api | 4 ++-- core/database/api/desktop/database.api | 4 ++-- .../encly/data/database/NotesDaoTest.kt | 4 ++-- .../encly/data/database/dao/NotesDao.kt | 9 +++++--- .../data/repository/NotesRepositoryImpl.kt | 4 ++-- .../domain/repository/NotesRepository.kt | 2 +- .../usecase/note/ObserveNotesUseCase.kt | 10 ++++----- .../presentation/viewmodel/TasksViewModel.kt | 14 ++++++------- .../viewmodel/TasksViewModelTest.kt | 21 +++++++++++++++++++ .../encly/testutil/TestNotesRepository.kt | 6 ++++-- .../encly/testutil/TestTasksRepository.kt | 2 +- 12 files changed, 54 insertions(+), 30 deletions(-) diff --git a/core/database/api/android/database.api b/core/database/api/android/database.api index 9324b233..9b2ca6a8 100644 --- a/core/database/api/android/database.api +++ b/core/database/api/android/database.api @@ -156,10 +156,10 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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 getArchivedNotesByTagId (J)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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; @@ -181,10 +181,10 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich 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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; public fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; public fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; diff --git a/core/database/api/database.klib.api b/core/database/api/database.klib.api index 9d40d866..33fc7285 100644 --- a/core/database/api/database.klib.api +++ b/core/database/api/database.klib.api @@ -36,9 +36,9 @@ abstract interface com.pasich.encly.data.database.dao/BackupDao { // com.pasich. 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 getArchivedNotesByTagId(kotlin/Long): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getArchivedNotesByTagId|getArchivedNotesByTagId(kotlin.Long){}[0] abstract fun getArchivedNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getArchivedNotesWithTags|getArchivedNotesWithTags(){}[0] abstract fun getNotesByTagId(kotlin/Long?): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getNotesByTagId|getNotesByTagId(kotlin.Long?){}[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 observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao.observeNoteWithTag|observeNoteWithTag(kotlin.Long){}[0] abstract suspend fun deleteNoteById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.deleteNoteById|deleteNoteById(kotlin.Long){}[0] @@ -142,9 +142,9 @@ final class com.pasich.encly.data.database.dao/NotesDao_Impl : com.pasich.encly. 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 getArchivedNotesByTagId(kotlin/Long): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getArchivedNotesByTagId|getArchivedNotesByTagId(kotlin.Long){}[0] final fun getArchivedNotesWithTags(): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getArchivedNotesWithTags|getArchivedNotesWithTags(){}[0] final fun getNotesByTagId(kotlin/Long?): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getNotesByTagId|getNotesByTagId(kotlin.Long?){}[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 observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao_Impl.observeNoteWithTag|observeNoteWithTag(kotlin.Long){}[0] final suspend fun deleteNoteById(kotlin/Long): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.deleteNoteById|deleteNoteById(kotlin.Long){}[0] diff --git a/core/database/api/desktop/database.api b/core/database/api/desktop/database.api index 70a4702e..38a3536f 100644 --- a/core/database/api/desktop/database.api +++ b/core/database/api/desktop/database.api @@ -155,10 +155,10 @@ public abstract interface class com/pasich/encly/data/database/dao/NotesDao { 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 getArchivedNotesByTagId (J)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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; @@ -180,10 +180,10 @@ public final class com/pasich/encly/data/database/dao/NotesDao_Impl : com/pasich 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 getArchivedNotesByTagId (J)Lkotlinx/coroutines/flow/Flow; public fun getArchivedNotesWithTags ()Lkotlinx/coroutines/flow/Flow; public fun getNoteById (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; 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 index eee60aa0..21ccc29d 100644 --- 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 @@ -257,7 +257,7 @@ class NotesDaoTest { } @Test - fun aTagsArchivedNotesAreReadByTag() = runBlocking { + fun aTagsNotesAreReadWithItsArchivedOnesInOneQuery() = runBlocking { insert("live", tagId = TAG) insert("old", tagId = TAG) { isArchived = true } insert("trashed", tagId = TAG) { @@ -266,7 +266,7 @@ class NotesDaoTest { } insert("other", tagId = OTHER_TAG) { isArchived = true } - assertEquals(listOf("old"), titles(dao.getArchivedNotesByTagId(TAG))) + assertEquals(listOf("live", "old"), titles(dao.getNotesOfTagWithArchived(TAG))) } @Test 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 index 00b866e5..7b4982b4 100644 --- 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 @@ -34,10 +34,13 @@ public interface NotesDao { ) public fun getNotesByTagId(tagId: Long?): Flow> - /** The archived notes (not in the trash) of one tag: the way to a hidden tag's archived notes. */ + /** + * The notes (not in the trash) of one tag, 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. + */ @Transaction - @Query("SELECT * FROM notes WHERE isTrash = 0 AND isArchived = 1 AND tagId = :tagId") - public fun getArchivedNotesByTagId(tagId: Long): Flow> + @Query("SELECT * FROM notes WHERE isTrash = 0 AND tagId = :tagId") + public fun getNotesOfTagWithArchived(tagId: Long): Flow> @Transaction @Query("SELECT * FROM notes WHERE id = :noteId") 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 index 35736b53..4ddf9721 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -24,8 +24,8 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note override fun getArchivedNotesWithTag(): Flow> = daoFlow { dao().getArchivedNotesWithTags() } - override fun getArchivedNotesByTagId(tagId: Long): Flow> = - daoFlow { dao().getArchivedNotesByTagId(tagId) } + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + daoFlow { dao().getNotesOfTagWithArchived(tagId) } override fun observeNote(noteId: Long): Flow = daoFlow { dao().observeNoteWithTag(noteId) } 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 index 468ec9d1..ed769296 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -13,7 +13,7 @@ interface NotesRepository { fun getAllNotesWithTag(): Flow> fun getNotesByTagId(tagId: Long): Flow> fun getArchivedNotesWithTag(): Flow> - fun getArchivedNotesByTagId(tagId: Long): Flow> + fun getNotesOfTagWithArchived(tagId: Long): Flow> fun getTrashNotes(): Flow> /** The note as stored now (null once it is gone), following every change to it. */ 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 index d517fd6e..dc47d4ca 100644 --- 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 @@ -9,7 +9,6 @@ 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.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -38,10 +37,11 @@ class ObserveNotesUseCase( 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 } + // 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. + else -> repository.getNotesOfTagWithArchived(tagId).map { all -> + all.filter { !it.note.isArchived || it.tag?.isVisible == false } } } return notes.map { list -> list.toSortedItems(sortOption) }.flowOn(dispatcher) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index 63c596c7..babad246 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -207,14 +207,12 @@ class TasksViewModel( 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 diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt index 4db32354..b32baba1 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModelTest.kt @@ -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/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt index 0f3a7759..382ef039 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -73,8 +73,10 @@ internal class TestNotesRepository : NotesRepository { override fun getArchivedNotesWithTag(): Flow> = archivedNotesWithTags - override fun getArchivedNotesByTagId(tagId: Long): Flow> = - archivedNotesWithTags.map { list -> list.filter { it.note.tagId == tagId } } + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + combine(tagFlow(tagId), archivedNotesWithTags) { live, archived -> + live + archived.filter { it.note.tagId == tagId } + } override fun observeNote(noteId: Long): Flow = combine(allNotesWithTags, archivedNotesWithTags) { a, diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTasksRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestTasksRepository.kt index 7479c187..bcc38bb8 100644 --- a/shared/src/commonTest/kotlin/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 } From 2952e1c254fb83e973ae6d7e2f44c1159825c3d3 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 13:49:26 +0200 Subject: [PATCH 67/75] docs: the task list crash in the 3.0.0 changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50175143..fc1de5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ versionCode 30000. ### 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)). From 41f8b4a85dad44d2c51dec9c9c1638fd999c1b1d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:43:24 +0200 Subject: [PATCH 68/75] style: format and detekt fixes for the duplicate key fix --- .../pasich/encly/presentation/viewmodel/TasksViewModel.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt index babad246..4bb5159c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TasksViewModel.kt @@ -212,7 +212,13 @@ class TasksViewModel( 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) + 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 From 84609f26f779e36fc4c9edebe5e5e8dd9bdb9a92 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:57:07 +0200 Subject: [PATCH 69/75] feat(database): a note can have several tags 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 --- core/database/api/android/database.api | 123 ++++-- core/database/api/database.klib.api | 142 +++++-- core/database/api/desktop/database.api | 123 ++++-- .../8.json | 398 ++++++++++++++++++ .../data/database/AppDatabaseSchemaTest.kt | 31 ++ .../pasich/encly/data/database/TagsDaoTest.kt | 9 +- .../encly/data/database/NotesDaoTest.kt | 130 ++++-- .../encly/data/database/VaultMigrationTest.kt | 65 ++- .../pasich/encly/data/database/AppDatabase.kt | 5 +- .../pasich/encly/data/database/VaultSchema.kt | 72 +++- .../encly/data/database/dao/BackupDao.kt | 17 +- .../encly/data/database/dao/NotesDao.kt | 126 +++++- .../pasich/encly/data/database/dao/TagsDao.kt | 13 +- .../com/pasich/encly/data/model/Note.kt | 11 +- .../com/pasich/encly/data/model/NoteTag.kt | 33 ++ .../pasich/encly/data/model/NoteWithTag.kt | 13 - .../pasich/encly/data/model/NoteWithTags.kt | 16 + .../encly/data/database/DesktopDaoTest.kt | 42 +- .../data/database/DesktopMigrationTest.kt | 70 +++ 19 files changed, 1239 insertions(+), 200 deletions(-) create mode 100644 core/database/schemas/com.pasich.encly.data.database.AppDatabase/8.json create mode 100644 core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteTag.kt delete mode 100644 core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTag.kt create mode 100644 core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTags.kt diff --git a/core/database/api/android/database.api b/core/database/api/android/database.api index 9b2ca6a8..2d17441a 100644 --- a/core/database/api/android/database.api +++ b/core/database/api/android/database.api @@ -110,41 +110,50 @@ public final class com/pasich/encly/data/database/VaultSchema { 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 tagById (JLkotlin/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 tagById (JLkotlin/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 { @@ -152,50 +161,76 @@ public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { } 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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -310,26 +345,26 @@ 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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)V - public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 ()Ljava/lang/String; + public final fun component10 ()Z public final fun component11 ()Z - public final fun component12 ()Z - public final fun component13 ()Ljava/lang/String; + 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/Integer; - public final fun component16 ()Ljava/lang/Long; + 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 ()Ljava/lang/Long; + public final fun component7 ()Z public final fun component8 ()Z - public final fun component9 ()Z - public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + 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 @@ -337,7 +372,7 @@ public final class com/pasich/encly/data/model/Note { public final fun getDescription ()Ljava/lang/String; public final fun getId ()J public final fun getLineSpacing ()Ljava/lang/Integer; - public final fun getTagId ()Ljava/lang/Long; + 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; @@ -359,7 +394,7 @@ public final class com/pasich/encly/data/model/Note { public final fun setId (J)V public final fun setLineSpacing (Ljava/lang/Integer;)V public final fun setPinned (Z)V - public final fun setTagId (Ljava/lang/Long;)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 @@ -370,19 +405,32 @@ public final class com/pasich/encly/data/model/Note { } public final class com/pasich/encly/data/model/Note$Companion { - public final fun new (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)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/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + 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/NoteWithTag { - public fun (Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;)V +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 ()Lcom/pasich/encly/data/model/Tag; - public final fun copy (Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;)Lcom/pasich/encly/data/model/NoteWithTag; - public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteWithTag;Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;ILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteWithTag; + 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 getTag ()Lcom/pasich/encly/data/model/Tag; + public final fun getTags ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -435,6 +483,19 @@ public final class com/pasich/encly/data/model/Tag { 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 diff --git a/core/database/api/database.klib.api b/core/database/api/database.klib.api index 33fc7285..e3002720 100644 --- a/core/database/api/database.klib.api +++ b/core/database/api/database.klib.api @@ -18,42 +18,55 @@ final enum class com.pasich.encly.data.database/VaultFile : kotlin/Enum // 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 tagById(kotlin/Long): com.pasich.encly.data.model/Tag? // com.pasich.encly.data.database.dao/BackupDao.tagById|tagById(kotlin.Long){}[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 getNotesByTagId(kotlin/Long?): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getNotesByTagId|getNotesByTagId(kotlin.Long?){}[0] - abstract fun getNotesOfTagWithArchived(kotlin/Long): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao.getNotesOfTagWithArchived|getNotesOfTagWithArchived(kotlin.Long){}[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 observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao.observeNoteWithTag|observeNoteWithTag(kotlin.Long){}[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/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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?){}[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 updateTagId(kotlin/Long, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao.updateTagId|updateTagId(kotlin.Long;kotlin.Long?){}[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] @@ -118,19 +131,23 @@ abstract class com.pasich.encly.data.database/AppDatabase : androidx.room/RoomDa 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 tagById(kotlin/Long): com.pasich.encly.data.model/Tag? // com.pasich.encly.data.database.dao/BackupDao_Impl.tagById|tagById(kotlin.Long){}[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] @@ -141,24 +158,33 @@ final class com.pasich.encly.data.database.dao/NotesDao_Impl : com.pasich.encly. 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 getNotesByTagId(kotlin/Long?): kotlinx.coroutines.flow/Flow> // com.pasich.encly.data.database.dao/NotesDao_Impl.getNotesByTagId|getNotesByTagId(kotlin.Long?){}[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 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 observeNoteWithTag(kotlin/Long): kotlinx.coroutines.flow/Flow // com.pasich.encly.data.database.dao/NotesDao_Impl.observeNoteWithTag|observeNoteWithTag(kotlin.Long){}[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/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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Int?;kotlin.Boolean;kotlin.Int?){}[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 updateTagId(kotlin/Long, kotlin/Long?): kotlin/Int // com.pasich.encly.data.database.dao/NotesDao_Impl.updateTagId|updateTagId(kotlin.Long;kotlin.Long?){}[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] @@ -265,7 +291,7 @@ final class com.pasich.encly.data.database/SecureDatabaseManager : com.pasich.en } 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/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ...) // com.pasich.encly.data.model/Note.|(kotlin.Long;kotlin.String;kotlin.String;kotlin.String;kotlin.Long;kotlin.Long;kotlin.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?){}[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] @@ -297,9 +323,9 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No 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 tagId // com.pasich.encly.data.model/Note.tagId|{}tagId[0] - final fun (): kotlin/Long? // com.pasich.encly.data.model/Note.tagId.|(){}[0] - final fun (kotlin/Long?) // com.pasich.encly.data.model/Note.tagId.|(kotlin.Long?){}[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] @@ -317,22 +343,22 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No 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/String // com.pasich.encly.data.model/Note.component10|component10(){}[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/Boolean // com.pasich.encly.data.model/Note.component12|component12(){}[0] - final fun component13(): kotlin/String // com.pasich.encly.data.model/Note.component13|component13(){}[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/Int? // com.pasich.encly.data.model/Note.component15|component15(){}[0] - final fun component16(): kotlin/Long? // com.pasich.encly.data.model/Note.component16|component16(){}[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/Long? // com.pasich.encly.data.model/Note.component7|component7(){}[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/Boolean // 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/Long? = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String = ..., kotlin/Int? = ..., kotlin/Int? = ..., kotlin/Long? = ...): 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.Long?;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?;kotlin.Long?){}[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] @@ -343,24 +369,40 @@ final class com.pasich.encly.data.model/Note { // com.pasich.encly.data.model/No 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/Long? = ...): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/Note.Companion.new|new(kotlin.String;kotlin.String;kotlin.Long?){}[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/NoteWithTag { // com.pasich.encly.data.model/NoteWithTag|null[0] - constructor (com.pasich.encly.data.model/Note, com.pasich.encly.data.model/Tag?) // com.pasich.encly.data.model/NoteWithTag.|(com.pasich.encly.data.model.Note;com.pasich.encly.data.model.Tag?){}[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 note // com.pasich.encly.data.model/NoteWithTag.note|{}note[0] - final fun (): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/NoteWithTag.note.|(){}[0] - final val tag // com.pasich.encly.data.model/NoteWithTag.tag|{}tag[0] - final fun (): com.pasich.encly.data.model/Tag? // com.pasich.encly.data.model/NoteWithTag.tag.|(){}[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(): com.pasich.encly.data.model/Note // com.pasich.encly.data.model/NoteWithTag.component1|component1(){}[0] - final fun component2(): com.pasich.encly.data.model/Tag? // com.pasich.encly.data.model/NoteWithTag.component2|component2(){}[0] - final fun copy(com.pasich.encly.data.model/Note = ..., com.pasich.encly.data.model/Tag? = ...): com.pasich.encly.data.model/NoteWithTag // com.pasich.encly.data.model/NoteWithTag.copy|copy(com.pasich.encly.data.model.Note;com.pasich.encly.data.model.Tag?){}[0] - final fun equals(kotlin/Any?): kotlin/Boolean // com.pasich.encly.data.model/NoteWithTag.equals|equals(kotlin.Any?){}[0] - final fun hashCode(): kotlin/Int // com.pasich.encly.data.model/NoteWithTag.hashCode|hashCode(){}[0] - final fun toString(): kotlin/String // com.pasich.encly.data.model/NoteWithTag.toString|toString(){}[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] @@ -421,6 +463,22 @@ final class com.pasich.encly.data.model/Tag { // com.pasich.encly.data.model/Tag 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] @@ -502,6 +560,8 @@ final object com.pasich.encly.data.database/VaultSchema { // com.pasich.encly.da 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] diff --git a/core/database/api/desktop/database.api b/core/database/api/desktop/database.api index 38a3536f..ff00ce77 100644 --- a/core/database/api/desktop/database.api +++ b/core/database/api/desktop/database.api @@ -109,41 +109,50 @@ public final class com/pasich/encly/data/database/VaultSchema { 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 tagById (JLkotlin/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 tagById (JLkotlin/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 { @@ -151,50 +160,76 @@ public final class com/pasich/encly/data/database/dao/BackupDao_Impl$Companion { } 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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/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 getNotesByTagId (Ljava/lang/Long;)Lkotlinx/coroutines/flow/Flow; + 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 observeNoteWithTag (J)Lkotlinx/coroutines/flow/Flow; + 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;JZLjava/lang/Long;ZZZLjava/lang/String;ZLjava/lang/Integer;ZLjava/lang/Integer;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 updateTagId (JLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun updateTrashed (JZLjava/lang/Long;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -309,26 +344,26 @@ 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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)V - public synthetic fun (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 ()Ljava/lang/String; + public final fun component10 ()Z public final fun component11 ()Z - public final fun component12 ()Z - public final fun component13 ()Ljava/lang/String; + 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/Integer; - public final fun component16 ()Ljava/lang/Long; + 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 ()Ljava/lang/Long; + public final fun component7 ()Z public final fun component8 ()Z - public final fun component9 ()Z - public final fun copy (JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;)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;JJLjava/lang/Long;ZZLjava/lang/String;ZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + 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 @@ -336,7 +371,7 @@ public final class com/pasich/encly/data/model/Note { public final fun getDescription ()Ljava/lang/String; public final fun getId ()J public final fun getLineSpacing ()Ljava/lang/Integer; - public final fun getTagId ()Ljava/lang/Long; + 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; @@ -358,7 +393,7 @@ public final class com/pasich/encly/data/model/Note { public final fun setId (J)V public final fun setLineSpacing (Ljava/lang/Integer;)V public final fun setPinned (Z)V - public final fun setTagId (Ljava/lang/Long;)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 @@ -369,19 +404,32 @@ public final class com/pasich/encly/data/model/Note { } public final class com/pasich/encly/data/model/Note$Companion { - public final fun new (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)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/lang/Long;ILjava/lang/Object;)Lcom/pasich/encly/data/model/Note; + 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/NoteWithTag { - public fun (Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;)V +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 ()Lcom/pasich/encly/data/model/Tag; - public final fun copy (Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;)Lcom/pasich/encly/data/model/NoteWithTag; - public static synthetic fun copy$default (Lcom/pasich/encly/data/model/NoteWithTag;Lcom/pasich/encly/data/model/Note;Lcom/pasich/encly/data/model/Tag;ILjava/lang/Object;)Lcom/pasich/encly/data/model/NoteWithTag; + 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 getTag ()Lcom/pasich/encly/data/model/Tag; + public final fun getTags ()Ljava/util/List; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -434,6 +482,19 @@ public final class com/pasich/encly/data/model/Tag { 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 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 index 48f453e9..befe6f0b 100644 --- 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 @@ -231,10 +231,41 @@ class AppDatabaseSchemaTest { 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/TagsDaoTest.kt b/core/database/src/androidDeviceTest/kotlin/com/pasich/encly/data/database/TagsDaoTest.kt index e011bf4e..4a367a41 100644 --- 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 @@ -9,7 +9,6 @@ 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 @@ -38,12 +37,12 @@ class TagsDaoTest { 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)) + 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"))) - assertNull(db.notesDao().getNoteById(workNote)?.tagId) - assertEquals(home, db.notesDao().getNoteById(homeNote)?.tagId) + 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/data/database/NotesDaoTest.kt b/core/database/src/androidHostTest/kotlin/com/pasich/encly/data/database/NotesDaoTest.kt index 21ccc29d..3c9e4ab6 100644 --- 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 @@ -4,7 +4,7 @@ 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.NoteWithTags import com.pasich.encly.data.model.Tag import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -36,6 +36,7 @@ class NotesDaoTest { 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 @@ -43,10 +44,12 @@ class NotesDaoTest { 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 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 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 { @@ -71,12 +74,74 @@ class NotesDaoTest { } @Test - fun aTagFilterLeavesOutArchivedNotes() = runBlocking { - insert("kept", tagId = TAG) - insert("archived", tagId = TAG) { isArchived = true } - insert("other", tagId = OTHER_TAG) + 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()) + } - assertEquals(listOf("kept"), titles(dao.getNotesByTagId(TAG))) + @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 @@ -113,7 +178,7 @@ class NotesDaoTest { // ...and the editor, which opened the note before, saves its text and the colour it changed. dao.updateEdited( - id, "edited", "new", false, "", 0L, false, null, false, false, true, "teal", false, null, false, null, + id, "edited", "new", false, "", 0L, false, false, true, "teal", false, null, false, null, ) val stored = checkNotNull(dao.getNoteById(id)) @@ -132,7 +197,7 @@ class NotesDaoTest { } dao.updateEdited( - id, "note", "text", false, "", 0L, false, null, false, true, false, "none", false, null, false, null, + 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. @@ -146,7 +211,7 @@ class NotesDaoTest { val id = insert("note") { isArchived = true } dao.updateEdited( - id, "note", "", false, "", 0L, false, null, true, true, false, "none", false, null, false, null, + id, "note", "", false, "", 0L, true, true, false, "none", false, null, false, null, ) assertEquals(false, checkNotNull(dao.getNoteById(id)).isPinned) @@ -160,13 +225,13 @@ class NotesDaoTest { } dao.updateEdited( - id, "note", "same", false, "", 99L, false, null, false, false, false, "none", false, null, false, null, + 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, null, false, false, false, "none", false, null, false, null, + 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) @@ -179,7 +244,7 @@ class NotesDaoTest { assertTrue(uid.isNotEmpty()) dao.updateEdited( - id, "again", "x", false, "", 0L, false, null, false, false, false, "none", false, null, false, null, + id, "again", "x", false, "", 0L, false, false, false, "none", false, null, false, null, ) assertEquals(uid, checkNotNull(dao.getNoteById(id)).uid) @@ -200,22 +265,20 @@ class NotesDaoTest { @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) + 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.updateTagId(id, OTHER_TAG) + dao.changeNoteTags(id, added = listOf(OTHER_TAG), removed = listOf(TAG)) dao.updateColor(stale.id, "teal") - val stored = checkNotNull(dao.getNoteById(id)) - assertEquals(OTHER_TAG, stored.tagId) - assertEquals("teal", stored.color) + assertEquals(setOf(OTHER_TAG), tagsOf(id)) + assertEquals("teal", checkNotNull(dao.getNoteById(id)).color) } @Test fun everySingleColumnWriteLeavesTheOtherColumnsAlone() = runBlocking { - val id = insert("note", tagId = TAG) { + val id = insert("note", TAG) { description = "d" color = "red" isPinned = true @@ -225,8 +288,8 @@ class NotesDaoTest { 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)) + dao.clearNoteTags(id) + assertEquals(before.copy(description = "new", color = "blue"), dao.getNoteById(id)) } @Test @@ -258,26 +321,25 @@ class NotesDaoTest { @Test fun aTagsNotesAreReadWithItsArchivedOnesInOneQuery() = runBlocking { - insert("live", tagId = TAG) - insert("old", tagId = TAG) { isArchived = true } - insert("trashed", tagId = TAG) { + insert("live", TAG) + insert("old", TAG) { isArchived = true } + insert("trashed", TAG) { isArchived = true isTrash = true } - insert("other", tagId = OTHER_TAG) { isArchived = true } + insert("other", OTHER_TAG) { isArchived = true } assertEquals(listOf("live", "old"), titles(dao.getNotesOfTagWithArchived(TAG))) } @Test - fun aNoteCanBeObservedByIdWithItsTag() = runBlocking { - db.tagsDao().addTags(listOf(Tag(id = TAG, nameTag = "X"))) - val id = insert("note", tagId = TAG) { isArchived = true } + fun aNoteCanBeObservedByIdWithItsTags() = runBlocking { + val id = insert("note", TAG, OTHER_TAG) { isArchived = true } - val live = checkNotNull(dao.observeNoteWithTag(id).first()) + val live = checkNotNull(dao.observeNoteWithTags(id).first()) - assertEquals("X", live.tag?.nameTag) - assertEquals(null, dao.observeNoteWithTag(999).first()) + assertEquals(setOf("X", "Y"), live.tags.map { it.nameTag }.toSet()) + assertEquals(null, dao.observeNoteWithTags(999).first()) } @Test 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 index 85392ab6..70470168 100644 --- 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 @@ -52,7 +52,7 @@ class VaultMigrationTest { * 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) { + 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 -> @@ -72,7 +72,7 @@ class VaultMigrationTest { override fun onCreate(db: SupportSQLiteDatabase) { statements.forEach(db::execSQL) VaultSchema.triggerStatements().forEach(db::execSQL) - db.execSQL(noteSql) + rowsSql.forEach(db::execSQL) } override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit @@ -174,6 +174,66 @@ class VaultMigrationTest { } } + 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() @@ -182,6 +242,7 @@ class VaultMigrationTest { } 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/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 index 9a8f32d5..deaf9e1e 100644 --- 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 @@ -9,15 +9,16 @@ 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 = 7 +public const val DB_VERSION: Int = 8 @Database( - entities = [Note::class, Tag::class, Task::class, Subtask::class], + entities = [Note::class, Tag::class, Task::class, Subtask::class, NoteTag::class], version = DB_VERSION, exportSchema = true, ) 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 index 94b4d0b1..e6832551 100644 --- 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 @@ -39,6 +39,11 @@ public object VaultSchema { /** 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 " + @@ -161,6 +166,63 @@ public object VaultSchema { } } + /** + * 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 @@ -168,7 +230,15 @@ public object VaultSchema { * 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) + .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. */ 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 index 5cf49ba3..b4a41019 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -22,8 +23,14 @@ public interface BackupDao { @Query("SELECT * FROM tags ORDER BY id") public suspend fun allTags(): List - @Query("SELECT * FROM tags WHERE id = :tagId") - public suspend fun tagById(tagId: Long): Tag? + @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 @@ -37,6 +44,9 @@ public interface BackupDao { @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 @@ -46,6 +56,9 @@ public interface BackupDao { @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() 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 index 7b4982b4..d56ba27c 100644 --- 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 @@ -7,7 +7,9 @@ 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 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. @@ -17,34 +19,36 @@ 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> + 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> - - @Transaction - @Query( - """ - SELECT * FROM notes - WHERE (:tagId IS NULL AND tagId IS NULL OR tagId = :tagId) - AND isTrash = 0 AND isArchived = 0 - """, - ) - public fun getNotesByTagId(tagId: Long?): Flow> + public fun getArchivedNotesWithTags(): Flow> /** - * The notes (not in the trash) of one tag, 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. + * 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 tagId = :tagId") - public fun getNotesOfTagWithArchived(tagId: Long): Flow> + @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 observeNoteWithTag(noteId: Long): Flow + 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> @@ -60,8 +64,35 @@ public interface NotesDao { @Query("UPDATE notes SET color = :color WHERE id = :noteId") public suspend fun updateColor(noteId: Long, color: String): Int - @Query("UPDATE notes SET tagId = :tagId WHERE id = :noteId") - public suspend fun updateTagId(noteId: Long, tagId: Long?): 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 @@ -85,6 +116,7 @@ public interface NotesDao { * 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. @@ -93,7 +125,6 @@ public interface NotesDao { 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, - tagId = CASE WHEN :setTag = 1 THEN :tagId ELSE tagId 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, @@ -108,8 +139,6 @@ public interface NotesDao { stamp: Boolean, description: String, date: Long, - setTag: Boolean, - tagId: Long?, setPinned: Boolean, pinned: Boolean, setColor: Boolean, @@ -120,6 +149,49 @@ public interface NotesDao { 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 @@ -134,6 +206,14 @@ public interface NotesDao { @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 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 index 7fc89e5d..789416c1 100644 --- 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 @@ -15,10 +15,12 @@ public interface TagsDao { @Query("SELECT * FROM tags") public fun getTags(): Flow> - @Insert(onConflict = OnConflictStrategy.REPLACE) + // 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.REPLACE) + @Insert(onConflict = OnConflictStrategy.ABORT) public suspend fun addTag(tag: Tag): Long @Update @@ -30,12 +32,13 @@ public interface TagsDao { @Delete public suspend fun deleteTag(tag: Tag): Int - @Query("UPDATE notes SET tagId = NULL WHERE tagId = :tagId") + @Query("DELETE FROM note_tags WHERE tagId = :tagId") public 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). + * 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 { 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 index 63babc51..9ced61ee 100644 --- 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 @@ -20,7 +20,6 @@ public data class Note( 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 = "''") @@ -41,6 +40,12 @@ public data class Note( * 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(), ) { /** @@ -63,12 +68,12 @@ public data class Note( /** 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, tagId: Long? = null): Note = Note( + public fun new(title: String, value: String, tagIds: Set = emptySet()): Note = Note( title = title, value = value, date = nowMillis(), dateCreate = nowMillis(), - tagId = tagId, + 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/NoteWithTag.kt b/core/database/src/commonMain/kotlin/com/pasich/encly/data/model/NoteWithTag.kt deleted file mode 100644 index 92dd15b7..00000000 --- a/core/database/src/commonMain/kotlin/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 - -public data class NoteWithTag( - @Embedded val note: Note, - @Relation( - parentColumn = "tagId", - entityColumn = "id", - ) - val tag: Tag?, -) 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/desktopTest/kotlin/com/pasich/encly/data/database/DesktopDaoTest.kt b/core/database/src/desktopTest/kotlin/com/pasich/encly/data/database/DesktopDaoTest.kt index 14f24b0a..908b8d0f 100644 --- 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 @@ -12,7 +12,6 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue /** The DAO transactions and the entity helpers, against a real encrypted vault on the desktop. */ @@ -39,15 +38,44 @@ class DesktopDaoTest { fun deletingATagUntagsItsNotesAndLeavesOthersAlone() = runBlocking { val work = db.tagsDao().addTag(Tag(nameTag = "Work")) val home = db.tagsDao().addTag(Tag(nameTag = "Home")) - val inWork = db.notesDao().insertNote(Note(title = "w", tagId = work)) - val inHome = db.notesDao().insertNote(Note(title = "h", tagId = 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")) - assertNull(checkNotNull(db.notesDao().getNoteById(inWork)).tagId) - assertEquals(home, checkNotNull(db.notesDao().getNoteById(inHome)).tagId) + 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")) @@ -79,9 +107,9 @@ class DesktopDaoTest { @Test fun theHelpersOfNotesAndTasksFillTheirDefaults() { - val note = Note.new("title", "[]", tagId = 3) + val note = Note.new("title", "[]", tagIds = setOf(3)) assertTrue(note.date > 0 && note.dateCreate > 0) - assertEquals(3L, note.tagId) + assertEquals(setOf(3L), note.tagIds) assertEquals(Note.NO_COLOR, note.color) val task = Task.new("t", description = "d", priority = 2, categoryId = 5) 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 index 5ea1d8f2..8df0275c 100644 --- 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 @@ -133,6 +133,8 @@ class DesktopMigrationTest { 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 @@ -211,6 +213,74 @@ class DesktopMigrationTest { 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" From c54eb0370c7a7cf300e6dc41e99750807868f622 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:57:26 +0200 Subject: [PATCH 70/75] feat(backup): backups, imports and exports carry every tag of a note 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 --- .../strings/values-ar/strings_import.xml | 1 - .../strings/values-de/strings_import.xml | 1 - .../strings/values-es/strings_import.xml | 1 - .../strings/values-fr/strings_import.xml | 1 - .../strings/values-it/strings_import.xml | 1 - .../strings/values-nl/strings_import.xml | 1 - .../strings/values-pl/strings_import.xml | 1 - .../strings/values-pt/strings_import.xml | 1 - .../strings/values-uk/strings_import.xml | 1 - .../strings/values/strings_import.xml | 1 - core/security/api/android/security.api | 13 +-- core/security/api/desktop/security.api | 13 +-- core/security/api/security.klib.api | 14 ++- .../pasich/encly/core/backup/BackupPayload.kt | 100 +++++++++++++++--- .../core/backup/BackupPayloadCodecTest.kt | 64 +++++++++-- .../encly/desktop/documents/PdfComposer.kt | 4 +- .../encly/desktop/NoteDocumentsPdfTest.kt | 11 +- .../presentation/viewmodel/BackupFlowsTest.kt | 6 +- .../viewmodel/ImportNotesViewModelTest.kt | 4 +- .../encly/ui/screens/ImportNotesScreenTest.kt | 2 +- .../encly/data/backup/BackupImporter.kt | 12 ++- .../encly/data/backup/RoomVaultDataStore.kt | 14 ++- .../encly/data/backup/VaultDataStore.kt | 29 ++++- .../encly/data/export/MarkdownExport.kt | 8 +- .../pasich/encly/data/export/MarkdownNotes.kt | 7 +- .../pasich/encly/data/export/NoteDocument.kt | 12 +-- .../data/handoff/MyNotesHandoffMapper.kt | 2 +- .../pasich/encly/data/importer/Frontmatter.kt | 68 +++++++++--- .../encly/data/importer/ImportMapper.kt | 12 +-- .../encly/data/importer/ImportReview.kt | 9 +- .../pasich/encly/data/importer/ImportTypes.kt | 5 +- .../encly/data/importer/JoplinParser.kt | 11 +- .../pasich/encly/data/importer/KeepParser.kt | 5 +- .../data/importer/MarkdownNotesParser.kt | 16 ++- .../data/importer/StandardNotesParser.kt | 3 +- .../screen/importnotes/ImportPages.kt | 2 - .../screen/importnotes/ImportReviewScreen.kt | 4 +- .../viewmodel/MarkdownExportViewModel.kt | 3 +- .../encly/data/backup/BackupImporterTest.kt | 42 +++++++- .../encly/data/export/NoteDocumentTest.kt | 17 +-- .../data/export/TablesAndCodeExportTest.kt | 2 +- .../encly/testutil/InMemoryVaultDataStore.kt | 16 +++ .../encly/data/backup/BackupManagerTest.kt | 2 +- .../encly/data/export/MarkdownExportTest.kt | 23 ++-- .../encly/data/export/MarkdownNotesTest.kt | 13 ++- .../data/handoff/MyNotesHandoffImportTest.kt | 8 +- .../encly/data/importer/ExpectedNote.kt | 6 +- .../encly/data/importer/ImportMapperTest.kt | 18 ++-- .../encly/data/importer/KeepParserTest.kt | 2 +- .../NotesnookAndMarkdownFixturesTest.kt | 4 +- .../encly/data/importer/RealExportsTest.kt | 26 +++-- .../encly/data/importer/TextParsersTest.kt | 5 +- .../domain/usecase/RestoreFromBackupTest.kt | 6 +- 53 files changed, 458 insertions(+), 195 deletions(-) diff --git a/core/resources/src/commonMain/strings/values-ar/strings_import.xml b/core/resources/src/commonMain/strings/values-ar/strings_import.xml index 21f01df7..e74bc237 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings_import.xml @@ -46,7 +46,6 @@ ملاحظات مثبّتة لن تبقى مثبّتة: %1$d ملاحظات مؤرشفة تصل كملاحظات عادية: %1$d ألوان ملاحظات لم تُحفظ: %1$d - وسوم إضافية أُسقطت (للملاحظة وسم واحد): %1$d ملاحظات مهام استُوردت كملاحظات عادية: %1$d عناصر مشفرة أو غير مدعومة تم تخطيها: %1$d ملفات تعذّر استخدامها: %1$d diff --git a/core/resources/src/commonMain/strings/values-de/strings_import.xml b/core/resources/src/commonMain/strings/values-de/strings_import.xml index aa62e595..d45d68a5 100644 --- a/core/resources/src/commonMain/strings/values-de/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-de/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-es/strings_import.xml b/core/resources/src/commonMain/strings/values-es/strings_import.xml index 3313d0eb..b27f95d4 100644 --- a/core/resources/src/commonMain/strings/values-es/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-es/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-fr/strings_import.xml b/core/resources/src/commonMain/strings/values-fr/strings_import.xml index da70ab91..cc036a07 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-it/strings_import.xml b/core/resources/src/commonMain/strings/values-it/strings_import.xml index 8061f494..ccfe09d8 100644 --- a/core/resources/src/commonMain/strings/values-it/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-it/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-nl/strings_import.xml b/core/resources/src/commonMain/strings/values-nl/strings_import.xml index 32d0ece5..ed3720a3 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pl/strings_import.xml b/core/resources/src/commonMain/strings/values-pl/strings_import.xml index df2a0c2c..7bbae07c 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-pt/strings_import.xml b/core/resources/src/commonMain/strings/values-pt/strings_import.xml index aa78f301..59ba206b 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings_import.xml @@ -44,7 +44,6 @@ 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 diff --git a/core/resources/src/commonMain/strings/values-uk/strings_import.xml b/core/resources/src/commonMain/strings/values-uk/strings_import.xml index dd0928bd..e4f8489c 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings_import.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings_import.xml @@ -44,7 +44,6 @@ Закріплених нотаток, які більше не будуть закріплені: %1$d Архівних нотаток, що стануть звичайними: %1$d Кольорів нотаток не збережено: %1$d - Зайвих тегів відкинуто (нотатка має лише один тег): %1$d Нотаток-завдань, імпортованих як звичайні нотатки: %1$d Зашифрованих або непідтримуваних елементів пропущено: %1$d Файлів, які не вдалося використати: %1$d diff --git a/core/resources/src/commonMain/strings/values/strings_import.xml b/core/resources/src/commonMain/strings/values/strings_import.xml index 991b207b..f75ad4e6 100644 --- a/core/resources/src/commonMain/strings/values/strings_import.xml +++ b/core/resources/src/commonMain/strings/values/strings_import.xml @@ -46,7 +46,6 @@ 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 diff --git a/core/security/api/android/security.api b/core/security/api/android/security.api index 65e067b0..8f1c6300 100644 --- a/core/security/api/android/security.api +++ b/core/security/api/android/security.api @@ -83,8 +83,8 @@ public final class com/pasich/encly/core/backup/BackupKeys { 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/lang/String;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/lang/String;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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; @@ -95,18 +95,18 @@ public final class com/pasich/encly/core/backup/BackupNote { public final fun component4 ()Ljava/lang/String; public final fun component5 ()J public final fun component6 ()J - public final fun component7 ()Ljava/lang/String; + 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/lang/String;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/lang/String;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupNote; + 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 getTagUid ()Ljava/lang/String; + 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; @@ -139,6 +139,7 @@ public final class com/pasich/encly/core/backup/BackupPayload { 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 diff --git a/core/security/api/desktop/security.api b/core/security/api/desktop/security.api index 65e067b0..8f1c6300 100644 --- a/core/security/api/desktop/security.api +++ b/core/security/api/desktop/security.api @@ -83,8 +83,8 @@ public final class com/pasich/encly/core/backup/BackupKeys { 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/lang/String;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/lang/String;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + 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; @@ -95,18 +95,18 @@ public final class com/pasich/encly/core/backup/BackupNote { public final fun component4 ()Ljava/lang/String; public final fun component5 ()J public final fun component6 ()J - public final fun component7 ()Ljava/lang/String; + 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/lang/String;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/lang/String;ZZZLjava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lcom/pasich/encly/core/backup/BackupNote; + 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 getTagUid ()Ljava/lang/String; + 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; @@ -139,6 +139,7 @@ public final class com/pasich/encly/core/backup/BackupPayload { 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 diff --git a/core/security/api/security.klib.api b/core/security/api/security.klib.api index b28ceb29..c0edcae0 100644 --- a/core/security/api/security.klib.api +++ b/core/security/api/security.klib.api @@ -427,7 +427,7 @@ final class com.pasich.encly.core.backup/BackupHeader { // com.pasich.encly.core } 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/String?, 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.String?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[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] @@ -445,8 +445,8 @@ final class com.pasich.encly.core.backup/BackupNote { // com.pasich.encly.core.b 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 tagUid // com.pasich.encly.core.backup/BackupNote.tagUid|{}tagUid[0] - final fun (): kotlin/String? // com.pasich.encly.core.backup/BackupNote.tagUid.|(){}[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] @@ -466,10 +466,10 @@ final class com.pasich.encly.core.backup/BackupNote { // com.pasich.encly.core.b 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/String? // com.pasich.encly.core.backup/BackupNote.component7|component7(){}[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/String? = ..., 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.String?;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String;kotlin.Int?;kotlin.Int?){}[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] @@ -484,6 +484,8 @@ final class com.pasich.encly.core.backup/BackupNote { // com.pasich.encly.core.b } 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] } } @@ -533,6 +535,8 @@ final class com.pasich.encly.core.backup/BackupPayload { // com.pasich.encly.cor 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] 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 index 6cb09f02..1bb84b92 100644 --- 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 @@ -34,6 +34,9 @@ import kotlinx.serialization.json.jsonObject * 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 @@ -52,7 +55,7 @@ public data class BackupPayload( val settings: Map = emptyMap(), ) { public companion object { - public const val SCHEMA_VERSION: Int = 5 + public const val SCHEMA_VERSION: Int = 6 /** Tasks carried a `reminderDate`, dropped on import. */ public const val SCHEMA_V1: Int = 1 @@ -65,6 +68,9 @@ public data class BackupPayload( /** 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 } } @@ -73,7 +79,8 @@ public data class BackupTag(val uid: String, val name: String, val visible: Bool /** * [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. + * [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( @@ -83,7 +90,7 @@ public data class BackupNote( val description: String, val date: Long, val dateCreate: Long, - val tagUid: String?, + val tagUids: List = emptyList(), val isTrash: Boolean, val isPinned: Boolean = false, val isArchived: Boolean = false, @@ -112,6 +119,66 @@ public data class BackupTask( @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 { @@ -158,14 +225,17 @@ public object BackupPayloadCodec { when (schema) { in BackupPayload.SCHEMA_V1..BackupPayload.SCHEMA_V3 -> { val tree = decodeStream(json, JsonElement.serializer(), plaintext) - json.decodeFromJsonElement(BackupPayload.serializer(), upgrade(tree.jsonObject, schema)) + val upgraded = upgrade(tree.jsonObject, schema) + json.decodeFromJsonElement(LegacyPayload.serializer(), upgraded).toCurrent() } - // The shape of the current schema without the section: decoded strictly in one pass (the - // commonest file, up to the size limit), not through a JSON tree. - BackupPayload.SCHEMA_V4 -> decodeStream(json, BackupPayload.serializer(), plaintext).also { + // 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" } - }.copy(schema = BackupPayload.SCHEMA_VERSION) + }.toCurrent() + + BackupPayload.SCHEMA_V5 -> decodeStream(json, LegacyPayload.serializer(), plaintext).toCurrent() else -> decodeStream(json, BackupPayload.serializer(), plaintext) } @@ -175,10 +245,10 @@ public object BackupPayloadCodec { } /** - * Schema 1 to 3 -> current (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 + * 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. A + * 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. */ @@ -197,7 +267,7 @@ public object BackupPayloadCodec { } require(SETTINGS_KEY !in old) { "Unexpected settings in schema $schema" } return JsonObject( - old + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_VERSION)) + + old + ("schema" to JsonPrimitive(BackupPayload.SCHEMA_V5)) + (tasks?.let { mapOf(TASKS_KEY to JsonArray(it)) }.orEmpty()), ) } @@ -220,7 +290,7 @@ public object BackupPayloadCodec { 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 { validNoteTags(it, tagUids) } && payload.notes.all(::validNoteStyle) && payload.tasks.all { it.priority in PRIORITIES && (it.categoryTagUid == null || it.categoryTagUid in tagUids) @@ -228,6 +298,10 @@ public object BackupPayloadCodec { 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 && 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 index 09333078..06c5a571 100644 --- 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 @@ -10,7 +10,7 @@ class BackupPayloadCodecTest { exportedAt = 1_700_000_000_000, tags = listOf(tag), notes = listOf( - BackupNote("n1", "Title", "[]", "desc", 2L, 1L, tagUid = "t1", isTrash = true), + BackupNote("n1", "Title", "[]", "desc", 2L, 1L, tagUids = listOf("t1"), isTrash = true), ), tasks = listOf( BackupTask( @@ -188,23 +188,64 @@ class BackupPayloadCodecTest { /** * 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-4 vector, kept byte for byte: a payload without preferences encodes - * exactly as the schema-4 app wrote it, apart from the schema number. + * 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(schema4Json(), BackupPayloadCodec.encode(withSubtasks.copy(schema = 4)).decodeToString()) + assertEquals(schema6Json(), BackupPayloadCodec.encode(withSubtasks).decodeToString()) } - /** The schema-5 vector: the same file plus the `settings` section, last, in the order given. */ + /** The same file plus the `settings` section, last, in the order given. */ @Test fun encodedSettingsSectionIsPinned() { - val expected = schema4Json().replaceFirst("\"schema\":4", "\"schema\":5").removeSuffix("}") + + 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")) @@ -235,7 +276,7 @@ class BackupPayloadCodecTest { 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 6. + // 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)) } @@ -277,6 +318,10 @@ class BackupPayloadCodecTest { """"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}],""" + @@ -294,7 +339,10 @@ class BackupPayloadCodecTest { @Test fun linksMustPointAtTagsInTheBackup() { - assertInvalid(payload.copy(notes = payload.notes.map { it.copy(tagUid = "missing") })) + 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") })) } 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 index 9afc2165..384f8a69 100644 --- a/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt +++ b/desktopApp/src/desktopMain/kotlin/com/pasich/encly/desktop/documents/PdfComposer.kt @@ -51,7 +51,9 @@ internal class PdfComposer( fun compose(note: NoteDocument) { newPage() paragraph(note.title, PageStyle.TITLE_SIZE, bold = true, color = PageStyle.INK) - note.tag?.let { paragraph(it, PageStyle.META_SIZE, color = PageStyle.QUIET) } + if (note.tags.isNotEmpty()) { + paragraph(note.tags.joinToString(", "), PageStyle.META_SIZE, color = PageStyle.QUIET) + } y -= PageStyle.BLOCK_GAP note.blocks.forEach { block -> draw(block) diff --git a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt index 23d886c8..d8a72d49 100644 --- a/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt +++ b/desktopApp/src/desktopTest/kotlin/com/pasich/encly/desktop/NoteDocumentsPdfTest.kt @@ -55,17 +55,18 @@ class NoteDocumentsPdfTest { private fun pagesOf(pdf: ByteArray): Int = Loader.loadPDF(pdf).use { it.numberOfPages } - private fun note(vararg blocks: DocumentBlock, title: String = "Title", tag: String? = null) = - NoteDocument(title, tag, blocks.toList()) + private fun note(vararg blocks: DocumentBlock, title: String = "Title", tags: List = emptyList()) = + NoteDocument(title, tags, blocks.toList()) @Test - fun theFileIsAPdfWithTheTitleTagAndText() { - val pdf = pdfOf(note(DocumentBlock.Paragraph("Buy bread and milk"), title = "Groceries", tag = "Home")) + 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" in text, text) + assertTrue("Home, Weekly" in text, text) assertTrue("Buy bread and milk" in text, text) } diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt index d16db08d..a1d3c6a1 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/BackupFlowsTest.kt @@ -19,6 +19,7 @@ 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 @@ -89,7 +90,8 @@ 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")) } @@ -187,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 diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt index 1bc5336d..60990c34 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/viewmodel/ImportNotesViewModelTest.kt @@ -99,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 }) } @@ -185,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 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 index 64eceb3b..1b5bb2c9 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/ImportNotesScreenTest.kt @@ -104,7 +104,7 @@ class ImportNotesScreenTest : ComposeScreenTest() { title = title, snippet = "snippet of $title", hasChecklist = uid == "a", - tag = if (uid == "a") "home" else null, + tags = if (uid == "a") listOf("home") else emptyList(), trashed = trashed, date = 1_700_000_000_000, existing = existing, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt index 9614a314..f256cc0c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/BackupImporter.kt @@ -7,6 +7,7 @@ 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 @@ -64,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, @@ -119,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 } @@ -222,13 +227,12 @@ 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() - 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/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt index 182f41fd..4a04e536 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/RoomVaultDataStore.kt @@ -7,6 +7,7 @@ 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 @@ -38,6 +39,7 @@ class RoomVaultDataStore private constructor( tags = db.backupDao().allTags(), tasks = db.backupDao().allTasks(), subtasks = db.backupDao().allSubtasks(), + noteTags = db.backupDao().allNoteTags(), ) } } @@ -47,8 +49,12 @@ class RoomVaultDataStore private constructor( db.useReaderConnection { it.deferredTransaction { val note = db.notesDao().getNoteById(noteId) - val tag = note?.tagId?.let { db.backupDao().tagById(it) } - VaultSnapshot(listOfNotNull(note), listOfNotNull(tag), emptyList()) + 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), + ) } } } @@ -58,6 +64,8 @@ class RoomVaultDataStore private constructor( 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() @@ -69,6 +77,8 @@ class RoomVaultDataStore private constructor( 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 index d56e9b53..d73444aa 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/VaultDataStore.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/backup/VaultDataStore.kt @@ -1,22 +1,38 @@ 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. */ +/** + * 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 @@ -26,10 +42,12 @@ interface VaultDataStore { suspend fun snapshot(): VaultSnapshot - /** Only note [noteId] and its tag: for exporting one note without reading the whole vault. */ + /** 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 } - VaultSnapshot(listOfNotNull(note), all.tags.filter { it.id == note?.tagId }, emptyList()) + 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. */ @@ -41,6 +59,9 @@ interface VaultDataStore { 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/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt index a8be5c9d..6c60e452 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownExport.kt @@ -28,13 +28,12 @@ object MarkdownExport { } internal fun writeAll(snapshot: VaultSnapshot, labels: MarkdownLabels, zip: ZipEntrySink) { - val tagNames = snapshot.tags.associate { it.id to it.nameTag } 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, tagNames[note.tagId], labels)) + zip.putText(name, note.date, MarkdownNotes.render(note, snapshot.tagNamesOf(note.id), labels)) } zip.finish() } @@ -42,11 +41,12 @@ object MarkdownExport { /** 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 - val tagName = snapshot.tags.firstOrNull { it.id == note.tagId }?.nameTag - out.write(MarkdownNotes.render(note, tagName, labels).encodeToByteArray()) + 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/MarkdownNotes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt index 40836942..eded6bd5 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/MarkdownNotes.kt @@ -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()) { 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 index 41bf820c..5369f995 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/export/NoteDocument.kt @@ -33,24 +33,24 @@ sealed interface DocumentBlock { } /** - * One note as a document for paper or PDF: [title], the [tag] it carries and the [blocks] that are not blank. + * 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 tag: String?, val blocks: List) { +class NoteDocument(val title: String, val tags: List, val blocks: List) { companion object { private const val H1_TO_H4 = 4 /** - * The document of [note]. [tagName] is its tag, if any; a note whose stored content cannot be read gets - * [unreadable] as its only paragraph, and a note without a title [untitled]. + * 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, tagName: String?, untitled: String, unreadable: String): NoteDocument { + 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 }, - tag = tagName?.trim()?.takeIf { it.isNotEmpty() }, + tags = tagNames.map { it.trim() }.filter { it.isNotEmpty() }, blocks = blocks, ) } diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt index 9f40db1b..4022e867 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffMapper.kt @@ -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, ) } 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 index 04a7eee9..2f37e53a 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/Frontmatter.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/Frontmatter.kt @@ -4,8 +4,15 @@ 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) { +/** + * 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 @@ -16,8 +23,12 @@ class MarkdownDocument(private val meta: Map>, val body: St /** 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(',') } + /** 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 @@ -51,7 +62,8 @@ object Frontmatter { 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")) + val listKeys = HashSet() + MarkdownDocument(parse(lines.subList(1, end), listKeys), lines.drop(end + 1).joinToString("\n"), listKeys) } } @@ -59,17 +71,23 @@ object Frontmatter { 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> { + /** 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() - meta.getOrPut(current) { ArrayList() } += scalars(pair.groupValues[2].trim()) + 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) meta.getValue(current) += unquote(item.groupValues[1].trim()) + if (item != null && current != null) { + listKeys += current + meta.getValue(current) += unquote(item.groupValues[1].trim()) + } } } return meta.filterValues { values -> values.any { it.isNotBlank() } } @@ -77,15 +95,39 @@ object Frontmatter { /** 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.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 { diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt index 68b8fde0..97ec0a3f 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportMapper.kt @@ -50,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 { @@ -78,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, @@ -87,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) }, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt index 1689a56b..1784213d 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportReview.kt @@ -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, ) @@ -93,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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt index bdd4826b..248c0cd2 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/ImportTypes.kt @@ -141,8 +141,6 @@ data class ImportLosses( 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, ) { @@ -151,7 +149,6 @@ data class ImportLosses( pinned + other.pinned, archived + other.archived, colors + other.colors, - extraTags + other.extraTags, todos + other.todos, ) @@ -174,7 +171,7 @@ data class ImportedNote( val sourceKey: String?, val title: String, val blocks: List, - /** Tag names in order of preference; Encly keeps the first. */ + /** Tag names, in order; the note gets every one of them. */ val tags: List = emptyList(), val created: Long? = null, val edited: Long? = null, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt index e964e5a9..37f3b958 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/JoplinParser.kt @@ -8,7 +8,7 @@ import com.pasich.encly.editor.codec.MarkdownBlockMapper * 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 { @@ -68,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() } @@ -81,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), @@ -89,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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt index fd0dc161..928c102e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/KeepParser.kt @@ -14,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 { @@ -61,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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt index c5db1d05..0a621e47 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/MarkdownNotesParser.kt @@ -77,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), ) } @@ -106,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/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt index 598affad..78fedef4 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/importer/StandardNotesParser.kt @@ -70,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)) } @@ -113,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/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 index 395061a8..b71bfc22 100644 --- 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 @@ -35,7 +35,6 @@ 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_tags 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 @@ -111,7 +110,6 @@ internal fun lossLines(preview: ImportPreview): List { 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.extraTags > 0) add(stringResource(Res.string.import_loss_tags, losses.extraTags)) 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)) 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 index 83b0ce40..fe4f16f6 100644 --- 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 @@ -221,11 +221,11 @@ private fun NoteRow(note: ReviewNote, checked: Boolean, expandedText: String?, a } } -/** Badges and date on one line: checklist, tag, in trash, already in the vault, then the date. */ +/** 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.tag?.let { add("#$it") } + 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))) 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 index 1a53b93c..44744629 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/MarkdownExportViewModel.kt @@ -243,8 +243,7 @@ class MarkdownExportViewModel( private fun documentOf(snapshot: VaultSnapshot, noteId: Long, labels: MarkdownLabels): NoteDocument { val note = snapshot.notes.firstOrNull { it.id == noteId && !it.isTrash } ?: throw BackupException(BackupError.IO) - val tagName = snapshot.tags.firstOrNull { it.id == note.tagId }?.nameTag - return NoteDocument.of(note, tagName, labels.untitled, labels.unreadable) + 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. */ diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt index 76a05f6a..e819ba19 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/backup/BackupImporterTest.kt @@ -2,12 +2,15 @@ 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.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.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 @@ -49,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( @@ -197,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(), @@ -247,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())) @@ -319,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/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt index 6ae62633..2ea028f3 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/NoteDocumentTest.kt @@ -9,7 +9,6 @@ import com.pasich.encly.editor.model.LinkDataBlock import kotlinx.coroutines.flow.MutableStateFlow import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull class NoteDocumentTest { private fun text(value: String) = Block.TextBlock(text = MutableStateFlow(value)) @@ -55,31 +54,33 @@ class NoteDocumentTest { } @Test - fun aNoteBecomesADocumentWithItsTitleAndTag() { + fun aNoteBecomesADocumentWithItsTitleAndTags() { val json = BlockConverter.blocksToJson(listOf(text("Body"))) val note = Note(id = 1, title = " Plan ", value = json) - val document = NoteDocument.of(note, tagName = " Work ", untitled = "Untitled", unreadable = "Unreadable") + val tags = listOf(" Work ", " ", "Home") + val document = NoteDocument.of(note, tags, untitled = "Untitled", unreadable = "Unreadable") assertEquals("Plan", document.title) - assertEquals("Work", document.tag) + 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 = "[]"), null, "Untitled", "Unreadable") + val document = NoteDocument.of(Note(id = 1, title = " ", value = "[]"), emptyList(), "Untitled", "Unreadable") assertEquals("Untitled", document.title) - assertNull(document.tag) + assertEquals(emptyList(), document.tags) assertEquals(emptyList(), document.blocks) } @Test fun aNoteThatCannotBeReadSaysSo() { - val document = NoteDocument.of(Note(id = 1, title = "T", value = "{not json"), "", "Untitled", "Unreadable") + 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) - assertNull(document.tag) + 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 index d0e838e1..8ca6e801 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/data/export/TablesAndCodeExportTest.kt @@ -81,7 +81,7 @@ class TablesAndCodeExportTest { value = BlockConverter.blocksToJson(listOf(code("a\n b"), table(listOf("x", "y"), listOf("z", "")))), ) - val document = NoteDocument.of(note, tagName = null, untitled = "Untitled", unreadable = "?") + val document = NoteDocument.of(note, tagNames = emptyList(), untitled = "Untitled", unreadable = "?") assertEquals( listOf(DocumentBlock.Paragraph("a\n b"), DocumentBlock.Paragraph("x | y\nz | ")), diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultDataStore.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/InMemoryVaultDataStore.kt index 44fbd798..ef91d6fb 100644 --- a/shared/src/commonTest/kotlin/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/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt index 73e90118..ccf12dd1 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/backup/BackupManagerTest.kt @@ -185,7 +185,7 @@ class BackupManagerTest { exportedAt = 0, tags = emptyList(), notes = listOf( - BackupNote("n-new", "New", "[]", "", 1L, 1L, tagUid = null, isTrash = false), + BackupNote("n-new", "New", "[]", "", 1L, 1L, isTrash = false), ), tasks = emptyList(), settings = settings, 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 index 4c2a52c1..3a850abe 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownExportTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownExportTest.kt @@ -2,6 +2,7 @@ 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 @@ -134,16 +135,20 @@ class MarkdownExportTest { } @Test - fun theTagsAreInTheFrontMatterAndHiddenTagsAreIncluded() { + fun theTagsAreInTheFrontMatterInTheirOrderAndHiddenTagsAreIncluded() { val files = exportAll( VaultSnapshot( - notes = listOf(note(1, "Secret plan", text("x")) { tagId = 7 }), - tags = listOf(Tag(id = 7, nameTag = "Private", isVisible = false)), + 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: [\"Private\"]\n")) + assertTrue(files.getValue("Secret plan.md").contains("tags: [\"Work\", \"Private\"]\n")) } @Test @@ -223,7 +228,7 @@ class MarkdownExportTest { 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, + emptyList(), labels, ) @@ -243,7 +248,7 @@ class MarkdownExportTest { 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")) { tagId = 7 }, + 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 }, @@ -251,7 +256,7 @@ class MarkdownExportTest { ) 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) + return VaultSnapshot(notes, tags, tasks, noteTags = listOf(NoteTag(2, 7))) } private fun goldenZip(snapshot: VaultSnapshot): ByteArray { @@ -264,7 +269,7 @@ class MarkdownExportTest { } fun render(id: Long): String { val note = snapshot.notes.first { it.id == id } - return MarkdownNotes.render(note, snapshot.tags.firstOrNull { it.id == note.tagId }?.nameTag, labels) + 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)) @@ -308,7 +313,7 @@ class MarkdownExportTest { MarkdownExport.writeNote(snapshot, 1, labels, out) assertArrayEquals( - MarkdownNotes.render(snapshot.notes.single(), null, labels).toByteArray(Charsets.UTF_8), + MarkdownNotes.render(snapshot.notes.single(), emptyList(), labels).toByteArray(Charsets.UTF_8), out.readByteArray(), ) } diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt index 7b1c3ac9..219418a8 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/export/MarkdownNotesTest.kt @@ -15,7 +15,7 @@ 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() { @@ -88,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 = "[]")) @@ -140,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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt index c84428fb..214e4732 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/handoff/MyNotesHandoffImportTest.kt @@ -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/importer/ExpectedNote.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt index 81fc10a0..5c0bc642 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ExpectedNote.kt @@ -8,11 +8,14 @@ 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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt index c728028a..4760d875 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/ImportMapperTest.kt @@ -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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/KeepParserTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/KeepParserTest.kt index 69c8b46d..5c68a292 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt index dfbdb3f0..e428c253 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/NotesnookAndMarkdownFixturesTest.kt @@ -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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt index 25193925..75186202 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/RealExportsTest.kt @@ -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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/TextParsersTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/importer/TextParsersTest.kt index e64bb8c6..605ec6a4 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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) @@ -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/domain/usecase/RestoreFromBackupTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt index f40e8f60..4487ebe5 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/RestoreFromBackupTest.kt @@ -13,6 +13,7 @@ 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.InMemoryKeyValueStore import com.pasich.encly.testutil.InMemoryVaultDataStore @@ -59,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())) @@ -78,7 +80,7 @@ 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 From cee938b9cdcce6db4f1235f200c0474cb49e52bb Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:57:34 +0200 Subject: [PATCH 71/75] feat(notes): lists, search and the editor handle several tags per note 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 --- .../data/repository/NotesRepositoryImpl.kt | 67 ++++++++++++----- .../pasich/encly/domain/model/NoteListItem.kt | 18 ++--- .../domain/repository/NotesRepository.kt | 43 +++++++---- .../usecase/note/ObserveNotesUseCase.kt | 19 +++-- .../usecase/note/UpdateNoteTagUseCase.kt | 12 +++- .../editor/persistence/NotePersistence.kt | 18 ++--- .../viewmodel/ArchiveViewModel.kt | 2 +- .../viewmodel/EditNoteViewModel.kt | 20 ++++-- .../viewmodel/NoteListViewModel.kt | 6 +- .../viewmodel/TagListViewModel.kt | 8 +-- .../encly/domain/model/NoteListItemTest.kt | 7 +- .../editor/persistence/NotePersistenceTest.kt | 72 +++++++++++++------ .../viewmodel/ArchiveViewModelTest.kt | 4 +- .../EditNoteViewModelLifecycleTest.kt | 8 +-- .../viewmodel/LockClearsViewModelsTest.kt | 9 ++- .../viewmodel/NoteListEmptyStateTest.kt | 4 +- .../viewmodel/NoteSearchViewModelTest.kt | 20 ++++-- .../viewmodel/TagListViewModelTest.kt | 20 +++--- .../encly/testutil/TestNotesRepository.kt | 32 +++++---- .../data/repository/DaoRepositoriesTest.kt | 16 +++-- .../data/repository/RepositoryImplTest.kt | 33 +++++---- .../domain/usecase/note/NoteUseCasesTest.kt | 14 ++-- .../usecase/note/ObserveNotesUseCaseTest.kt | 59 ++++++++++++--- .../presentation/screen/NoteEntriesTest.kt | 4 +- .../viewmodel/EditNoteViewModelComfortTest.kt | 8 +-- .../viewmodel/EditNoteViewModelEditorTest.kt | 21 +++--- .../EditNoteViewModelSelectionTest.kt | 4 +- .../viewmodel/EditNoteViewModelTest.kt | 6 +- .../viewmodel/NoteListEventsTest.kt | 8 +-- .../viewmodel/NoteListViewModelTest.kt | 12 ++-- 30 files changed, 374 insertions(+), 200 deletions(-) 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 index 4ddf9721..219c46cf 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/data/repository/NotesRepositoryImpl.kt @@ -3,10 +3,12 @@ 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.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" @@ -18,23 +20,38 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note private fun dao() = databaseProvider.getDatabase(lease ?: error("Vault locked")).notesDao() - override fun getAllNotesWithTag(): Flow> = daoFlow { dao().getAllNotesWithTags() } + override fun getAllNotesWithTag(): Flow> = + daoFlow { dao().getAllNotesWithTags() }.map { it.map(NoteWithTags::normalized) } - override fun getNotesByTagId(tagId: Long): Flow> = daoFlow { dao().getNotesByTagId(tagId) } + override fun getArchivedNotesWithTag(): Flow> = + daoFlow { dao().getArchivedNotesWithTags() }.map { it.map(NoteWithTags::normalized) } - override fun getArchivedNotesWithTag(): Flow> = daoFlow { dao().getArchivedNotesWithTags() } + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + daoFlow { dao().getNotesOfTagWithArchived(tagId) }.map { it.map(NoteWithTags::normalized) } - override fun getNotesOfTagWithArchived(tagId: Long): Flow> = - daoFlow { dao().getNotesOfTagWithArchived(tagId) } + override fun observeNote(noteId: Long): Flow = + daoFlow { dao().observeNoteWithTags(noteId) }.map { it?.normalized() } - override fun observeNote(noteId: Long): Flow = daoFlow { dao().observeNoteWithTag(noteId) } + 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 setTag(noteId: Long, tagId: Long?): Result = storageWrite(TAG, "setTag") { - dao().updateTagId(noteId, tagId).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 = @@ -51,30 +68,34 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note override fun getTrashNotes(): Flow> = daoFlow { dao().getTrashNotes() } - override suspend fun getNoteById(noteId: Long): Note? = dao().getNoteById(noteId) + override suspend fun getNoteById(noteId: Long): Note? = dao().getNoteWithTags(noteId)?.normalized()?.note override suspend fun insertNote(note: Note): Result = storageWrite(TAG, "insertNote") { - dao().insertNote(note) + dao().insertNoteWithTags(note) } override suspend fun updateEdited(note: Note, base: Note?): Result = storageWrite(TAG, "updateEdited") { - dao().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 = base == null || note.date != base.date || note.description != base.description, + stamp = note.date != base.date || note.description != base.description, description = note.description, date = note.date, - setTag = base == null || note.tagId != base.tagId, - tagId = note.tagId, - setPinned = base == null || note.isPinned != base.isPinned, + setPinned = note.isPinned != base.isPinned, pinned = note.isPinned, - setColor = base == null || note.color != base.color, + setColor = note.color != base.color, color = note.color, - setTextSize = base == null || note.textSize != base.textSize, + setTextSize = note.textSize != base.textSize, textSize = note.textSize, - setLineSpacing = base == null || note.lineSpacing != base.lineSpacing, + setLineSpacing = note.lineSpacing != base.lineSpacing, lineSpacing = note.lineSpacing, + addTags = note.tagIds - base.tagIds, + removeTags = base.tagIds - note.tagIds, ).requireRows() } @@ -96,3 +117,11 @@ class NotesRepositoryImpl(private val databaseProvider: DatabaseProvider) : Note 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/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt index 643b481f..e59b74c3 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/model/NoteListItem.kt @@ -1,7 +1,7 @@ package com.pasich.encly.domain.model 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.editor.codec.BlockConverter import com.pasich.encly.utils.NotesTextFormatter @@ -15,23 +15,25 @@ import com.pasich.encly.utils.NotesTextFormatter * @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 +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 [noteWithTag]'s blocks once; an unreadable note becomes an item, not a crash. */ - fun of(noteWithTag: NoteWithTag): NoteListItem { - val note = noteWithTag.note + /** 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(noteWithTag, preview, searchText) + return NoteListItem(noteWithTags, preview, searchText) } } } 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 index ed769296..dab6429e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/domain/repository/NotesRepository.kt @@ -1,7 +1,7 @@ package com.pasich.encly.domain.repository import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import kotlinx.coroutines.flow.Flow /** @@ -10,19 +10,33 @@ import kotlinx.coroutines.flow.Flow */ @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 getNotesOfTagWithArchived(tagId: Long): Flow> + // 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 + 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 and nothing else, so a caller holding an older - // copy of the note cannot undo another change. + // 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 - suspend fun setTag(noteId: Long, tagId: Long?): 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. */ @@ -31,16 +45,19 @@ interface NotesRepository { /** 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. */ + /** 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 tag, pin, colour, text size and line + * 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. 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. + * 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 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 index dc47d4ca..f8197fa9 100644 --- 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 @@ -2,7 +2,7 @@ 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.data.model.NoteWithTags import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.NoteListItem import com.pasich.encly.domain.repository.NotesRepository @@ -24,7 +24,7 @@ class ObserveNotesUseCase( @IoDispatcher private val dispatcher: CoroutineDispatcher, ) { /** - * @param tagId the tag to show, or null for every note whose tag is not hidden + * @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( @@ -40,20 +40,25 @@ class ObserveNotesUseCase( // 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 { !it.note.isArchived || it.tag?.isVisible == false } + 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) + 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?.isVisible ?: true + /** 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 { + 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()) 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 index a85198fd..bf3d0731 100644 --- 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 @@ -4,9 +4,15 @@ import com.pasich.encly.data.model.Note import com.pasich.encly.domain.repository.NotesRepository import dev.zacsweers.metro.Inject -/** Moves the note to the tag [tagId] (0: no tag). Writes the tag only. */ +/** + * 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): Result = - repository.setTag(targetNote.id, tagId.takeIf { it > 0L }) + 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/presentation/editor/persistence/NotePersistence.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/editor/persistence/NotePersistence.kt index be964b59..a695e76f 100644 --- 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 @@ -399,7 +399,7 @@ class NotePersistence( 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 || + 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 @@ -411,7 +411,7 @@ class NotePersistence( val content = contentToKeep(currentNote) withContext(ioDispatcher) { notesRepository.insertNote( - Note.new(title = copyTitle(currentNote.title), value = content, tagId = currentNote.tagId) + Note.new(title = copyTitle(currentNote.title), value = content, tagIds = currentNote.tagIds) .copy( color = currentNote.color, textSize = currentNote.textSize, @@ -441,13 +441,13 @@ class NotePersistence( } private fun snapshotOf(note: Note, blocksJson: String): String = - "${note.title}\u0000${note.tagId}\u0000${note.isPinned}\u0000${note.color}\u0000" + + "${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 tagId: Long?, + val tagIds: Set, val isPinned: Boolean, val color: String, val textSize: Int?, @@ -456,10 +456,10 @@ private data class NoteMeta( /** 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 && - tagId == other.tagId && isPinned == other.isPinned && color == other.color && + tagIds == other.tagIds && isPinned == other.isPinned && color == other.color && textSize == other.textSize && lineSpacing == other.lineSpacing && date == other.date -private fun Note.metaKey() = NoteMeta(title, tagId, isPinned, color, textSize, lineSpacing) +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 @@ -503,7 +503,7 @@ private fun List.frozen(): List = map { 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, tagId = note.tagId) + 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. */ @@ -512,8 +512,8 @@ private suspend fun NotesRepository.insertDraft(draft: Note): Long? = insertNote ?.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. + * [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, diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt index 72ebf167..880bbe23 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModel.kt @@ -76,7 +76,7 @@ class ArchiveViewModel( 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/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt index fb62d220..890a0f02 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModel.kt @@ -302,7 +302,7 @@ class EditNoteViewModel( 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) } + 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 @@ -417,12 +417,20 @@ class EditNoteViewModel( } /** - * 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. + * 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 updateTagNote(tagId: Long) { - // "No tag" (0) is stored as null, its only representation. - persistence.updateNote { it.copy(tagId = tagId.takeIf { id -> id > 0L }) } + 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. */ diff --git a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt index 229ccf4f..41a9978e 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModel.kt @@ -152,7 +152,7 @@ class NoteListViewModel( 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 @@ -175,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/TagListViewModel.kt b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt index 15a658d0..2c580400 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModel.kt @@ -26,7 +26,6 @@ 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 @@ -81,12 +80,11 @@ class TagListViewModel( /** * 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()) diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt index 0933b22c..2ca78ca7 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/domain/model/NoteListItemTest.kt @@ -1,7 +1,7 @@ package com.pasich.encly.domain.model import com.pasich.encly.data.model.Note -import com.pasich.encly.data.model.NoteWithTag +import com.pasich.encly.data.model.NoteWithTags import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -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/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 index f3446917..5980ad4a 100644 --- 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 @@ -1,7 +1,7 @@ package com.pasich.encly.presentation.editor.persistence 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.usecase.note.UpdateNoteArchiveStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase import com.pasich.encly.editor.codec.BlockConverter @@ -91,15 +91,15 @@ class NotePersistenceTest { @Test fun aSavedEditMovesTheShownDateAndDropsAnImportedSummary() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag( - note = Note( + NoteWithTags( + Note( id = NOTE_ID, title = "title", value = BlockConverter.blocksToJson(listOf(text("body"))), description = "imported summary", date = OLD_DATE, ), - tag = null, + emptyList(), ), ) val persistence = persistence(loading = true) @@ -117,15 +117,15 @@ class NotePersistenceTest { @Test fun anUnchangedNoteKeepsItsDateAndSummary() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag( - note = Note( + NoteWithTags( + Note( id = NOTE_ID, title = "title", value = BlockConverter.blocksToJson(listOf(text("body"))), description = "imported summary", date = OLD_DATE, ), - tag = null, + emptyList(), ), ) val persistence = persistence(loading = true) @@ -148,7 +148,7 @@ class NotePersistenceTest { assertTrue(persistence.hasChanges()) blocks = listOf(text("body")) - persistence.updateNote { it.copy(tagId = TAG_ID) } + persistence.updateNote { it.copy(tagIds = setOf(TAG_ID)) } assertTrue(persistence.hasChanges()) } @@ -241,7 +241,7 @@ class NotePersistenceTest { assertEquals(-1L, persistence.state.value.note.id) assertEquals("title (копія)", persistence.state.value.note.title) - assertNull(persistence.state.value.note.tagId) + assertEquals(emptySet(), persistence.state.value.note.tagIds) } @Test @@ -259,7 +259,7 @@ class NotePersistenceTest { @Test fun unreadableContentIsNotOverwritten() = runTest(dispatcher) { repository.allNotesWithTags.value = - listOf(NoteWithTag(note = Note(id = NOTE_ID, title = "t", value = "{not json"), tag = null)) + 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. @@ -275,7 +275,7 @@ class NotePersistenceTest { @Test fun duplicatingUnreadableContentCopiesTheStoredText() = runTest(dispatcher) { repository.allNotesWithTags.value = - listOf(NoteWithTag(note = Note(id = NOTE_ID, title = "t", value = "{not json"), tag = null)) + 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 }) @@ -288,7 +288,7 @@ class NotePersistenceTest { @Test fun aCopyOfUnreadableContentIsReportedAndNeverInserted() = runTest(dispatcher) { repository.allNotesWithTags.value = - listOf(NoteWithTag(note = Note(id = NOTE_ID, title = "t", value = "{not json"), tag = null)) + 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 }) @@ -372,7 +372,7 @@ class NotePersistenceTest { // Another window saves new text. val other = BlockConverter.blocksToJson(listOf(text("from the other window"))) repository.allNotesWithTags.value = - listOf(NoteWithTag(Note(id = NOTE_ID, title = "title", value = other), null)) + 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) @@ -491,19 +491,19 @@ class NotePersistenceTest { val firstSave = async { persistence.save() } runCurrent() // the insert is in flight - persistence.updateNote { it.copy(title = "draft", tagId = TAG_ID) } + 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(TAG_ID, note.tagId) + 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(TAG_ID, repository.updatedNotes.single().tagId) + assertEquals(setOf(TAG_ID), repository.updatedNotes.single().tagIds) } @Test @@ -515,13 +515,40 @@ class NotePersistenceTest { passQuietTime() assertEquals(0, repository.updateCalls) - persistence.updateNote { it.copy(tagId = TAG_ID) } + persistence.updateNote { it.copy(tagIds = setOf(TAG_ID)) } passQuietTime() - assertEquals(TAG_ID, repository.updatedNotes.single().tagId) + 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 @@ -629,9 +656,9 @@ class NotePersistenceTest { @Test fun aCopyKeepsTheColourButIsNeitherPinnedNorArchived() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag( + NoteWithTags( Note(id = NOTE_ID, title = "t", value = "[]", isPinned = true, isArchived = true, color = "pink"), - null, + emptyList(), ), ) val persistence = persistence(loading = true) @@ -665,14 +692,15 @@ class NotePersistenceTest { loading = loading, ) - private fun storeNote(vararg content: Block, isTrash: Boolean = false) { + 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(NoteWithTag(note = note, tag = null)) + repository.allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) } private fun text(value: String) = Block.TextBlock(MutableStateFlow(value)) diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/ArchiveViewModelTest.kt index fc1d890b..00063358 100644 --- a/shared/src/commonTest/kotlin/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 @@ -51,7 +51,7 @@ class ArchiveViewModelTest { } 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) { 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 index 963217d8..65fbe735 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelLifecycleTest.kt @@ -3,7 +3,7 @@ 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.NoteWithTag +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 @@ -51,7 +51,7 @@ class EditNoteViewModelLifecycleTest { val viewModel = viewModel(SavedStateHandle(mapOf("addTag" to TAG_ID))) advanceUntilIdle() - assertEquals(TAG_ID, viewModel.state.value.note.tagId) + assertEquals(setOf(TAG_ID), viewModel.state.value.note.tagIds) assertTrue(viewModel.isNewNote) } @@ -211,7 +211,7 @@ class EditNoteViewModelLifecycleTest { title = "Renamed elsewhere", value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("new text")))), ) - repository.allNotesWithTags.value = listOf(NoteWithTag(note = saved, tag = null)) + repository.allNotesWithTags.value = listOf(NoteWithTags(saved, emptyList())) advanceUntilIdle() assertEquals("Renamed elsewhere", viewModel.state.value.note.title) @@ -303,7 +303,7 @@ class EditNoteViewModelLifecycleTest { value = BlockConverter.blocksToJson(listOf(Block.TextBlock(MutableStateFlow("body")))), isTrash = isTrash, ) - repository.allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + repository.allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) } private fun viewModel(handle: SavedStateHandle) = EditNoteViewModel( 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 index 24826b95..bcf46ad0 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/LockClearsViewModelsTest.kt @@ -3,7 +3,7 @@ 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.NoteWithTag +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 @@ -91,7 +91,7 @@ class LockClearsViewModelsTest { locked.value = true advanceUntilIdle() locked.value = false - repository.allNotesWithTags.value = listOf(NoteWithTag(Note(id = 1, title = "Shopping"), null)) + repository.allNotesWithTags.value = listOf(NoteWithTags(Note(id = 1, title = "Shopping"), emptyList())) val decoy = noteList(holder) advanceUntilIdle() @@ -145,7 +145,10 @@ class LockClearsViewModelsTest { @Test fun theSearchQueryAndResultsAreClearedByALock() = runTest(dispatcher) { repository.allNotesWithTags.value = listOf( - NoteWithTag(Note(id = 1, title = "salary", value = """[{"blockType":"TEXT","text":"salary 100"}]"""), null), + NoteWithTags( + Note(id = 1, title = "salary", value = """[{"blockType":"TEXT","text":"salary 100"}]"""), + emptyList(), + ), ) val viewModel = NoteSearchViewModel(ObserveNotesUseCase(repository, dispatcher), dispatcher, lockEvents) viewModel.onQueryChange("salary") diff --git a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt index cc5a4404..fcc36d82 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEmptyStateTest.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.NoteListItem import kotlin.test.Test import kotlin.test.assertEquals @@ -10,7 +10,7 @@ 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/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteSearchViewModelTest.kt index 59070fbe..6ac75ce4 100644 --- a/shared/src/commonTest/kotlin/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 @@ -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/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt b/shared/src/commonTest/kotlin/com/pasich/encly/presentation/viewmodel/TagListViewModelTest.kt index 872bb390..eca9c0fd 100644 --- a/shared/src/commonTest/kotlin/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 @@ -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/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt index 382ef039..b266c421 100644 --- a/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt +++ b/shared/src/commonTest/kotlin/com/pasich/encly/testutil/TestNotesRepository.kt @@ -1,7 +1,7 @@ package com.pasich.encly.testutil 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.repository.NotesRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map internal class TestNotesRepository : NotesRepository { - val allNotesWithTags = MutableStateFlow>(emptyList()) + val allNotesWithTags = MutableStateFlow>(emptyList()) /** The id an insert returns; zero or less makes the insert fail. */ var insertResult: Long = DEFAULT_INSERT_ID @@ -20,15 +20,13 @@ internal class TestNotesRepository : NotesRepository { var updateCalls: Int = 0 private set - private val notesByTag = mutableMapOf>>() + private val notesByTag = mutableMapOf>>() - fun tagFlow(tagId: Long): MutableStateFlow> = notesByTag.getOrPut(tagId) { + fun tagFlow(tagId: Long): MutableStateFlow> = notesByTag.getOrPut(tagId) { MutableStateFlow(emptyList()) } - override fun getAllNotesWithTag(): Flow> = allNotesWithTags - - override fun getNotesByTagId(tagId: Long): Flow> = tagFlow(tagId) + override fun getAllNotesWithTag(): Flow> = allNotesWithTags override suspend fun getNoteById(noteId: Long): Note? = allNotesWithTags.value.firstOrNull { it.note.id == noteId @@ -69,16 +67,16 @@ internal class TestNotesRepository : NotesRepository { return if (updateResult) Result.success(Unit) else Result.failure(IllegalStateException()) } - val archivedNotesWithTags = MutableStateFlow>(emptyList()) + val archivedNotesWithTags = MutableStateFlow>(emptyList()) - override fun getArchivedNotesWithTag(): Flow> = archivedNotesWithTags + override fun getArchivedNotesWithTag(): Flow> = archivedNotesWithTags - override fun getNotesOfTagWithArchived(tagId: Long): Flow> = + override fun getNotesOfTagWithArchived(tagId: Long): Flow> = combine(tagFlow(tagId), archivedNotesWithTags) { live, archived -> - live + archived.filter { it.note.tagId == tagId } + live + archived.filter { item -> item.tags.any { it.id == tagId } } } - override fun observeNote(noteId: Long): Flow = combine(allNotesWithTags, archivedNotesWithTags) { + override fun observeNote(noteId: Long): Flow = combine(allNotesWithTags, archivedNotesWithTags) { a, b, -> @@ -97,7 +95,15 @@ internal class TestNotesRepository : NotesRepository { 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 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") diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/DaoRepositoriesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/DaoRepositoriesTest.kt index b34e5a36..8871a97b 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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 index 951eef1d..93e9342e 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/data/repository/RepositoryImplTest.kt @@ -66,12 +66,23 @@ class RepositoryImplTest { @Test fun anEditSavesOnlyTheColumnsItChanged() = runBlocking { - val base = Note(id = NOTE_ID, title = "t", value = "v", date = 1L, color = "teal", isPinned = true) - val edited = base.copy(value = "w", tagId = 3L) - // The title and content go; the tag changed; the pin, colour and sizes are left to whatever the row holds. + 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.updateEdited( - NOTE_ID, "t", "w", false, "", 1L, true, 3L, false, true, false, "teal", false, null, false, null, + notesDao.saveEdited( + NOTE_ID, "t", "w", false, "", 1L, false, true, false, "teal", false, null, false, null, + setOf(3L), setOf(1L), ), ).thenReturn(1) @@ -80,12 +91,8 @@ class RepositoryImplTest { @Test fun withoutABaseEveryColumnIsWritten() = runBlocking { - val note = Note(id = NOTE_ID, title = "t", value = "v", date = 1L) - `when`( - notesDao.updateEdited( - NOTE_ID, "t", "v", true, "", 1L, true, null, true, false, true, "none", true, null, true, null, - ), - ).thenReturn(1) + 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) } @@ -93,7 +100,7 @@ class RepositoryImplTest { @Test fun aStorageExceptionBecomesAFailureNotACrash() = runBlocking { val note = Note(id = NOTE_ID) - `when`(notesDao.insertNote(note)).thenAnswer { throw IOException("disk") } + `when`(notesDao.insertNoteWithTags(note)).thenAnswer { throw IOException("disk") } assertTrue(NotesRepositoryImpl(provider).insertNote(note).exceptionOrNull() is IOException) } @@ -112,7 +119,7 @@ class RepositoryImplTest { locked = false lease = DatabaseLease() val note = Note(title = "new session") - `when`(notesDao.insertNote(note)).thenReturn(NOTE_ID) + `when`(notesDao.insertNoteWithTags(note)).thenReturn(NOTE_ID) assertTrue(stale.insertNote(note).isFailure) assertEquals(NOTE_ID, NotesRepositoryImpl(provider).insertNote(note).getOrNull()) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt index 1a0c664f..da937f19 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/domain/usecase/note/NoteUseCasesTest.kt @@ -1,7 +1,7 @@ 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.NoteWithTags import com.pasich.encly.domain.model.NoteColor import com.pasich.encly.domain.repository.NotesRepository import com.pasich.encly.testutil.TestNotesRepository @@ -20,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 { @@ -59,7 +59,7 @@ class NoteUseCasesTest { fun trashingAnArchivedNoteKeepsItArchivedSoRestoringReturnsItToTheArchive() = runTest { val useCase = UpdateNoteTrashStatusUseCase(repository) val archived = note.copy(isArchived = true) - repository.archivedNotesWithTags.value = listOf(NoteWithTag(archived, null)) + repository.archivedNotesWithTags.value = listOf(NoteWithTags(archived, emptyList())) useCase(archived, isTrash = true) useCase(archived.copy(isTrash = true), isTrash = false) @@ -79,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 index b727cca8..ab80d64f 100644 --- 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 @@ -1,7 +1,7 @@ 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.NoteWithTags import com.pasich.encly.data.model.Tag import com.pasich.encly.domain.enums.NoteSortOption import com.pasich.encly.domain.model.NoteListItem @@ -27,7 +27,7 @@ class ObserveNotesUseCaseTest { @Test fun aDatabaseReadFailureFailsTheFlowInsteadOfEmittingNoNotes() = 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 emitted = mutableListOf>() @@ -113,9 +113,9 @@ class ObserveNotesUseCaseTest { 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, + 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 @@ -152,6 +152,47 @@ class ObserveNotesUseCaseTest { 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 { @@ -177,16 +218,16 @@ class ObserveNotesUseCaseTest { value: String = "", pinned: Boolean = false, title: String = "n$id", - ) = NoteWithTag( - note = Note( + ) = NoteWithTags( + Note( id = id, title = title, value = value, date = date, dateCreate = date, - tagId = tag?.id, + tagIds = setOfNotNull(tag?.id), isPinned = pinned, ), - tag = tag, + listOfNotNull(tag), ) } diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt index ae19e6c6..187e2d52 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/screen/NoteEntriesTest.kt @@ -5,7 +5,7 @@ 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 @@ -13,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 = "", ) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt index 2d872d82..c4652975 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelComfortTest.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.snapshots.Snapshot import androidx.lifecycle.SavedStateHandle import com.pasich.encly.core.security.VaultLockEvents 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 @@ -61,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 } @@ -150,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) @@ -165,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) diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt index d8067dd5..4f29d05e 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelEditorTest.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 @@ -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 index c6a2c77d..d3794b33 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.kt +++ b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelSelectionTest.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.usecase.note.UpdateNoteArchiveStatusUseCase import com.pasich.encly.domain.usecase.note.UpdateNoteTrashStatusUseCase import com.pasich.encly.editor.codec.BlockConverter @@ -52,7 +52,7 @@ class EditNoteViewModelSelectionTest { ), ) private val repository = TestNotesRepository().apply { - allNotesWithTags.value = listOf(NoteWithTag(note = note, tag = null)) + allNotesWithTags.value = listOf(NoteWithTags(note, emptyList())) } private val clipboard = FakeClipboard() diff --git a/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/EditNoteViewModelTest.kt index 0386b969..909f685a 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListEventsTest.kt index e7b4efa2..f4f6031b 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt b/shared/src/jvmAndAndroidTest/kotlin/com/pasich/encly/presentation/viewmodel/NoteListViewModelTest.kt index c225fd10..2d9a722c 100644 --- a/shared/src/jvmAndAndroidTest/kotlin/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 { From 23b192aec9c6c19d578d82d196c497e9eb445d0d Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:57:38 +0200 Subject: [PATCH 72/75] feat(ui): pick several tags for a note and see them on its card 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 --- .../commonMain/strings/values-ar/strings.xml | 8 +++++ .../commonMain/strings/values-de/strings.xml | 4 +++ .../commonMain/strings/values-es/strings.xml | 5 +++ .../commonMain/strings/values-fr/strings.xml | 5 +++ .../commonMain/strings/values-it/strings.xml | 5 +++ .../commonMain/strings/values-nl/strings.xml | 4 +++ .../commonMain/strings/values-pl/strings.xml | 6 ++++ .../commonMain/strings/values-pt/strings.xml | 5 +++ .../commonMain/strings/values-uk/strings.xml | 6 ++++ .../src/commonMain/strings/values/strings.xml | 4 +++ .../dialogs/NoteCardBottomSheetTest.kt | 22 ++++++++---- .../encly/ui/screens/EditNoteScreenTest.kt | 2 +- .../pasich/encly/ui/screens/MainScreenTest.kt | 6 ++-- .../encly/ui/screens/NotePreviewsTest.kt | 24 +++++++++++-- .../encly/ui/screens/TagsAndTrashTest.kt | 6 ++-- .../com/pasich/encly/ui/screens/TestApp.kt | 5 +-- .../components/editNote/NoteHeader.kt | 28 +++++++++------ .../presentation/components/tiles/NoteItem.kt | 2 +- .../presentation/designsystem/NoteCard.kt | 34 +++++++++++++++++-- .../dialogs/NoteCardBottomSheet.kt | 31 +++++++++++------ .../encly/presentation/screen/NotesCards.kt | 2 +- .../presentation/screen/NotesListScreen.kt | 6 ++-- .../screen/editnote/EditNoteScreen.kt | 9 ++--- .../screen/editnote/EditorSheets.kt | 9 ++--- 24 files changed, 180 insertions(+), 58 deletions(-) diff --git a/core/resources/src/commonMain/strings/values-ar/strings.xml b/core/resources/src/commonMain/strings/values-ar/strings.xml index c30a7dd7..dfc05098 100644 --- a/core/resources/src/commonMain/strings/values-ar/strings.xml +++ b/core/resources/src/commonMain/strings/values-ar/strings.xml @@ -728,4 +728,12 @@ إظهار الأداة أعلى أسفل + + %1$d وسم آخر + %1$d وسم آخر + %1$d وسمان آخران + %1$d وسوم أخرى + %1$d وسمًا آخر + %1$d وسم آخر + diff --git a/core/resources/src/commonMain/strings/values-de/strings.xml b/core/resources/src/commonMain/strings/values-de/strings.xml index 9e4fffda..4f3ab38f 100644 --- a/core/resources/src/commonMain/strings/values-de/strings.xml +++ b/core/resources/src/commonMain/strings/values-de/strings.xml @@ -715,4 +715,8 @@ Werkzeug einblenden Nach oben Nach unten + + %1$d weiteres Label + %1$d weitere Labels + diff --git a/core/resources/src/commonMain/strings/values-es/strings.xml b/core/resources/src/commonMain/strings/values-es/strings.xml index f1f08cd9..33acb9ee 100644 --- a/core/resources/src/commonMain/strings/values-es/strings.xml +++ b/core/resources/src/commonMain/strings/values-es/strings.xml @@ -721,4 +721,9 @@ Mostrar herramienta Subir Bajar + + %1$d etiqueta más + %1$d etiquetas más + %1$d etiquetas más + diff --git a/core/resources/src/commonMain/strings/values-fr/strings.xml b/core/resources/src/commonMain/strings/values-fr/strings.xml index 044e0184..edab7131 100644 --- a/core/resources/src/commonMain/strings/values-fr/strings.xml +++ b/core/resources/src/commonMain/strings/values-fr/strings.xml @@ -721,4 +721,9 @@ Afficher l\'outil Monter Descendre + + %1$d autre libellé + %1$d autres libellés + %1$d autres libellés + diff --git a/core/resources/src/commonMain/strings/values-it/strings.xml b/core/resources/src/commonMain/strings/values-it/strings.xml index 1d93cc09..2d1cf9c5 100644 --- a/core/resources/src/commonMain/strings/values-it/strings.xml +++ b/core/resources/src/commonMain/strings/values-it/strings.xml @@ -721,4 +721,9 @@ Mostra strumento Su Giù + + %1$d altra etichetta + altre %1$d etichette + altre %1$d etichette + diff --git a/core/resources/src/commonMain/strings/values-nl/strings.xml b/core/resources/src/commonMain/strings/values-nl/strings.xml index 91e32f65..7bd9c4e4 100644 --- a/core/resources/src/commonMain/strings/values-nl/strings.xml +++ b/core/resources/src/commonMain/strings/values-nl/strings.xml @@ -715,4 +715,8 @@ Hulpmiddel tonen Omhoog Omlaag + + nog %1$d label + nog %1$d labels + diff --git a/core/resources/src/commonMain/strings/values-pl/strings.xml b/core/resources/src/commonMain/strings/values-pl/strings.xml index 870bb083..2021fab5 100644 --- a/core/resources/src/commonMain/strings/values-pl/strings.xml +++ b/core/resources/src/commonMain/strings/values-pl/strings.xml @@ -727,4 +727,10 @@ 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/core/resources/src/commonMain/strings/values-pt/strings.xml b/core/resources/src/commonMain/strings/values-pt/strings.xml index 3f5c73dc..2830c2fe 100644 --- a/core/resources/src/commonMain/strings/values-pt/strings.xml +++ b/core/resources/src/commonMain/strings/values-pt/strings.xml @@ -721,4 +721,9 @@ Mostrar ferramenta Subir Descer + + mais %1$d etiqueta + mais %1$d etiquetas + mais %1$d etiquetas + diff --git a/core/resources/src/commonMain/strings/values-uk/strings.xml b/core/resources/src/commonMain/strings/values-uk/strings.xml index b013e67d..5677b3e7 100644 --- a/core/resources/src/commonMain/strings/values-uk/strings.xml +++ b/core/resources/src/commonMain/strings/values-uk/strings.xml @@ -727,4 +727,10 @@ Показати інструмент Вгору Вниз + + ще %1$d мітка + ще %1$d мітки + ще %1$d міток + ще %1$d мітки + diff --git a/core/resources/src/commonMain/strings/values/strings.xml b/core/resources/src/commonMain/strings/values/strings.xml index b7700f90..f8a58da5 100644 --- a/core/resources/src/commonMain/strings/values/strings.xml +++ b/core/resources/src/commonMain/strings/values/strings.xml @@ -733,4 +733,8 @@ Show tool Move up Move down + + %1$d more tag + %1$d more tags + 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 index a8d11406..93eb2744 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheetTest.kt @@ -16,7 +16,7 @@ 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.NoteWithTag +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 @@ -30,15 +30,17 @@ class NoteCardBottomSheetTest : ComposeScreenTest() { 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 = listOf(Tag(id = 1, nameTag = "Work"), Tag(id = 2, nameTag = "Home")) + app.tags.tags.value = tags setScreen(viewModels(app.tagList())) { NoteCardBottomSheet( isVisible = visible, - item = NoteWithTag(note, null), + item = NoteWithTags(note, tags.filter { it.id in note.tagIds }), onAction = { actions += it }, onDismiss = { dismissed++ }, ) @@ -92,14 +94,22 @@ class NoteCardBottomSheetTest : ComposeScreenTest() { } @Test - fun aTagChipMovesTheNoteToThatTagAndNoTagClearsIt() { - show(Note(id = 5, title = "Groceries", tagId = 2)) + 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), NoteAction.ChangeTag(0)), actions) + assertEquals( + listOf( + NoteAction.ChangeTag(1, tagged = true), + NoteAction.ChangeTag(2, tagged = false), + NoteAction.ChangeTag(0, tagged = false), + ), + actions, + ) } @Test 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 index 87c69a29..d48a9a67 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/EditNoteScreenTest.kt @@ -616,7 +616,7 @@ class EditNoteScreenTest : ComposeScreenTest() { waitForText("Travel") rule.onNode(hasText("Travel") and hasClickAction()).performClick() - waitFor { editor.state.value.note.tagId == 4L } + waitFor { 4L in editor.state.value.note.tagIds } waitForText("TRAVEL", substring = true) } 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 index ad28a641..5225f99b 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/MainScreenTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/MainScreenTest.kt @@ -40,7 +40,7 @@ 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.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.repository.NotesRepository @@ -110,7 +110,7 @@ class MainScreenTest : ComposeScreenTest() { @Test fun aFailedReadIsAnErrorNeverAnEmptyVault() { val failing = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } + override fun getAllNotesWithTag(): Flow> = flow { throw IOException("vault closed") } } show(failing) @@ -122,7 +122,7 @@ class MainScreenTest : ComposeScreenTest() { @Test fun whileLoadingNeitherEmptyNorErrorIsShown() { val slow = object : NotesRepository by TestNotesRepository() { - override fun getAllNotesWithTag(): Flow> = MutableSharedFlow() + override fun getAllNotesWithTag(): Flow> = MutableSharedFlow() } show(slow) 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 index 6f7170df..d4c8f6d4 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/NotePreviewsTest.kt @@ -5,6 +5,7 @@ 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 @@ -12,7 +13,7 @@ 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.NoteWithTag +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 @@ -39,7 +40,8 @@ class NotePreviewsTest : ComposeScreenTest() { ) private fun showCard(show: Boolean) { - val item = NoteListItem.of(NoteWithTag(note(1, "Groceries", "milk and bread"), Tag(id = 1, nameTag = "Home"))) + 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) @@ -47,6 +49,21 @@ class NotePreviewsTest : ComposeScreenTest() { } } + @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) @@ -66,7 +83,8 @@ class NotePreviewsTest : ComposeScreenTest() { @Test fun aDescriptionIsAPreviewToo() { - val item = NoteListItem.of(NoteWithTag(note(1, "Plan", "body").copy(description = "A short summary"), null)) + 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) 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 index 485410e7..091651a8 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TagsAndTrashTest.kt @@ -32,7 +32,7 @@ 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.NoteWithTag +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 @@ -72,8 +72,8 @@ class TagsAndTrashTest : ComposeScreenTest() { 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), + NoteWithTags(Note(id = 1, title = "a", tagIds = setOf(1)), listOf(work)), + NoteWithTags(Note(id = 2, title = "b", tagIds = setOf(1)), listOf(work)), ) showTags() diff --git a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt index 3cbf583a..aa3654f4 100644 --- a/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt +++ b/shared/src/androidHostTest/kotlin/com/pasich/encly/ui/screens/TestApp.kt @@ -28,7 +28,7 @@ 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 @@ -114,7 +114,8 @@ internal class TestApp { private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) 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() } 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 index c866abc4..364710ac 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -31,17 +32,17 @@ import com.pasich.encly.utils.noteInstant import org.jetbrains.compose.resources.stringResource /** - * The note's overline (design spec §4.4): "PERSONAL · TODAY 9:40 AM", the tag and when the note + * 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(tagName: String?, date: Long, notSaved: Boolean, modifier: Modifier = Modifier) { +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 = listOfNotNull(tagName, edited).joinToString(OVERLINE_SEPARATOR).uppercase(), + text = (tagNames + edited).joinToString(OVERLINE_SEPARATOR).uppercase(), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -63,15 +64,16 @@ fun NoteOverline(tagName: String?, date: Long, notSaved: Boolean, modifier: Modi 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. + * 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, - selectedTagId: Long?, - onSelect: (Long) -> Unit, + selectedTagIds: Set, + onToggle: (Long) -> Unit, onManageTags: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -79,18 +81,22 @@ fun NoteTagSheet( 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 = { onSelect(0L) }, - selected = selectedTagId == null || selectedTagId == 0L, + 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 = { onSelect(tag.id) }, - selected = selectedTagId == tag.id, + onClick = { onToggle(tag.id) }, + selected = checked, + modifier = Modifier.semantics { selected = checked }, ) } } 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 index 7218a53e..977a55b5 100644 --- 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 @@ -82,7 +82,7 @@ fun NoteItem( title = note.title, excerpt = preview, meta = NoteCardMeta( - tag = item?.tag?.nameTag, + tags = item?.tags?.map { it.nameTag }.orEmpty(), date = note.date.takeIf { it > 0L }?.let { formatCardDate(Instant.fromEpochMilliseconds(it)) }, ), excerptLines = excerptLines, 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 index 01b02184..05688065 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/NoteCard.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/designsystem/NoteCard.kt @@ -23,6 +23,7 @@ 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 @@ -30,10 +31,12 @@ 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 /** @@ -44,9 +47,12 @@ 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. */ +/** The meta line of a note card: the note's tags, relative date and checklist progress; each is optional. */ @Immutable -data class NoteCardMeta(val tag: String? = null, val date: String? = null, val progress: String? = null) +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. @@ -179,7 +185,7 @@ private fun NoteMetaLine(meta: NoteCardMeta, muted: Color) { .fillMaxWidth() .padding(top = EnclyTheme.spacing.xxs), ) { - meta.tag?.let { MetaItem(icon = EnclyIcons.Tag, text = it, color = muted) } + 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 { @@ -193,6 +199,28 @@ private fun NoteMetaLine(meta: NoteCardMeta, muted: Color) { } } +/** 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( 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 index 6c82ccba..65cab704 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/dialogs/NoteCardBottomSheet.kt @@ -44,7 +44,7 @@ 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.NoteWithTag +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 @@ -69,7 +69,9 @@ sealed class 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() + + /** 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() } @@ -81,7 +83,7 @@ sealed class NoteAction { @Composable fun NoteCardBottomSheet( isVisible: Boolean, - item: NoteWithTag, + item: NoteWithTags, onAction: (NoteAction) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -98,7 +100,10 @@ fun NoteCardBottomSheet( scrollable = true, ) { NoteActionHeader(item) - TagChips(selectedTagId = item.note.tagId ?: 0L, onSelect = { onAction(NoteAction.ChangeTag(it)) }) + 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)) }, @@ -167,7 +172,7 @@ private fun ColorChoices(selected: NoteColor, onSelect: (NoteColor) -> Unit) { /** Title and last edit: the sheet's heading. */ @Composable -private fun NoteActionHeader(item: NoteWithTag) { +private fun NoteActionHeader(item: NoteWithTags) { Column( verticalArrangement = Arrangement.spacedBy(EnclyTheme.spacing.xxs), modifier = Modifier.fillMaxWidth(), @@ -187,11 +192,14 @@ private fun NoteActionHeader(item: NoteWithTag) { } } -/** "No tag" and every tag as chips; the selected one is the note's tag. */ +/** + * "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( - selectedTagId: Long, - onSelect: (Long) -> Unit, + selectedTagIds: Set, + onToggle: (tagId: Long, tagged: Boolean) -> Unit, tagListViewModel: TagListViewModel = metroViewModel(), ) { val state by tagListViewModel.state.collectAsState() @@ -206,12 +214,13 @@ private fun TagChips( item { EnclyChip( label = stringResource(Res.string.no_tag), - selected = selectedTagId == 0L, - onClick = { onSelect(0L) }, + selected = selectedTagIds.isEmpty(), + onClick = { if (selectedTagIds.isNotEmpty()) onToggle(0L, false) }, ) } items(state.listTags, key = { it.id }) { tag -> - EnclyChip(label = tag.nameTag, selected = selectedTagId == tag.id, onClick = { onSelect(tag.id) }) + val tagged = tag.id in selectedTagIds + EnclyChip(label = tag.nameTag, selected = tagged, onClick = { onToggle(tag.id, !tagged) }) } } } 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 index f5a61e60..6f89725c 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesCards.kt @@ -131,6 +131,6 @@ internal fun NoteAction.toEvent(note: Note): NoteListEvent? = when (this) { 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) + 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 index 7fd2e8ca..d43918bc 100644 --- a/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesListScreen.kt +++ b/shared/src/commonMain/kotlin/com/pasich/encly/presentation/screen/NotesListScreen.kt @@ -45,7 +45,7 @@ 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.NoteWithTag +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 @@ -169,7 +169,7 @@ internal class NoteSheetState { @Composable internal fun NoteActionsSheet( sheet: NoteSheetState, - observeNote: (Long) -> Flow, + observeNote: (Long) -> Flow, onEvent: (NoteListEvent) -> Unit, onEdit: (Note) -> Unit, onDuplicate: (Note) -> Unit, @@ -193,7 +193,7 @@ internal fun NoteActionsSheet( } } - val item = live ?: sheet.active?.noteWithTag ?: return + val item = live ?: sheet.active?.noteWithTags ?: return NoteCardBottomSheet( isVisible = sheet.visible, item = item, 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 index c99f0e2f..8d6aef68 100644 --- 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 @@ -473,7 +473,7 @@ private fun EditorTitle( ) } -/** The overline: the note's tag and when it was edited, and "Not saved" after a failed save. */ +/** 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, @@ -483,12 +483,13 @@ private fun EditorOverline( // 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 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 (tagId, date) = meta + val (tagIds, date) = meta NoteOverline( - tagName = tagState.listTags.firstOrNull { it.id == tagId }?.nameTag, + // 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( 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 index b1cfb6d5..882b16f8 100644 --- 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 @@ -63,7 +63,7 @@ internal fun EditorBlockSheets(sheet: BlockSheetState, canEdit: Boolean) { ) } -/** Chooses the note's tag; "Manage tags" leaves for the tags screen ([onManageTags]). */ +/** Chooses the note's tags (the sheet stays open); "Manage tags" leaves for the tags screen ([onManageTags]). */ @Composable internal fun EditorTagSheet( onManageTags: () -> Unit, @@ -75,11 +75,8 @@ internal fun EditorTagSheet( val noteState by viewModel.state.collectAsState() NoteTagSheet( tags = tagState.listTags, - selectedTagId = noteState.note.tagId, - onSelect = { tagId -> - viewModel.updateTagNote(tagId) - onDismiss() - }, + selectedTagIds = noteState.note.tagIds, + onToggle = viewModel::toggleTagNote, onManageTags = onManageTags, onDismiss = onDismiss, ) From 9dd24399247343d6b44ac8446d33f71f7c81ecae Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 16:57:38 +0200 Subject: [PATCH 73/75] docs: several tags per note in the 3.0.0 changelog Refs: #109 --- CHANGELOG.md | 1 + fastlane/metadata/android/ar/changelogs/30000.txt | 1 + fastlane/metadata/android/de-DE/changelogs/30000.txt | 1 + fastlane/metadata/android/en-US/changelogs/30000.txt | 1 + fastlane/metadata/android/es-ES/changelogs/30000.txt | 1 + fastlane/metadata/android/fr-FR/changelogs/30000.txt | 1 + fastlane/metadata/android/it-IT/changelogs/30000.txt | 1 + fastlane/metadata/android/nl-NL/changelogs/30000.txt | 1 + fastlane/metadata/android/pl-PL/changelogs/30000.txt | 1 + fastlane/metadata/android/pt-PT/changelogs/30000.txt | 1 + fastlane/metadata/android/uk/changelogs/30000.txt | 1 + 11 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1de5a6..6f664454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ versionCode 30000. - 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)). diff --git a/fastlane/metadata/android/ar/changelogs/30000.txt b/fastlane/metadata/android/ar/changelogs/30000.txt index 6781c055..1237b293 100644 --- a/fastlane/metadata/android/ar/changelogs/30000.txt +++ b/fastlane/metadata/android/ar/changelogs/30000.txt @@ -1,6 +1,7 @@ Encly 3.0.0 • رمز PIN من 6 إلى 12 رقمًا؛ يبقى الرمز بعد الفتح بعبارة الاسترداد. • تحديد النص عبر عدة فقرات؛ جداول وكتل برمجية وقوائم متداخلة وسحب العناصر. +• عدة وسوم للملاحظة الواحدة. • شريط أدوات قابل للتخصيص؛ إخفاء نص الملاحظات في القوائم. • العربية والتخطيط من اليمين إلى اليسار؛ الإعدادات ضمن النسخة الاحتياطية. • البصمة تعمل بعد القفل في الخلفية. diff --git a/fastlane/metadata/android/de-DE/changelogs/30000.txt b/fastlane/metadata/android/de-DE/changelogs/30000.txt index b466f404..6167363a 100644 --- a/fastlane/metadata/android/de-DE/changelogs/30000.txt +++ b/fastlane/metadata/android/de-DE/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/en-US/changelogs/30000.txt b/fastlane/metadata/android/en-US/changelogs/30000.txt index bfca0582..251bb908 100644 --- a/fastlane/metadata/android/en-US/changelogs/30000.txt +++ b/fastlane/metadata/android/en-US/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/es-ES/changelogs/30000.txt b/fastlane/metadata/android/es-ES/changelogs/30000.txt index 42adde92..8f1c6eb4 100644 --- a/fastlane/metadata/android/es-ES/changelogs/30000.txt +++ b/fastlane/metadata/android/es-ES/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/fr-FR/changelogs/30000.txt b/fastlane/metadata/android/fr-FR/changelogs/30000.txt index 2ed7ebe5..57e1558a 100644 --- a/fastlane/metadata/android/fr-FR/changelogs/30000.txt +++ b/fastlane/metadata/android/fr-FR/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/it-IT/changelogs/30000.txt b/fastlane/metadata/android/it-IT/changelogs/30000.txt index 7358d0f8..47319d91 100644 --- a/fastlane/metadata/android/it-IT/changelogs/30000.txt +++ b/fastlane/metadata/android/it-IT/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/nl-NL/changelogs/30000.txt b/fastlane/metadata/android/nl-NL/changelogs/30000.txt index 9277bd4b..ade366b4 100644 --- a/fastlane/metadata/android/nl-NL/changelogs/30000.txt +++ b/fastlane/metadata/android/nl-NL/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/pl-PL/changelogs/30000.txt b/fastlane/metadata/android/pl-PL/changelogs/30000.txt index 11c62714..b616a28d 100644 --- a/fastlane/metadata/android/pl-PL/changelogs/30000.txt +++ b/fastlane/metadata/android/pl-PL/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/pt-PT/changelogs/30000.txt b/fastlane/metadata/android/pt-PT/changelogs/30000.txt index fc5e79c0..35abb354 100644 --- a/fastlane/metadata/android/pt-PT/changelogs/30000.txt +++ b/fastlane/metadata/android/pt-PT/changelogs/30000.txt @@ -1,6 +1,7 @@ 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. diff --git a/fastlane/metadata/android/uk/changelogs/30000.txt b/fastlane/metadata/android/uk/changelogs/30000.txt index cf403044..b7eeae4d 100644 --- a/fastlane/metadata/android/uk/changelogs/30000.txt +++ b/fastlane/metadata/android/uk/changelogs/30000.txt @@ -1,6 +1,7 @@ Encly 3.0.0 • PIN від 6 до 12 цифр; PIN лишається після входу фразою відновлення. • Виділення тексту через кілька абзаців; таблиці, блоки коду, вкладені списки, перетягування пунктів. +• Кілька міток для однієї нотатки. • Власний тулбар; можна сховати текст нотаток у списках. • Арабська і інтерфейс справа наліво; налаштування в бекапі. • Відбиток працює після блокування у фоні; нотатки зберігають дату створення. From 5b0f609eb2174d0aee58f06f3032d9d2a60a06ff Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 18:50:07 +0200 Subject: [PATCH 74/75] build: bump agp 9.5.1, ksp, serialization, datastore, sqlcipher, gradle 9.8.2 and gradle actions --- .github/workflows/android.yml | 24 +++++------ .github/workflows/publish-play.yml | 4 +- .github/workflows/release.yml | 4 +- gradle/libs.versions.toml | 10 ++--- gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 48749 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew.bat | 52 ++++++++++++++++++----- 7 files changed, 64 insertions(+), 34 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f36ab0c9..6cd1a534 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -44,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 @@ -53,7 +53,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -110,7 +110,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 @@ -119,7 +119,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -224,7 +224,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -457,7 +457,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 @@ -466,7 +466,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -543,12 +543,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: build-root-directory: first - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + 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. @@ -597,7 +597,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -676,7 +676,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 - name: Grant execute permission for gradlew run: chmod +x ./gradlew @@ -756,7 +756,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 - name: Enable KVM run: | 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 f2623a89..c9fd9a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,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 @@ -80,7 +80,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/gradle/libs.versions.toml b/gradle/libs.versions.toml index dc3d7001..adc675c2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,9 +3,9 @@ # 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" +ksp = "2.3.13" metro = "1.5.0" detekt = "1.23.8" kover = "0.9.11" @@ -32,13 +32,13 @@ room = "2.8.5" coroutines = "1.11.0" biometric = "1.1.0" reorderable = "3.1.0" -datastore = "1.2.1" -serializationJson = "1.11.0" +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.19.1" +sqlcipher = "4.20.0" sealite = "1.0.0" sqliteKtx = "2.7.1" iconsLucide = "1.1.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..9fa7f288283f81eec4952784c271abd7e0938dee 100644 GIT binary patch delta 40003 zcmXVXV_;=n(`?KW+qP}nwryK;Cbl)PZQHgraVDAA_MPYb?w|dC_gdAps=7My9_;c1 ztlkb0=n*FZH^_`E@f^5C0__~sdBLciD5?k1(iwZ&47JEkE>h=Yivi)ncmMTWm!#>1 zP0D@}Oz1GG=`SP0!W|6$o7#+bP(>;jXVYf`*W8LJN9?}e+D;jFriyqc`RL(vi%n@Hjx{%ZELe}A>%#%# z@w=A{ro?$Nsxd^(Alf!dO>_cHSq>ToA`_;#5(ESU0yKGn3_qC`NfiTpRkWH^dO=Nz z!F$)9^7Qw%!a8|Lav~Bg5b}b}d&BW~A1yvazaaKCWGV!yeOkV@nOeg#AbCW(Q!KVn zm^Hod<_PtuvRrfpXNH}3s0 zBQw+vX;t>HUrC~fkef{cFNz(YSaX1#O>*74lzykPnbmr#D|Wcs_(vT$1Jqc&DFYaT zJ?=D}9&PaV2WRk8OJilXiL*QFdo-hLnZ2*a+k7zNh3`CQyojCb^&sd4a!vR`lH59( zCk%q_&nKO4geOBFs=^=?dmre3C6IYfzCZ^B0nz%u5-2efCJ!NF0XG%ZM9>YyWMxDwHAQLKl^N2eXW0b%uyN5w!oW(C>{ zxHU3OTJ_RNH8zV&2P|x{%D)h`jD#|3%rQKUMa#||CQ(U_lZ%CBI$NBTi8UO#fG}L}c;$&GYowyS@d*!xfmd;XaR^Co>+3^7l$Xk)41ROW*|i9re7kr#d0`zM~(OX|5>E@V4N+?U*C&FG@~FY(Q|weNVw8{bYfN zmyAWCAexxhOxbtmw4UAmC${fUT?4AN$G#+n`8XgH*z285%9=`w*@1mjF!cOIRA|hoCS^jd+dfve`9Rr`?WOw<4E#-yd68`G z3I+lWk`4YpNJ7z2Bxj(J12NMWOG-#zYTAQZLmMO4i+{3IkPC7ci?iIAT6&dPn0U2j zY!?noAAH|}qNRwV0e=cjdcOy*E*85`Wpwx*Z@JBFb==>twGn|37WCiTzPw1h_L#W5 z3rmk_b6jLLSg!}}ItvhX1k%3+l8x7ePyKtG4m~;9{%;?}6T<1}I zxy%l89Bbga$BPx6s{F^a=(e@0(ILO>n;$}PQ326nV!TtD!(;^}DJENA@FvWa0T2%D zOJYr<%x~+djt?OwKtV42++E9v5j(Uv|Wo|Fo#0z+o0{BiJx#xn#E%4YFNAnlk??TcfwFkN1?_DO90b^ z)`AjT#uhE}vSKaw={;HlGh!|x5{m%nOe`yQTMFcK!D9`kHd-yvb(!*B{ zkdYPNJvH}(i-QI>GohnED1gj&u1mgUC;1`GrwD|w!bAejnN9_X~BiT=ZJ zbEQHiuoV4i6vmL)29>LtA^@JrZfcX*ArMfhF1-@#meuZYG46R%YLoiJ_7ouWQ=vK) z6ZVDEMhK3(b;vqP^&V29)om_dwqs7T3p}c!k+E&xS0Ce=Cr%3M-6%`We z(_70KrhqEJ#w-;+q3f*ZTEliM8{CIR>Fwx**1`_Jmw*5Ve*r8|o8u>w>~jz0TcC<< zB6zb^mqy`z(9Gd%RUgUL)?~Gawpp;Js8XT$xeEKK3N6xUONhphyt?=k`uXuvm|>Fv zZtE?`vrV{7UhR19fkMzC*kQr_3|d^SUhQf|hclw+Mtf7B{&98KVV$KE*%l`?u<82; zhJ{n92n8J}rHpFm$H?io2(v^JY~#TpPLUW~0K4BZd^I*+tubKFLyX19!7y*vo&g5N{zD(%?P9kDI0(o*Gzf^Y95@6z z2u$(?juFtt3rz$2hhrA219oEbPj!(|nAVv^T9~v(>5B68W{K!!V~o-X2VQqpG%HF5 z4-uE$X9TRRfzNU(g?QJvw+W;lYeM-%AE3E^T<70*GGrnZS{}q$y{-bT-SVD&()o73 zKUda37{|&0Qt|j51)p&@37NP@lDu0eiU{Fm`zgST8Jo^C>r#}tOdG{}%(V_%H(h*{ zOK;ndGRaH-RYWg9Rp*S^zCLBHhMS9m@lEVG7ZQR4v0hbAGmyLPoSkKqAxqh&u*bvf zJS=v<%XR=e=+kmX_O^;6hfUu^H={MCq|{?4ZLy`5ku6Ij)?JkCg>a>@qos9|c`UX! zdk=U#^T1a*L;-=lI@jeB7Z8nYzLaz2ML#pn+)OcxI<{FKYLW=ukNlNavqRWun9a+^ zGluyb;l3gHTh$D{5m0llM(3u;jHmX<--eS$g;-%`tGBc8(5g74N`;zv2@_nCS-`W2 zv*Tcxzj@!q+h+yuR%SsY=HWIai1Vn883GJB0wB1xQ|xyWYnpb%l)$VMKpjt&OXOhJ zB%utOvUDnk#kdViCpJJFN2&a3Twv#|kr$jo2sg26D$A_J&EVXzoqg3*PL5UP&$1Y_83s93KTGH#t!%F~72c8~fP|Wm#F;Kb19y z^ltKU_sy*Kx=}{nE;(_^-I*Ghw!j6BscY5ZZShTvVMTp~!HN$27MZXYeN>H$r;5a( zHPg1A?QsQ_6)l*NIxuWDk3g6nu!8u4RTen|TOGF}%*GxSmI>2R>*e8RZ@;uD-7(;iQAaHcyNs4Os#y7+hw`=D-mKK-S-x zAi|l}$inoykiTua;LG=Oo%bJII1wl1BKX6|Dl^W>QWf8gQQ22$XKGLX>j; zQIXp5xoNlk%dT+rWH8A205uexxr7&G9&<8#44Wy5fx_j5_#Wsf+Zw$wF0q-jt7ajCbjiM z@~V~XvU)ZiY#wFc3VJO~pc1Gy^O??n#-KW2iWZWVkzq~n7y3fe@7*ICXcTzeK_6(SUMdVubu6i~@{L8e)K_`J8H@C;O78SNc4 zo{hF_lsY!o@+Iv}FmOyFE<5{cW9Ub3L^`UHg~C7k0@JW#(z|!MIw0qH;Y^QN5td z$}8>4u}dSr$m1)##&I3`$1b(Uw>Y?7sD(X(LF-yq`wHXQ8So#$LBsTEJbg|=fq+;c z{!h(NVNxVplTZQ;oR*l;!;ISWmMm%P;2}rXi0$XE3j>5VkRueFH8{l=kh|Q}+Ti7~ zX=SKLhRMH>u;hbCT?UZ$q(~GS(H+Vc*G(rpvyTJf-TXh!pX2jD(v(eJn)|*hZ$+Hk z%(TYidJFLkdJ6I4wz(Ltvf`?LD@x6G($W7D*F;|JS{y*nkcM*vdnKb&*C{lNp53b7 z-9{YXNDbXL_1lKzs}3;_+kqAfgSpNEt|g(84jrxS)j;_mmU0HGJ=}v18`!Y-xXr3( z{hbE!?fU7rPP0vY&&*PErjB!1udC2gpVAA5r(gm>4?l*ychLa|u_N)zePkL`04$Ze ziMDNSxd0I0J#3Qt5HD}@7LP-SK5MGHWg3AMrH89-O^aU4L7pgUg1Q92$?9{0Cp7@F z{kH6#_aA%q-acs5a(ZPvGHGd_$w$xi9ToLzE;<-x#m$soBL zH+$MNyBLGu*Q>=;tW$N4og7LGd1BaC^Vy>NkODpwxo~XtS4W+O25i`VpFc3YBI}1!{dQ>F>I}MJHVQs zu48m&#aOCCvS$@{MA4kdJ(QA3A}Gr__5IO*#bbpgF(?%jF{4+vOpJcnh|rqsfESzg z7ZihPLN1LFzX2R9|8zg4V-?$?)e39>gd9j7!T(D}PvlbyR?RPN5`tdvDFJp3LsG^C zO)@C@j(zZdm+I&Xvz|M7n#>&tS;}BRlSRzP9pqU8qdrMmjLJ^gf^1Fxh}4l|IN47> zlS3mKL*5%Nv|LY~`2r%Catna8kmF6>I?BHGx9aKp{&;~jhKmN|At9}?60M-Fd4_6e zT4^}QDU@eAXnF*9bvjTebkR#QhLW_CNq5ZT6t^-o8t5S<6s${nR zAfHBNsM;jev$~n|X|MrMFJMoR>0nvSP8%kg|Gm#fk@ef`RMG0(#hB$c8eOu&@oKaEI`^;(OsqB3eOo=Lhzpy`PaggOrgG2jYL0 zotm7mzKX~w=PuT)MHet7ZSMQ=TR2xG>3WqoYWyv{#z`43L{o?x`D_>STqA*lf;Dwb z@4O0O4*4R!Iv~`TZM|#t5c#^}ybFUha)e&7ZC81N3&Y_Z?xh3zXlmM)*6W9(R&~{O zbS6&9P9V8<{+SKUKX3twXC9n)gBKn^qa|iauhXzBX(nS&r$rNmSRl?N^<-yj`$M{q+o$Ch-8IlKhN8nj1d%SYs3S_rm0 zc~F1N)In|RmkY};TN6ctVJEpjAeqN_8ciV=WNvgSdxYE$o0mt;XVat~jbHGIx*BHj z?HhO>JQ4fmxgk;ESg0NDf>w0?h9|(mw00=jejoA{&gT{5mdYmdCfDQ_y+)DzLlxnK zUvSTt>yT~i`-jRhibXZY^y^%x^hZ=j=qzZX167z|l*69<0pHZ{GuD62F}p0;pf~xO z(iS)*jGWVAq}%d%e?sA1zQ|dKS&1P6e^AhDIuhKbz^l- zU&m<~33qjgpBZZ82eKU&Q^-G=7x-kabRQ_bnL6kQ-8;9EPlj&jLQt@C6&l%cUFg(4 z0A`L;9rG&CMxm9v?KXOn33iBcs1$_y0OhVtG^Tl|!c?@V= z*SPfR_E1;Aa7RF0I4VdxwAa0u!E=Jgkh1pHW;!9}Hc^y8yU{BLjlz~03VDH^@?sOt`}q>&8Z#anyF>X zF=Gd-D1Z~?mhk#NyUl%5{-+lx1PBNZ*#EcNG=b!rsP?f)oVOLaWTj9>(-*7;t=oE* z>Al!lFKi!>y|#;s|MS;m1*Rwr`{kc5GOqRiW?fG|6B+e_w#P#ay(cGQUA9%u#**zn zEi8Kz9 z7doGX*SKi3TFV}x!8a-Qw8wvTqRu-u<7P2D8sNr|EZssaA}i7BFVg z+Fp6b4Q=n#9z`ANLy6X2zoN7#QZuZ{lMK{*kH`Bg{jJ^DT0#m%DNQhsaI;}W_XI+Z zQEUQaoR)*K!_(|SlAG+S>h)^t9tKJz!$I52F|W zxzsa$C>8~|KX*pUWmuh}K@cSACO4CC?|><{at$Lo?SU3R?E$aY>F&#)K#S)P`Tf7Z zNhz-O)B#$@WSFgIn-B8O?qwY7MSr~k+|?pXFLZhnp1Vmv%$0_P^u_!hj_U$&Jr@Dr zU_4zvel+Vcv-HDo5V;+ z?3PUg;L#}aHg-BCrB5jVg?b2j`J3i0bP<_L(D;Z>v#W(GbdhIS4L!V_PyC2X7Onk> z>cd7{j4VsS{*o$c!G>nh-**q)dqm3S*62TgB1Y|KV*fMCbpFqaji3_+%7L>W=d3H) zRXv`cSllX?Tn6cuYw%P1(rQ$D-=uBlII1Cbu^|{29%`Iw?CW%#m?C5dfw$J(k^r9V@FyRjBq}rSXQ`cr~I0$`uHDI zLu(0f{s&bJ2mej3tz_#i)paXgnD)EhqWc(JB$=0y=aT$`_{BIW&>w-DNp!ot?ocO^ zJmak6gzyxFXiycCvkoYC_1Zu2(IW_Nq@ZcG#nXq{rJ=G?flqakT&$w6QhZ(jS>4b5 z?V0_V3HxuoL2>8O1TrW&U61BWK@Z+*xo`OIRC+rOF&-_#;0GLqkO5hMyJTSS-+ySW zh{ds0>Spafa7;8PvySGEUfAZ+_ALPqMrhoCuuRbmK^<1p4oe+QH{md;`IJsc`t@X2 zsDwt+1ybPz``@vgQ_8ykOjcp=0;Z%jX-{UsMN!%6{U!vB?8ZhTXQAZ$=ZYhvHT7%~ zLO(BF7^s+$-+sSWOmSQci$y0k zICHjFd0TsKq1?=Dd|+?32~2F~US`p$j9kjFziT#MTzw1)ugkB^VH&rVIXh0Klfv=- zT9ymes@lh`!4NYW$;kRDZ${TH{?V$W!1GQXuaRFI(>!FG+(Y%B#g^8yHc(2{=e8hS z^_xhL4gd!{ZuI8&251LRi25cBhV`*MKA8$G7|)(b_8PT%IG`xtJ!){3UPYcGIM2CV zt;xV^4a^&!;0J#H0LaHW2@Mj9`*7*pR!;4X%&BCJ>-F0tZDJqF^<6=-$u}k|jd!?+ zjXNX;H40t;hN@UrK`jFX;vUS-;~(Zi(3g4I;8Ri`+yhqpc!S*-hjT(}YWdDU zf1T8$4aoGy5`$8SU#DrK+1-l3>06S`WTLJb!=fJ%)-OXH`l2N{$lgkYM;Zh^JqN3G|h;8T*V`q zgQzqm0+${vPnU`Af*mA=s$`L`^H?!_JyR=h3NX=vFY#CTo5>!gnDZY$$V2@CCmG3U zKG5a)25>}2Hg5#h>bfJ>SPYJ6-`Z-`QcW6|sh0d$$#hX8MJ7?#B+xD~*Y2be1x3d<3-6(AKWhoJyj-Kj zE1-iH62zYl2?!%&rK?5Kr%~X*S03@VzqcEtAIzq{y~1WG-*-FW<%orMdW&bx!{LV;$=6!q5ha zhmKQKlsM3w9J!NWPDlngM-Vyd@Ud;>@V`f2$P@oaDg5D@s`Xl?ok%}afU)Zs_)pkF z!#r2HaKb}^fB=&Bc?pu0n9-6I8&Q%sIdFiT$f~-Fq6HW-OOsN95l2O;nVPRBR3z%< z#R7yD+1|8YO%4Z5wNFed(R3voC;@ zYwh+fZ!QL{rW+o)+s#ovbnG3Jc9`*Da2?u)jKk`Q!SV6>ljf2WYuV+abr>A60KKhd zYC0Usu(5EdF)=0Ger$g^$%W~=^U6`2ps3Kuu zju8*Pf&;_r)?tTz%e(G|kyI_m%a25dNj1ybUuD*;?yJKJ8KvDh>I^78oX zCc)rZ*>x-hvA`LEVs>*D_iE1Jh2WCH6XzAW8|r&!XI+;-Y?4y&y(?LK#&;lD;ID zx1h%@t`;z}#vMUv72BZw2{T*4<*+27p~l_fHyW;^qPy1;J|rPo1n=5|zy+bAuwV@D zxWGLb3TEIOwr>1M^9bD)uFT;k9p&~HXI&T`(^}cS$hQSC`IPehj~G2LDuF_%l=-sg z_AR1d_* z`H67iB<%^MBf`nSsV)Ytl9}8>`(PK}xJ59=ab|Q>PVC{9^E4r>%yp4JxCRr#x)p># z#>&Am`(_-*^Xg zL|U;~Br+D+!*YM~)a5wo!sxwpBS`QUhBAvWLCH3oFLi~dZ5yz(H}^xsyry6I@KIAvYY;JO?GTS zOQO)Ip-lmZe4NMQ3AJsAgyf()0I4dON{!_`F?$st&3ZUv?E#&^&ZTX7bCWWtihcd^QApR37;L97gbgyP;SdeJ8))|unmqG z`AV5oWMa#JG5C+a-TLx*y4`;2>lPcu^23y`bZ0r1qBiGI$M8@PLeh~5`;?PY0SP;^ zghXmEW)M~nfuPa3<|nbJYwzohgOhQ|)v{_>rvl^|XqymoLjU!_+}aP0^MB5P4($J* ze-SxUb@PdAsP?xVwow6@Cvze;i^X9~FCje&*8fN!umhSiKZMrKzxrAg(*oTAY z98?h&{uu}}m;n=k$lORSmHt1-$f>xaR@U+8Fr}|vRlU9BZ3x}}Fq^B)qoG{Stz~VD zBd3(w5AaVEHmngTcI2&(qncyMm+GV8KXCeH!kik=jpX`@Qellc(~u_WqiBy&#Y1wh zyQml!2`hh&IhoCgpoD_CjvqI~vWk@2&5CZYxB5`9j9cT0uiBJqDtN(65$CJoRoA~q z9_)FBs$LSLavA?=mER6}g#WL&?kkY*V*gTk$^VzigQ7u7u4!Ze#%gT4p=qFhZ)2`I zWQywamJFhzi)M1(sZbw@k^Gcg3PMm0UU6;eP)Kw1Oy5?JlMwzGR5B!Z8^)`PVyjrk zxm{2(^1oNHdZEi+@V+W#C|SlK^7VW6|CsGK{+OMZ`}Y3^{~6Fe<9z@2IIvUI0LC>M zVi5B<60@MqYRQcOTx<3==JnJXp@B>C8${j0DyDbqo1%s{o37LR(B7qP^G}TFD5-_> z>XMHn@qNOlM}JDHS>rnv(ruwfje(z|Qb&F8e0IOSj5JUiz$WD>JHt6h`8Q5r9HT3d zC1lbiwfG%)WA9iZDU-@O6nv)>FNf?{Vu3iQRv%OFiAr<~q>0`vSw0=4FoA&6M7E{m zVokx}cJZYi>t|#Z4-lj!9|x&Z&L_CNFzeIb%kVs%uPu{M>gO_tD+ENH=2f>3kc+wb zuZ2=;nrc8eSjUS>$ib(H6_p3juCkVgnL%$g2ZYVQ2bWH5O+`*=nBW~G3~R?Dpqi2S zl&bTKIo*H*uOPn?a8htq8B&>jJoa=>TSB|TG)^Ao=cnCO8w=q~0S>*+9b@2Q$t)Ah z1S4+Vy^xy?!MO*pYX?v(<^lXUhJ{cn!wqty(#+M|_P;HiAA}Zph$K)APzEn4m--Wr z((0S?IVm`H<$?l0yGkwz!#E0haerx^{W`v#s6JH#;ytriEAi2;E5*}C3Ujhvs$UY@ zi&91z;@gjaKCsY7Tj5UN$K^vymBUiH$Y>gi@S3zwS7$H!pfJfio4Kjv&C^M%RYZJ) zAIKt)$W+^oz-AXJbHZY;j$&!niii*TL*m#x(^?>N$FefC=G3`jezJan2!vxrN6Yd@ zQ}g`*Le))aAxs7*Iho#VM@>;+h(9q9nLVQj005Fa{L2*3c*Dx}4X-jng zcZc5@ypf2IP4~=mVE0RGgoF%j;cL>1e@ne+FYaMl2$=kquo~35rE%d=fX{k@WEsFd zKfcm2Opx=@SZy8=Zc#-xq%J7w!iW3(SB#|hI7bC{$$8nNIhhX(nXJ?AJ8^`nvYWAh z>W*M*Q9Z4pQksvndCs+Z4>%!;W`}*?(LSKn>e|feOZxrBpjy7UjMw($qAz@f;Ig8t zJ=K~B5i;lXkX@DIV#dm9KKc6EALRExUG)YshST`)$~r_~FS~fj5k};)2dc=noPgJ< z1x%^lzv!O1;z@4x@AK>p`#?vWWIqQ|ws0L;eLmmZJ)yU5SAA!`_(Eabvrz*N`6QbU z*u@bn5z|!MwwytsFfU18tqB4O5DXWTx<6*H$;URAtBvZx z;-$+>@zG)_MX2yZ`*j`e`1Hy9;*soL#NT6h9~f5s!9y+`5Z#=<{x?f79^PIC{Ckyi z!u-$2&XWJHVIn4TiLwC&oX|v&1JHo_2QSX@FSgdTven#FC^7Ou3Ca)}U_trH*EM75 zvCo3;epVgf zhXa48f1^3FeDVp^Rbv=WgraGHmUbm?_sHD1V2GdOm}?ZghBnA^k4JIGz;iU=XEq;_ zYH()D(KTln{$fYW>sxDewcG&kFJMIrOEJc(Q+KZ>|J)qoY`+Z#tVGlns#xQXO zH!wEo1Y39nOKDu?)A|VgAK!7bpFJfbiQ555#1TX!Bp6CD2g4G4wZ!%E%Wc54K^>Yg zxw6o-H!enJBqge9dN$raSqbv^dS8qQVs*=El-x_d74t5*OLjLx{7%LxvhNLm$Ik(~ zJRgtOb$#ILkwiGP20gy^*GI~_Dq|gqupS}H_T>!4nH}Awbgh=S3)va_?W(|FxoR@K6RZ7ry9@Pe$kAO7C_R-ePkZhd@Dkdv92UF-m#;ni`}jblB8GE8?}{zk4!X)&}1_puQVs+R%wn#X89MD zqqU#m*rEiqv3WZ*Rhw%Q)B=4+bJi*Bz1Y~llr$(VO5!N?-iGOcZoIyPS5f2fh!=Gu zg6@kM8cWI;eu39XR+PKJ-ZIYLtYST?&13R%U59`Vl-hZc3eu%97m#O~*P0$EkEUNa zwH;4}-J%$0f1^ffD47sZ(i~azngvE-q zG7ql_!a_rsW0UjKzj#ZvAsv@Y(8-FlGji{89fOO84{qiQP)yy#ToVIE;tC0ej1dgM zJzCT2-Idn6`|EkSo8`_c9EIZkgzQ~-gn7u3q&X*z=JI6~WbA0;39cBlz&Y(TBbD7y z2J_=J&>7S-{F6wh!9ZQcx_fU%t>)C`*&JMH?xwxMN_X+pRu@XL%V01M+?Jwi+WBp& zvo$*%%7W5{*ipJ?do--ZxKfi~qiHqtTpLxZx~aKX({0l_7iexlHEdq1#3;Z z4hQFl3$fh9pbN^qKsVFxQ}D1%QI+NHMY>z>6~m6W_>kAJps5miK@7}1lyTZ3x_$Uc z=!Kj;H2Auz3?SVRoxede(=9?E{ZdmL7fil(ozR*}&?%HQoM*upcZRDX^K^WKIlUJ8aBanu2ma(Wwnm zPiBFGp+^A}r?+elWE~w*844BwbvqZ#iOZLE!l_?@3RH1wVCT?aS)s5Y>5jRCT!csG zfYSVx@r()u2RXv@#?y>WGZGBEp|2V>3QM0dnlh>;oyB5!8}EUkQbj zT+Mov6$q^YeiQQY64*=!Taxv2WWqOL)nj>}18&|V2{xY~QRbo`ITzkn^2exaAl)4{ zVW5C$ACmJIRW(Yxwbu0o`Lja1L7RS?C3Qt$*A;B*qMk4fbCx!QGs4n4u>4e_CJ3Ecyc2XXozicrB717$`VHwnrr^d zfZ{f8?yfeb9_}_yjvB7^Vor{p7Ow6Vu8d~(CT?yT>X0D+v=j(PjhcZH+CO`-=j=IO=87mZvL**7G#SEM9I8rl20=frxp zT`Q@6ketQt<#*L{oZI2|_kQh}2tmtJPqLQ|OqdJ zcIchJr0VH0%*8N$pH(4uEoGX_d!<=Rqb{P_k|;Z9=|*YC6?>g76%+|Y3gW*gC}giN z_s?6o8Kvfgt!vw`K9o-Vu^SkMY^gI+;~ODyt_$~MaUiqwHyf0Skg*72d|(-9nZ#rBYP6MxPfv> zZU43hc^SuE(1(%@)W4X>4b22I-BSyUI^a`PJ3j8ML$!z4hfWlW)Hw-8xEvjpL$P?& z!Fg;Bu^w&rsH@0453M4NBa@%;XpTzx83Pi13H1ad{9(6@ng8t(Ks%fY9&_k@By=YX z<6}T4VQG0)S~;%OpANAd%>@X zqVI0+jOu$h?`(;lk|~T6$NRO%RsFgeCAI8wL4@Dd9+c%`-6G{64PL*8#6bw}-M1&# zIk)sZ@j&wJ1^*8%MMbrWC;uk+Gyhw6X_XZQ{#)x4AS0?vp>1yYxmgyS_V5TPoBSIU zE3xbO)AW;&(+Qj(vtLp_lelt^QZHHPh7CYfnx$AJFy1hQ{Y>?UQ#Hl65*X-@{jrs>EsNNQ9uu2>%Yn zQen3G=f8#o`G1+4EO}jEyMrtqwR*xzQ9`i-cuLxQKNKBtNeBiC!!jBAe-#8MPxPWsbi|`F> zSl?G9wv6f3IJ^BZk;XIpk#S$Mif{5cx%r}a$bGnu{zF)2LM0<7FDHxZ3gIV>7-?v}i(?zOXQDNtLc8+Ei=tVRZrT0t zCNj+ISIUpgmD^Lh_ZL4p$B^DjU0Oxv@~o}MVcGD^@in+dpjjpF9kn%gB3(KmXIuXf_KaR!K4}xR9rZps#spIUgok9|Uu;RUkjqp% zI<4!>LZ&{TAI3dEYA3W-($V^PIQQKR@W;=uYvr-0hWpvBb=I!jII%sKpdXST+(n!$ zDB}7M^%E5^CSp3h)jsgv5Q`F2HLM=QUYOmCBI7+xu%O_YXO1%D zZb)i}D{}`@H1b!A$=TQ!&&(bV8f_CBhzV^_$|eD16}gVEKT+7rJ5)&g5KRB^f^CGA z_h;|g+Ok>R?@r_@kSi=QmI&3$)8xXGwur~8a|ZGVg*F6lj#f#T(Gp{EtZ|aD)XSNd zttI~>N>y`XU}IDLg9fVZJ4CkW1#HF0H5rM>M0pTNpZvl>r`qR}t2nx2g24YP#xl1b zmc2oQ7&hvNM?dJHETU<9ZNgnZA!Y}a;;zGTY%`}Z@ z-B>WyK@Y_j)PGHm*1t9-nOWHd7=g!l62Z&RtbtU+-X?fpF#NIJ{RjjfrP<4I`l(cdU% z*S82q#?#NvQ%t$EnSdz|R0%Gse{oobb}DI|J;OnI7`G8REP(*?$j#gn;F{d4cw#!L z=AIb95O>xR8ip?1^D=$1RUt@&MBq*~sNm4p)mRyb18Syxyxqt?V(^d7WwidP`AoRA zO4rC3(!yqRUe@xoPPz3oEe7-oYCgda-ZCV=*Q(DCJ6mVe-KCIys0Y`E77Xb<`>T-4 z-5u%;npx*n9!&LGl$H7bZBh!3GLN*s5bn&EPbk~9U>+LA048Kl+QD!Np+V(gFuHmR zF`i*XKKpa7b9!zTwUqoT6V9U%3ZD~~*ThjfB7}d3n;LJZPeUJ3JqF4-s_81*5P2&1 zK{|u75m_I;;zNUm05eT)Y)Y(v<1emp75BCSn79=RJDyi5H#Kr#r9v8ch)`g%h^hcU zw?=ZnCag8zWbx5psRgIgGuGkiQ0=~#yzX!h;Hz=MJ>}f4!e`iek)6LLP&X)?V#xH5 zdd;M<{R}Q2d(D(Y4o=|pAJ8C^OMdFc^OKNCvx@tB;Z^U8CPW5C0~UCHQ)3hBDl;sP z{%&X6urPV^aPb0)<3hQV#7bV%K322PK@I`lTbI@?WFdwT3x9irS%I6>>i0{4kd`yL%dO4JI@?@6!*1I#9inMKc)yj7~HabPCZ~MH{UT=%&*>M1Q*)&un5}-$!)3 ztD+}qZJ|9>wWPQ^_}6Gcn06Vj;Lv*5jCf<$MEmI^L? z!cYibF_M}7hT(W4=vo74@;h2OZ%l6CR)#{{?kC*6xA9Hfe_PFKiq>v~e&H#?3N002q_lhGp_lf_d<6GV_e20~;>ST-yRVRs=z zP`o>PlOI%5f1f_E<&nlgD?VnYhZ>`e@!`hS#&{-`O(d;+B9m@QWrl3x9Xn}TIh%{4 zR<12RYRAWNg;cIFI&39#c57pHq%CD-+Y(B{-n8M)!NHB(>uf2XA1)-5L>#xB9H|nq zrF~QT#!Zcnnh-&tFH>;h_O3+I7OcuRBON1-HI%eFe@-}7HfuW__he3_lNoEsfKO0& z%sOs$B(3yF$G*X1c06yOO0ar?W8E|7OhHvLGcsa3yrE}-y*=*YR)Imd*GiF-hMt+5 z`tnX9J;LPxmk)Ykkd>^H8FB1ft|yVp+i4|!*Fwb0e5}349<~a}d>P(9=Q!!t4WJQg1)&nEJ=W92h6#qwEayGwWyXh%m4+OS>_en~|c*f3ist^*j18#K%(YTyoPm9f!l zpUk_G9u8s)?ySLPLUR8sESJJjL!yfxo5S_Yr ze+PC_(0s<@X)fK8s|Il|b_HOdi+g$epp`7xL3Dcr?jw^~^_(qD^I4jYS&;7@>{Uzl z>LqJ~2%x4Ke%v2K5I5+oFNh`HY#@kwH0bP*VC(-QKn%nLH($@cJr{d0fWtTvL@ny{ z)>j7+!cv{RhQTwM&u7~ixhGEzpe;{=+qR#fR1$42X!l}vsyR}u)MCEA#k8T z8>H-t^`9q!T^38;P$Zv;WGyFWe@9Br5+OYk?-Mj%U%dWNCv!rxW{I))2k>4zK@|>_ zoXxETvn=UBiA;wU+pSs)f|VEpPnL4NXX$l! z^i1$Jz?o7FnPKNJ`o~PXBYM;AHUH#4>dAl7Pgek6Q~pB>+?Tb9@ z)*w4x31CVV@jxQiO%@!dkj>jeLGfu0n-aet`lk+{@yofie=l$43%Q-6BxSoi-FF|I zK&mCcUPEel+d|pHkQ=!7gu||s<&JW6YY0fK)CFXT)HCap8-vIRD;G)3aMNx|NWh27 zGS*E#U4pVg{cWS%NP_WXCT9m_rS{TSWtGxer3PhJU0)_ht4D^NOe$gud-8_Juv=`R zk-TTaNRuhrf0UrA;-9pQ;toxA5sIYZcxFL9U>5{})QN%!(47nCM8KytgRtAjnO z3)fa(T5F^Y!?o4YKBuL6Nx$A&Cv3(WXV8iC@ywXr(PO0shpfGJ-Ws;z>|ah9!mhj~ zHG>d*x$=h6Ii48J4DGhk?7(tsdor1^LiYLve%8VMe^XieXIu2Hy#CpB^H+CT$z&g0 zgwFX2*%XkCaz|~IY^I@^SdtX7y|O!WJHrZixR7@9V#lu1gkz~~vYa*4PS>rM}6 zOgZFryJbXBVs5u9hv^OOW5bjqtT+rvs%uVhe=4W{!$Enq91UQT@Vyf{wBtZ;cW+l@ zAg3w8ZN^L)bbBtEC@4dGq)~Q8aKZ0!Z$)HGP_%eWVFe%(o9-8 zo0(TTyK;%vmbRQYBRmx8a?Ky9^u_kbe)mTuJ(g4Vi4>+8>|^#DBkLA#%s7dM-3xa! ze|_suEGuTc>!%;ev}a1vYDK<>nrAlb804MI7P_%7k;*3R?o_Ja{@UO@u;rnEIM}Hl zK8tJgWczWJ^9;>fD0O`8rJt@$;nSzq!=z@A^id;|YfoD#+muIG-U&=EKI$!6PJGmq z*RlL`n8L@;BF(^WB)HX-Gfc$m6y{q;f0&Qo%G#%04c&uvCq72r`;}q+F({F?dkd*S zMwo}G>&#^GDq_}4GC97r{d|)1iH0i9`7UQMtJhb#J1!}WwOZgf@@4Lhd?9o6Yt6%Q z*2uRre{A61YGR*lxUsikWz;u;$RrwLQU5q@E28BBUM`4Mg{v-rndXD&)Y;vFe@oaA zi$#qKVDuNUJ8E9Q9>En@sR6F~-B=NKYXD47pw; z@8I_^s-|!gj4Ff0)4(S8U!#G|f54B>VPFRlQ`AxQybazQ_yK}b*iL2YA1(n?u1+EE zPk(qnA%o-HlQMVCkoHsU?JuHGFRXeJ$(e*&wbSzd&obEH1w6Ify*g(ptnvi}ABAp@%e_|Ia+q# z5M8a8@O%+}7TZ>jFMbSFe`m325?_l=;)U3%qiy5(>mt6<@b^W0D_m8?KNs=sas120 zp0E-1oyAv5;|E$}MZ6gG*Eiv_CbjopcU5;*Z#KjJmLh%>yCbmZqc}Vvi;GhABz7$~ z*DMR1InrruHrH53I+u!(s($>#s9A2VKJ)mKSlYaXD7*6PikSG&U_@=auI9Ph|+=Yxnm2hwFa#&tfRuM=)mKIbyTx% zz<>+usPF?g>cToIe|!uNxv-83JNzoFqrxA;$6Z)Qg+GC(Tv$hiKgmb0!a6GaEGAr7 zM};r4UsqU1g|D&)Rai%bzl-m?u#O79D2rTJN5X8Imn-a!3a=N-mD7z}xdpXTWZg92 zX9{H`8n|kpXpk93;2L+q3(2ccBz^l|r#P#?m$q}|d($fhf0><4YMHxW;D_Ry#`}P4 zETDB$jF$NXc_=XNej=Bm%0m)Xw8zq77?oJkC~*XLfz!$*l3DMP;n`_IM21}6J(MP; zYB&8Lu&?(lA}vLEFxFeTjftjBZ=3oibI#eRueV)+?-^eF zNJw6Fv?$h-h_-3i?9r=wLK0`jI48rGQ4^9;%?)Nqe~v|c%`FTfd@CeL?|z0@Ygh*V; zlGT8fe{QjTQ47Tp8ZIQqFG%|$meWmpT<~NW>~X<|%ivxYd~F%*b-~kRFzR#R*H6R# zbLfv&ox?%R=`X~NM9tQ)8EfJHX8zZT9Ag&>5!NI4lhtk2MD=5-T~td006g>;bLWfQBeF` z6-vT(6R}A}VuDiOp$5}Z5}%ss?sU6!c4srYiy=l5f08jlqlrJjA7#8P8l(8&%gp^c z=bn3KzJ34v6~H4bF}yoDY1Q9YJ?;)gj5C0szUfEiq5$Y&(2Iv{N(F8!gZeVn8sG;o=r=!^8F zRy0p~%&=5BuVm$XsDvxHTEHCUsqttTk;h>F7xt%SqPT|ZBcY2lsZ%{$t%;lDrRuDd zVXpGe=C^TYguGjViF_VRQ@F=4`JCRAc0UD!{t+W~%8e}(1r+IiK;AMY$O0C>VCL0F z%$CcauyBeSky?N175ft=1*-X-HH_5V_R6#8GS{{4=A4;59oWn5xH}n6@ z_s{1S09?UXz{hU)kiE$q^@f^4E~GByRO+^N#$spMCaK$kn0vB>fq1#XkmwcZP3Y?& z7bV(2Q|YRtMwgf&gVM|EJfHKef6xB?Wp6EmE&0Hy46<1|o2T-91VX_0vF*L4TVqMU zr1h1mJQkMJi$~|ktGhq>yjM#!&Do6}-izT-!08&ds4O;>y3g07yBLlI#PmwK?>I0z zzj$JB1ThRH*fN5XVSAI`0-m>C~e~K<8xL1vKSNJM|Jr(s8K`qiB3M+a@i+Uqk zw~F-!M*WBBwpd`QNnSxzM{X^8&UebAwfeud)dmat}_7}Tam%< zNrb~#*r&$gpo)bR4QDr;d3MEB4q_&`LrcVh>bg)^WvbL-y^Xywr8}%P?vE+`fBkB# zalP@{X}+~7p{PVQrqWB5eM)0kiS{;b>bg`7>Jr(RY!U1;omRcmKfd3g$3oHGRW;k0 zW{j_{4J+}uL^4xZuc~j3X*!62zh=#(QBhrE(uk|UL@cBaH0Hysa*~s#$y7qqnHEe9 zD-kq44F|1-PE3iJPM>hZ_%4XMf2d5RnKX-O&SX#$oyJrGvVJXS*bIuv5RIyODsm}trO5%fwq7a$~2!mxoE8tRWKFf%nrCh1Y*p}pk`~^ zNtLujrp2@rR_TFp9aNoTH4{#vS|+=CSpu%aR9@}RH|wgJ9ZsqzuS|8+e*jyVoP@MS zL9=nQqW8gw?_;upA^1;qt`HfSvf;tnGCEVHGw3WP*Tj8^mV+f-Gd*%lF8~oMz+OQs zOXwW(ftvzuCegVWZ!nhIHN(@I)t+@)BHG;&i)k^5&dVq@O;(}{m?Ytd;J`_1Ox>*o zBj<*qA^p5O-Hf@klUitO7n7x`!9kbgQOMY7N(y@f1%=6>Yv!ht zg2MjWsavM4g1vd!PTesO)s+KT8tO%Y$UbyyUrgJtbP4Mk{t*&XE;VCzGyEf}J1Hy< zXrhSebnQw+b&_VTfAvEhyBaQ4VwDbp6O}ad$22HV52|D*{$#TtLID~u|i73%-Kb!*y zap5@E3QC)0x{j_#7%Snhwtri6Z&ceK-DGGL$%v77K0r4Xe*@-&csTY-faE9Wlg0FL`Y$klnS(xsTz^?2r0Nd(G*+4&^cfg8J`mT{h*y}{L3bgC7-SN9 zDBS1|#ZgrMf17DlhpNXtis!=ZZ5|WelXi&#Pqhd!^?(xT53BVaZ*ycoY^{zA>{G%C zwFV(^H%{qRq)JqQJkQ`n%y`yjGJUqQoQe_A%aX24_>&f?F7Y*YGi+#~d; zaFl}+2w_{ILlD4FNL++!Hn(fFa9GVG*CeH6I!wb%?&`uYW&}JY)5A0(+8qA`uG1io z%k&UE!LVs+%!K=%k&g|JyUND+qEKJ zi!3G4e>af(LgG!=VnV8H1+}Kvp`LHjvn6zl{)eglq*9rGI&?(`{pU01=1`RATgZNB zY1`EUx>1x6@ZY8vOXvmq4w{yzv7Fo7r2WlFFVS}e+{^ISh%(Tn;-r|qDed6}OErkU zLf@C^IQ;;{!s~U=k5DX5Do9fg*iGjoiGC~sf8bPg{u7yAqn|=XuXpDqt1qr|(9iJ_ zQFQceaVPy!a8pOWLScv}y5ec~F0XDan5%z7zZKBGL*CV*InwSp#R70rQB8tbmcy~X zr#}dl{gG+ed&igPPsnI#c7>IjVvz8C)u+HqE*F2HzsmGydK1*D&yUxn^|`vC)@f zo}31^f?2^UTadjfQ5!@thdVWg;v)XKooqA892X%~r!l-23j`@MdKmmPWiZVimWySU zMfofVYUoHp(fCGnz{yV0s~_fRIN$5we;H62>NJv5Mo1^mq!$H$E<<(*gVLd^PM&2b znahzY!)mlw?=xbu)ycDY4s^wCoTNqDwRDAc@;o8uamEl+YS76jr6mfH7KrM8oEM6O z3Leir6~#^nr19#^6-cPOm}%*$;Yhp$vEv`%GH^1CKo{BTAAxaf12af z4)#uxPK29rqtFY6A9CQG5A(zCh;lxCvRl zI&aftXRVN`8GZ!?HHE`YUS}??e;@1T8bm+WVDxj0VR-3Drhu_Bu#7hfLjK&z){sHs zcF|uMR|`h3>q+cAi3sJ^(d@-_8JajqYYd69O3wtKy_$+C<1Vj)@&nnYse?EGZu#sHG zhYm*1InQL(gA(eAB*J{C5Tbif1!Bu0~} zNr%a8Fw6$n#jk056VOx`DXF<-UDG!I*4^v3G&TEMc5mxw*|K}XrnVL*N6|)dgf;V0 zB%}E}gHAD$@MRnmCW87)fAgt3H~|*|Sv9)P$@@(}%r5qr8qxMa!IzuPkQq&~d^oq~ z8{ff!SMk+?&NVr?K$u%MdLzD0=Ie!{x(jH?ul4qd*!h5{JUdKZ|Den_@=Z+d?`+!A z-rBz2)7l$Ff7k5^CgM7#UsJ0d;qM;d@1Ah_<_L3fv?tV?5U`=>e*`$6$HBLtABJR2 zU2!dpLEgBk;9HHUkw1+56V`g2{83sbSh!7G>l{EjgL{5lB>X1+1XD!;Dbm*kEynGD zs4Ey)W);$w#GjJ+4w22P3g)4F<(di^l=*hXsAD(fV=8#%k!E6TS_g`m_xUdVY%$O= z1kEde)QoX4tSQ|Rf8Sl$g$q%Y_+A7%X8HYEZ12`kMAbyrX4JXQ^B0QwKK`PS^CmU- zs#a63FUfolf4P)4GtCYwk*;o~GWY(c5{2Qmf`sxkzN*kI(d6eB+G@?HVpSo-jHT}F zPJWOdlBtNlGQ-Xf!ym>E4n_BAd(}q2AxfL7D?LgO%neBVe<-3kGQPlcJUg1xc);k> zy3KMDuQanof%-HVqL`xpZ=2?kQ{&c5++C}L!yO^fukly;kjw}9FjSIc8u|ZaviEpD zxQT~dM0^A@T6*6z-a~lA9jm8mNxW*j8W=eOMF8Wh9fK&Q6Q-2~vi0Ose{KSQoSzU) z(NU(kg@lgAe|0Ec;-_-&1;&~aq%_gyM!Jlq&+sv(OQs6jli1*7 z%%qQcR_3P}t)U!@sVK|{ zGoP(j`1|72A7n0n)1NGit1UkiCam)#aoy)hf5PxP`8DyGV=?~(4Nl%=SO()~s4Qbm zX4a0laLM+jtS#{J3;w0dKj&XLDQrIdM#!==_bbaJ*rwHfC-bZPdQOwLwGReF<@f`- zgo}3H*E59kw|HiZcc5F!Z zy(ZT-^b_1qC!nkpmvUy71H}YHjw$RSM92iH8%VskJGF@f7;>s(XLqYppJ|R;HkLC=dxlodYkweV%f0~y=(h1`vIV95uTdWqFY_V7{4i^Q( zT3mHnButb1ss(bjtkn|XZXTBd($(%}7#tlIA68QNW+1DpyUC8V9MiZkWzKLYU6{L*(ilp{77vU6Ptpw4vTF zHvLCv3)7<`v^6k7+XK7mcDpW0Q6Nbdce-{bsjD+dYKnS}ZWIu;DcXCKEI4vH1*wnb zQ6*K=`DC$76W^K1Hi}oMZ?!~=i6uIJe-wb`dlS(p$-os`c=P}P5Pg>E1~nlD%!vIp zc$USNCsRnsU5t0K4b|3VxR2uhWv;j@k)(kXU74h7%pd;oDLOhrPX&BMUbihp&m5uWn0$7({Rkm*4)XKvBJun^rYAw#Cnf2J zgM7bRa=mI8W#$OI23&#qv!T)BSpP+memzKTph+<0>uw3EeWhjmN#akxzf+5QBA>2%uy zu3NAMzB&)_MQ%Lo9Hb66{I$tvcin<@_@BY@CARB@3z;v9?LM0Awi|EOBzJ={Qo<0a zBis`x>fNMuGjYdWv#Lg^Or-Wvq&i1ISbz!eO8gzA^#y?boBk|*0Ye+1C%Nq|H)HUJh)#px)OkbP{M%uTU8e=loUqQk}pXueC0g1fh9 zzEBBo9i`1eAF?PH6A*YS(INnrVCh||F*h#YQIw-BMe~h{{fQp>G0)AAa* zEN@VmtnrH2y)2&z1eCxHe!1^pLw7y9m z+iA4Owty;Zb+p*lNR_s8Xo>AST57w9s%$z{+YV5T?Ha1JT~Bqk+rY0Z#%Qw|vI9KY zA;+;1emYRQ8?O3HV57)VDgLInHpS1uW0U;CMiFFzf415(ei1Qdcz6vQ{iQ*w0LsgO zwiJI4?>`K*9i!6vCv10Rp<7!nqESc27p|#}H$D29yY_N>sLBP`v z_ox{AT74Ef(nAbUB;XNZxJke?_ynf^P)i30JpQh#&XaI@W`7sQUc7DGje@PV4pBVkL02VE_?tVN<32)5jl{YbMl=pki37qz5&?B8pG3g zeA9feyWG7MLC-$4-M(G5-O!Ju;;{^anjiKA^|erf8$pKyZffp8xVL8H8~d=&l@V1t z@@hVhYBF(%#(&;Hw_7JxH-F~)Bc-TuC$u3eHJZDP-Fm}*%z!cETH#2$;!G;RuoY_0 z@igx#;oNH;MM66lfr+_No8S9sa(^1L48r7j z=p_ClYV^5OBQ36kG2A0!>GIdoinR77${MZrVp5*j3~-V2}3)hu&_w$Dpl)9Q2>SQPUi*1oe7qz z6RbP~Q#3VIq&@*qO9u!Fbw9b2EqqXaTMJ;6)s;Ty&f}k%43H244i6pPc}GA34!$6O z1d?D0F&#m1lKdeflbJXVAX=>teAVrf+Adn9)ppZuwW}5&RnfpHX+;hM4ojdr|cfRyA5uK<0is|MXZ``(j`uYVu zk={LKyl+8hL2v(p`USm-!J%j@l8z?g4TFh36ZJ_mW=2vb8V4e&hTZ|QcTXxam|76( zkHk`D{eq$04TF)PhN#S8u3Ub8PtOYUO|H(3@6W_ysMu>JQ``F_UR_BDuS1cQVp{7c-moB_l&aX0oX@o=QhzF@u~;<<~^^Mw()g`0l2y zJ=d7MX@lHM?pR{?ZZpX=r>(^JHr=>}$e#y8Zb#oKLy`2r+EmIM z?1{Rthf&b%{KS<#xkksUcH;Oy}f2C)gFmQv~Cv`&k&8L&15`(5^L&5$EH}K zcTW^s+A!&GOzyrQ8jq$gV!EpO47)t@zW($kYA$DTRM%YYr*fL^BZDfL*8Cq7H>k=- zGpW)~Q)!x)Ty&P7Jmi&cv;E|wGWj;wPkx#r-_B;52|tPU53i4=(#dE~MjUZlB9Ug= zQr)IcKbmNgW!AJ6e4!J6UE{mg)Rf%4bNY+tGH3w|Z0^UJDZT$I{IrM` z`)DC8nc#HRLIzbcRq0yj7i(%s#A5J8XqqypmdR@l4yA|NFdNhK>YBDfPpMnur+RAe zQ5`j+YYa7_0@kgluGukhi+(znmWdY2nWml6X3%*|m4&I-CX*3=y{19UK3XLQR~AjJ zk0zm6B021*^Q{equ)#T+@MRCjA&|u?9NZ?XmJ^> zr52`Hry6KoCK~IL!{2~qF4gA|>?=CZ;8G7=dWr_QGt{ov>Z6Uc8D}DA7>BYArMjM0 zWv>gnwNblF(ZSSzx-{6AFjK*JA|32a#M6;zJQ#@&Lm$gBQ8U#T+^~NL1+y=hP6Yd- z@xGwBKhm3y4F{Ly`wcgG=rVvvI$^EFG^6@-4Y2e!>XfCoGcEly-O(W2x`lt*y|roH z?W1lAGdWYy>rCyet$x};JLN2{WZJkix62eZlL!v}rF-3fg}HZ|X*s3Vn$tbSPZ8>o zHG9!77VYtnDc%LeYD(;S9d0m=KQhz~#X8fGBv540GJ^(u6ctacnYj6QWKf_aEr3+w zO6-x9VoWQFj#k!*BxO(eLk^bkJZvmpz!)1BTkMd=w6cVo{pn-Dt?1mewqg0oW)Dde z(p^$!ZzdUkO%FG<0}TO%>!Z6(xkIpM8SJXhKi-QITsFCDm!1PQ*h~9-l%f5&A?bu5 zIQEqD#yC{f=q&Z zXGUP9jn$=A+R--I^ttVzrDz_gb_?C=qnqhKZt;nK9SpjiN$pr?(4BC1)Bp!u)jQUS z!@rO2_R$>#8sp9k^+nRKqk*Q@R=Fe}pbv_5K9rkQTXy?EGO;hxBVhj#%+ni7q)b13 zRHED+bPwj~)5gV0fZ=t1`Z)cyOmax>GCZ`RFjFgHpbl-Hz)_|Xo6P+=L^$QDkw*=q)SOfw!#M+R}pfhhZbqUnLg#qpLzyg!;845kMnX?&Q$rNx6g z2O_vBoteRT%S2Xn9x!v%Hxyid zx;V9TaeQrW8V6ET9kd%RkEWvO;6OS(lnOQ3SlB4AXjAC|iEa|avd~DYo^O!TN%Z5a zqFABWd_eHle#=@t;v~UL3g(Ffd$CDux43J4FlCzhP=e8PO02y%3a0`FJ_gt6Pb3E; z@b~je;LnZ{)S#4Of02%eCq4o0TWH;XmWijMgCf#_zeBMc>g~D3`(>c z^c6onO~-v8pCUKyw0?p9Mf!hZ!a_w|4Ek4Gb>w61iKMw9W)5OjAj7wWlf6j) z24ISt`_p#AsZ=E&!vCGVBi+A?=yBS1HbBi`gT4=qfhZk_$bR~PAjwPgL-4Ula{|Gf z?a7E_dl8G?2vq(Ej5`(S*N7y4b1^OXvltze{0Y6{qaV|M$jTtE?!i5vDcI|OT9EfD z5Hx4*b(#Kt0ivy$bWs%f1uTUSR$R$+>0chhiYJ18uhTCjh`k{PTDp(r0Kf9lYxHY` zQyu+_RB5ViD{cKre4t9E3v5Tp> zBQY`FbZu)3rhvi-Ucg~QiyXxF7O&!ABNwQ(EiFG!8SKSsNkmW3+m1xMBNGF8TwNml zIkQLRt__#5-^V_llFK50?JXEQ4fz31wrMNCH4j*wf3emJt;lh&q1LiH9Q|n zHhOrW@WO9#T+B2WIk6#Eyek=HI=gz}>DHWodf2sG=i?f#M^H#7 z)^@hEwszW@HCo1KLSE6nex-+(!I!(XZ){lY;T4vB*A1ryKWCOQ3#c2f2@;ST( z*1T-n*3BDQwtKi8(;<3``xO#;-fH!{>=f-Rm)d42(urvRDWb8TyR5p)tvajUyREu# zIeiBn8;cC~^hFjG;9=3ZOuc6eh7%9=FNG36@8l~Hg!!s}X)Z?USzdighjNZEEhtz# z8sD4PV>Y#Eh1<<^q(7p~VlWcYvcWf5p@i6G!xh;td z7_EM)(JmMq(!&Z56G5=npgcQT34wGB9W`a%WFG5>IT84qDGB zoBezpU+?2#evgafpK0qZaS*@wOfGST#kS$}5`)%%3#aCLd~`h9#ihBA_21FS(N z6#$-WoU~)m2l#_NKEOzlE0E3gm@SeJzbg|p)3M@3L74+fp6U-ZbNtKYYpcPROM8CjPJL&sTi4>=tA4Oio z$i;j*?U1Z9=BMZAp9Bbx6hDe=Em9;4(-#!cBe9V&m|QmL;V*%RPWl6Z!B5J2T>^3T zX=?THQ+yovU>lA@vRx1Vz2$!Pdy}8PER6pke+AVrYp=P{O!p3mSDpz(Ee`!e{A6yk;2(GCV;g+ z#TAIAEHt+AD1OyXL7Fd~_Y3g>Z4aR+etsQ1gSz>b7;Gki(p7mKFT-~7SqZ;?$-nXf zJtf5vEiy?n??%4`%fJ-BEAr{0u?GJa9A!uah>_${nd^4-YKCoR(Qw#E`W zn`RK=aYd+Ctm!`dXdOIdYkz-x%C8(&lhe;9_*v{yxnWNPRD`ocvgq|xdqAsQzs9dT z%3DTjl@EOH^maq}p#rd=1If03Urj~!t)?hs0nWItr~u}nVmh|HULG+l=gfsNkDB49 zA-Yzynd#>*>U*!w%0-I_>0ti)tJ$Zd&Ly5_3W}#>g zDMQs*On=vEn4s;n{&qhoRHy2_s#Y~HEid3uafUqkV2!(~$)~E-xssvfhg+)J?aO^? ziCUpAT%@lp8aMrFC4dl>&G?WEI#591et{h;AV}o#sPkdrM5>jHq|cUJAe;8(YTG7 z9(7rcAn8%qsRZ2IPk?G0HY$V$R4X2ZZaVEy+x45Y>XMiEfx$$7pGS2AURJGK=}}?1 zjU}GytfSfCQ=Mw3@C^ACNk99wowhjkdX?HGJ+Geda&I>qsz*!!y09^k^s7GF=~KN5 zmYh{R>5GfnEqw+i{N%b(zo1GaP}lg>9{HKTDG$aTHOO?q|LR$F;(YS5suGw|fk~Y` zF=i%{nW41VSA+ZP;!g~k zyQ`Z|`CWI?m*yhgr)~xBQwQK9LfhhrAoxYJFDN8PW^MF;sM~Q0Er~uH=M+Fp@1FL^ z&~{OuX_{Q+j?7?>nY3%l3qMTi^eaUb#^5R>C{51tF{WL|(sWc7JOBjz5 zt2cA;)lXmKXm(aP=RHXCYO9>f!y#9e z)*K>VeE?rB?mpn;k@26@Z>c>>K#4=N0fNcgbg#y0#pn?l9Mm z(iM-Q?^Uqa;nO=`U81w&DD^3Nh8Ci7VL04gJH3B@ly*b$LsVU_b(vp(jH06KH7E~; z+fUH6`lp?rByYWA`3O1r+0z$FoW78Jt7Z8nv_;*8&j|U)aWo45)ew!tX3C(2cA-oP zD|O)=nqtr@=D;`%>yX|kN*|U!YjvNx`f*xY7=H=AyhV%E`>;$cjvH)(wqT_g#_z+i z9>Pq2pTUA(z-+IA*9w=AzrEs>@NFOCchK&^_*d(nCFf&x(^F$K+>xal98b}E$LO|@ z)6wjzD5obqA$OHa9j0Kw8E|Lm{o!WU1LJQt1l*1)oZ*K@#$Und9}GB8kSE~G(#IOI zbZ@vrOcrvE(tS@iyBy8#D$Ma9H8xbaS5(k{_>E)qsc^{1(x+kPvVgPv+$@cBV}3&n zd%z8+_(r`<))j_EpF1S_g|qYsZ2oAQ@e~8$H?)pxoZI;TG#aE?(H2Qn7M zc^o}1`jl&ZM#xjUD@#w+9*1G(z>n8r)78|Zfku6?mDF_(Ocvlrx_FGxXVBah(zP0E|24O7KUNAjWYLC zau$uyzS1P!4%!Q%?ANR652tp4sZhWT7kB_J@O^yu$}dFrJs-fXMcWH%wYRT;_;R@I z1l86(eWBx`d2=76VBNgsA*WoA+5k>}Z1)&FdoQ^h4YVU(eDv<(_|A?)hKg%l8=9@+I+ZxxyMz4iCq|WxwH%I9=S^BS#v%)DZ>VorsxU_S+ z@1toA0hgl+zIt+G{O1i}?FCtS8-GT|Z`#rzGVc?(^Y1(+A4P6bj+-)t*6Hoe&~g^s zM&trotYtMdYk59hq~%83K9TLTQ_G#Si$1I&pkK=YN@$s(yS0>0>Gu)X;t=E^`lK#@ zh90JZu@4J?z~TnIsQux2Is6rWIA4PaE~^0O~Y0@tdf7SIDRf zxC4evS0}R_;qCbe#s6e~M!2?2X&mA1aLALJvP`Dz7@^95CpY5_GGj+=z-TC%?}~}@ z*74Trcf2;R@i-Dgnbj^1#Ch4F*J%OGc}fR<;grCQdhWm;}l+m#8~rFQ84SE{SEG*v>& zYgJmyz3NUa?^X|qw4jBbq^fbcP=b@oU}bQ*!RN>)A*BU>O!!XH3?9e*Ez!XaN^$Z` zXwwZII%+qUPui>j>Jsvv-i$U6`|Sy)6;F_hhE@-0zD@HgJ=a|Hz^f|_ zzQy1JPJ?gHFD-!yb@FkG4T0`L_uH-Rx7gi18ZPxDdB~OZa&=Ug`5c+KsM_u?ygXl> z)2~j#{K0&Gwbfu?Zo7O3_&= zLID+}+3FFRqu#^{74qg|^NKXqko;kLUHuq8@mRQjqc(hm=H#H_XdWsm`4~RybA?6L zP08{zcJBuC{s-yJ|CnzAcfriaGC-(VheCv%jRr~;@)~Aopz5(Z_yS)#?>>Plm*b); zr~c-t;NLbo=hb3`kn4UbtK@%@V1Wc=-WdOL$Sog+OVh}Z#}ROMf_QdT@(TeEC?wb} zsDx&J{0Iz{{0-}lcU!7=TMB|2-L}fbA*Zw1s50g~L|?9}4QsRold;jZt{80pwE(uD zvG&$AG<{2~GsfR5vdg?@xJKwN|3IU0d7X^GwnEMkVar(HN8OKMS=nH~Ij_o)4SD3l z3oC|vZrR$-P?;m(!PdN;u7D5Q`$?b-+rt8X57M-muARWz8T?9gmFs|YT?J4aOS@eN z9^7SvLvVLXu;A_*+&vI%!v=ywkSq?tT^AM$7J>(YYXZUH0s%I-1bG|ot(%`)^|oqi zYG=-z{=S-?>7K6cbC5E8Ej#^z&!nEh3vNTxpFBNtxZn-Y9qQj=;(T6gP!D<^R8Zt2 zf@c&Jr*iuV2a!4vp;oCS4tt-{y3XUWuH_Q-2|~4CD$dASv6ayG0S@)e`ZrIo90}R9 z7t%?M-@hf)xk?mL60#llW{Ku73<5(NB>MTOd>^FOu8jmBmToe|@7l{NF!xZ@rhtPm z(#iP}75dK`pb2k;pW>?H)87^#&8}T#0?LDOHl7Ryqh=|bzfYJScWKU)^a-de85EI& zeX8qLNOBU2V=;g=Rp$+s9@-^q)xaFHv>^DgYA5g1Be36V+-j%wmU|4aQ-FP7qvNLf zdiooI2_u-~2+yPPYvb;^*TT!QMm`kk#~ZK7PzFTG2h%FKH@ezJA@0dC?X{lnReMoL z@M>i}J>cTX`wgMvW_z+!86b!hwdJ4)45f)$Wz0E_p38@B=V~Dgn)IA&vO;3V+n= zO4!6sd2Q%RIB&y~ifVBGORYnJNEZ?5Pi+MvmbFyso=KNt3phSHP2oa8vI~GDRXtf+={WHw`s>@`ah)%W0_27i zwj)zUX&;kZ03~ZSX17g@J5--c9_j_?3@we#(+d_x9(!mG$%DYzDc?b;w}2}b^pg57 zKZ7`H{xYd=Be~L9w+zx8(l}Z(w!1*(e7@{{sDYnszV80x5d8DUmDpg-5xl4pxAaWh zRHB@zBIz>svW+Vy>5y#7x`jh~P+_X{_dl(Z!rwd3llxvwies>yXJEG8hWBFCHgdif*hepBhyBf^Y64!EH?6-e*~>p3i& zAJE1BJzPRRY2RW@Ii;z_>-tpCVC+-ho@##qtAYmC;tHsF~?% zPuE;IXi-L|`AzX*`|^i~PcLYSv8L50^~m(f*&V|NHIuFRhrL}2jN-V&hLEy~2;-MmiVR`M0=U_!82$unFx! zHE3=#TVq%q#+0s;Ds~j6QhN_&EGW}0!q(D#7u7nJ@dgJRMp<=dN=1M2=0MdX)PY_6 z>+r7lU;d-%S?TT{J3y$=IFfMT{(gZd##XpE$o9GnJ z!kGMM9X#XF!Sb&(w$P<*(0BT{nviP?1D=giW>6@mSN#Vvnn@zHq zIOi*KEk{YIwkD@!BxVX1S?B`bShVdWyu*dPP+Pw_JXfD8gWP12V^(ZF{H%)5s*}Fc zs?*@XgP4ObAi~W+uwH_Oii|XI@2E{#S^BB{biQB3&3V9YUjaD+&X9Z-2T{2HbjeXx ze=H>d=Bq+d!uqWT$9ee`6Ea*2AI`qOfoT^P*OzD>kv_p^D?h6*ch32?G@JIPvT|3F zvYskKZSVm6q&B>OkZ4;KM73kVQ=aD9YME219p7XN&n8#KVu~4o4 zZ;yfP_u2wgiZNQwB(<6k3}uRH$;2tt-vxjE;=PHTYHoVD91q3YE2xn|O>WqcUAkOc z8e1AmE&innanB|Jn|AtQp*slQCk zG(8dtwjEa4wymxS4C;-sjn3FUu=&DT#Fhc!v%!_}zp={Wk?z=^3IdG_tMR#b%obGI zq*3MobJAk3$&sx!Uo^sZu8J+a!khO}CYTIN1pNahr-2dR*6sOfHDOB+jms}iq`}o8 zxZ(SPuCgkb8G6dxq%+N2#NK?zDa`|}fguKziP-jQUpwcalYGNPYCHN?a!k2fcYTq=jW84S=<36cp-xc%)Ie=wB!10_P5Y**-FHTR#@z%mSq3s@p6$dlDh8m(m5 z6;J!N$bW#u`+KFS2jnCQd8+B71Y`{!7A{M!j^QtWr$hJnvYevc&%<;=FikPmB$z1z zaM!S;EHo9X9)eWlJP6DzRq12}3~gxl&tO815nkqfB-;vKB0|-s$o02#-M=e$(5z%& zq0UltEwb0XjqoUtPx0|Z6;}Eyd(3pDiacKxIhtmKl?gEwh=c|)QD|1;B9BdYDARCyG(IItpf_F zlBW|m-BSd?xBSy)Ai9Plrf$ro{706=III~KcKHVn7+&~=cJxV#$}H`wXgK+M+Nt~% zCy^b}C55kR#?^XiX9g*}<;Su3+!U&{q88kH4aRh~x@d7vtMK}c-_*@t?c6#(%-59f zm4y)}hA?b2E-Y&$?E3Z*^xXoeMuNt6+`jGvE6tk%|6H0teY6PPKmH;X)yz*qq;Fr#AVmepKgs;4(0 zGWhCU>1vOLW_I4~HW%9A#V)S-Qm=up&Q zMe12Nbv55QS$W!idfDpC+P56mfPGwp+O$z`-NauXP8F=6*0GiT$mQow8%ldkJ^T(D zFb#ZV?bFm&mJMIab)vI?8D9&%75$aBd(IYXOoCq(#P`s*$mIl@6?kLOr!IM(g`A=L z5iiU`Co?)j1+;1SiB7|f$wt2XQD zMQ6`89v*S5Dl0o9RDXLXB_3KRMQ>(&Es9^NDO^->c_H!=E}xL;ueG-TLIvPmkVV(-<;lapaL+4dQXs-fNk z$)U=%rVAVu&(|!lql)*G%-Z-=KPA9ypZ`!<6bySwf8obbcuLn}HorshN&~H;7vEO# z(GljXs@q9Jz+Z-2ZrBg8m1jLn9la$!#?cu)EazBps_MIW0p7_Ng^#t9^EKS6s|^}m zANja{5Vsf@Q5ij@}>GPd8(Y zCkl5JoKiyMRy1LwQhQ@VQ-SRd2uaP!Q&!ygZ+6syO(ZOqht(lA1G=a~bb-0FVfCZbtOLt+qcg;n zq2IzoTm}xY$ISvv3t1z_@sa_uz?h!2ClXI>!&UfIM?Cz)1z4YE5G@aKjEp2)%of5F z$A$d)yC(WpEL*?d1Usiw=`0K`q_1X=?*H1g6idyZXCQlTCCS&jCh&`2enlL=S$?bS z^}t;ZF^>1(EdioENBY}6_OehI?Cb(GAh~gSISX4t~*f6GWeF7-|*!*pj}q?WgJUnnRyq@3=C!Z`r6-((mx;^Ez*$e8XMH(x{0k zn{z8&&zH0t_ddm{ujgto$W)luLVE5zuSGIc%ZGxBV|oD&(1EgrPJmqg?r`&=44YY;GH@b1uRe$2T75f|jGnNt@Cpd^WL_l`p~8 zL6>5OT7?EehrQ+dR>6_)@vJgCIYqnEeV?$7Nx&zD@hSOZK74l-F4xtUX<~$$Ss1)! z5_XDP>KPotm+eql)>o2kdYiwtF*7*RSX8T1OBHA{1~(ST$zSoNRyt;FHnC9STy(34 zYVi8@T&SYD-qc8s8bqKtml;;%~->wu^e+krTq==gsPwyO{OY-<-@9(>bTW3)1(%+ zak}pmIIE%2M2lBs%|CW7)=N980t4%`F{})Gq39Ez4+vmMHsZVzH55o;l&qyZ61t68 z8y_f6G_^H7CuG5IJ>M*RYqpKY$w2Kc7m+4Lk;{oj=7?h9+%{MYC!VYKF&;IDePiUm z|4J}^LYgU*PaTFvX8$WXE|Gr@<1tDI(|0bI#rw>4TqB7P1=+M7YYwkVeg*ITX80wt zm~7WBy8TZkrE?|6%9q9B=XH$7$xNZAMx^c-jYTEoys2a0woT^3t!)YrXK2ZR585Z8 zQlUXUP}%RN#~w&}$~&go42 zXX+ld=(KviFV*%Zv)Ma6V-8}BKkBDzwVFS8ao$jwe^FR2&z%3s))XCU|b@QCwMYzG|wa7HJYkXgR8HK|O!}pYFpEAyg zXVmgMvD7B#j-y3@ncbVZ@lR&9G#W;8{V8i5U8Y-#YuB)D=BcM!Lcx@h*OPPeYdPJL zWx5))V4*jQj-Y*p%FOOQU26-<9QR|q(bu`%lEDsaf&v1>?%1j;k*Ath4}hs?tKB*x z)i_!pKWUKRFZ$kkcXL%|2I3bFVWPu@Y0vSGj1yPOjAB)WIjB|G*6l(B4%~IQ3XGjRw}CJ zTJo4!%FgQTrMO!6V!(0nK?)L;Tz1xQa_<-YT-p|RAz3MhGHgXOa?rT66KCRCqS!n- zCRq&)&YyMx)Q{3*OJ6jdYMI|?FfAPaT7iILq#@6 zz}($|$3hqzY%ki?rJt@y7C|8_Z77~3aEq^P+AJco70aM*J(mPZ1Eo1T(WA)CvQ9h*{t7xeq##h;&&j}&NK%!%0 zonyVeEkWk}%(0)O`YW#5fn?4M`J9zxj95q2&N$sUl#ufB-&}!q(RqM{$+XYfc~HHD?%-mt1=X$2tsV!nD!oJv zo-2AwhL4|WBZNP8oSVvf{RI;&P}WY6IZDwawUJjDTq7Mm4G(WneS9t~PcbcqB|&fl z;#xSnYf(+nnvJ;80f2UF2yUej7D@(2VL3u=1O*A=St$)v`UTk~Mtsj1AU0kA!joR@f7z^|Ou#!wbp%^9f{pJUTM+vlu!^1XF8ezxYy_wo5%`mP zKzq(RAWy9@@Lz{m#64iX2a@MOfaoDbwKP=!YoB-@R0Ub8m3j2X0T2M-xJPm#hOh+` z7y2C!)gRld|7Ms8$*I!-{`OVMK_teGh*|m`3xNzIw~h*7ki~mP^v71?zm^A!s6j+`gJ78`DKk$EreC|I>!4Cg`&>&ciyePjHM?s!6k|K{g{?GA;kXMZ~sDCdi z%4;M+?gAlBl0rrr9|M0cg)v6lkrBjpzxeG}2#_N1&%i1GKzlE~5-}kLhdPZ_vB!yQdNOr$_(a p*u9JI$nHJ~Ie>rOQvd+VJ-+SIe{jsW3;+heG7bRX3;q3``ajG)JAD8E delta 38903 zcmXuKV`H6d*R>tnwrw_UY}>YNtVSznY+H?!#@w*yotq<~T0j zArkK)>g`bg%+u2dp=@YSyZ&U?3An4ErwsiHHK)rmhjM0FgZU9&x{RM~5n#0j8W-qQ=xY8|rIy|XM=9&Sa>e*EIH+7i~ z>Chq(j`Sk9LyW`93Wt8$C^l#mpFE(}pTb&!8SK1aI|*IVuWi~?cB<+*+(kJtfFKd7 zwE5>I^az=bMQXZF6*9+(_4imc)|7r30%Ao@`*3y6%bWG`R7ve^LAj~(XMT{G%L~~u z1;;??T$fvE%gd}Yr;V{)`6MF@tn}~RlcI<9QruB*p_2hGFk(2orm_c?i+-FrKRo94 zFd?%yPyw_}{=<&R@gwaVm4nDVD&c?cLLA|fg$WJ@rVakTcQIxoNnS(41?Vedieh~N z(^wqSEv44LP zc*pFCw8l-uFtVEAyU)EAxSxK1zkd|?fzQO&wXxx^#@2U1emjdGaq$c>(ScYPh&;U`)$AH4TQC)z#M1Df~SR26aOuU(b~*98l_soZO1 zO)EA+0*`nNoKsGA4{gZTy2-9~obC8Ro_7L9=1!WfiV&pCLI4OiUR|P0ZAh%O$WW5! zG86QhuH5TkPOs>K8Y6P<)5p#1ei;0<*VN@6O#BV+GmP=A?NzwfE7BD>@rOJb>6)EG zbdDHEnql-Avp0Y{Q>5?+1wUF$ZifEt3HLua zQZIwoxG#(IH` zDJNAks@t!ZAMkQTQNn$smR*-PHJ}n#+RS$U<4yP3&G!5Il~z(PlA^ww+mjQ?zul_G z%RLD#4R$)n#Wp%UD{clP{5?#*Js3`D5%JfmMr&;-U(X*wxZzk+GfiqxDX#9%u{0 zF0%nrLr!031yQ2E!f}z)o)T79j zsZcJ$U$F!XhpvnQFwUC<#JnoAd&Kd@-+BJiR%|u>mn;MrSa$LrC2_J4jsid(QyVLY zg^X~y07ImzhTBOUQZ~&79d3xH54Uo!v~(cM71+LJkd!*@WaUli#qU`rT)guJx!AyF zZnfw<^HRL{yK@dgbeT1G>gp)(Eyq3I|H^$P@cHwQ5iI={anM*f?>*~HoZi(@J;Tyc zZth!3Op$JLhm(tTt3tC?Dl}lt(G>0qF00y2dZ|)u!-IfZI&*y&NXa4BU9{|DxYUi8 zY{{O;7>}_$6L_~{g9n380>4pMVKEjP6B`pp50L7^`(s&UYQNv&1P64TthHeL#!5(0 zh41IJT)EVy%W_htv)@8_F%+{}?;y4j*SSg8d_?WRt`PRhvfS;c=nQb}wAA%#XQcAr z^yt*wk~OeUK`=GF3+pM$?(mP@n^EBn5FX%3_uji3WK@*^Ere1=N|7H_Aitq9Bna>8hGwT;G}@nAQSUh6qm(Yn4b>8X@)qkJ5VR7}%w>Xz^Rbz z$Iwvqr3UDM2Wh=&oPc|Ow6H>9xJ?&NN`cYbGr_v!V%}Izp%<7Pj)!JNdEQBoE;`F# z_?85&JZkunTGqOPYL2+U$TKg1Zk7xcBZy3EldZbu?WoMwF*A6})Wlx(Jra@RH`Snp zjoI_dwdxVupz5XYJNTW0wmO!rg`bQlLn!Y_m>6XkqD`wu2;hEtLMHf(_QP_=wsXD$ zexdtO2eSt2bI8R`xIYu!Zihw@9X842(m3+iLGY09hy2% z;yvm_wEp%t2B1yPB7KEDmkJ#QtR}*?eaU`%w%C*mF0@a(`qvCmyFeAGVIPF7zC2>A zh0Tf#)_|DrXNeeFCKnRcbDcCyLfyF7KHUqZHkpryH`$ zq)H%6AHi~7!-tx(tP@ri9}-&Qf@kQSNe6M?PzhJSCv$CCzyfF|k~@)M-iE3>Z@s^? zTrmbG8;|k3%8EIrK*st=^U`Xp<5aEF!3@fg5s&N2DDI*{mf%tTYfkzJvGgcx%-c`- zMWWkaCc~uO{xIhozEL=rn6{CzbHN7X`Ha8*7b3^f|7D+s3$~&qBp4V777R>99uf)* z3@+Kf3^VzYk~*26fEJ+ZjkQFuMSH3ut4@&%h5=2l&nAl+6)gf68WtISN@bI!ZC$$TAn*YEuS`MQhh!W2m@r@YS z_oP<+IKVppJMB3h z2Pu?>rtK1qLD8ed*>dhp3Sww&9{vtT z{am@zI7TF|v$CrzE1y=+C@0pdv;92g>D(4^q~;pO2!I8bnI;~F7AF_;Zd%ynfX>e) z4lRoLAN&R^#U#jPO-fRDlfx%+_#7a$>-_{D)qPP| z4QL}Aq+q-Jc{q&|$NEU2Z7TR6Da-@RZ44s8!1C3eJe(-n+bmAN0C(0ovGZOQOMrG4KjN)jn4{{a`fx zVITd1e{Xp6&{FbCnktOaZ@8!h0xu$`(r=l7=?D0N(w|ve?7dGRBxDEBU33>lfFc(Q z$EpOR9m!vn+iQaW8Rdsr4H+pz=h)#^h`4oGKckxMEomg=1+6ogq z!}>k~G3q`MIXB_17CBF-@0_xEt?lAPC@JZm3~2~2gxq}D$JOxBC!Iw*y>ZWD>-qCX z)w8e5UufqZq+xz1l<|9N=$oHn0H?4G*d_E^dT;n8TdOYRY5(g;m4)<^h2L>f9{3A% z6t!m8vbYV;CHAZS^UasKYAzs&udlLJ!=}!-ylk`6y*>PDzKG`2ZG5iLGFQm}f^*D; z)r6c8&FMm0gAY;;3!nVY^f^w6Ubt^&>R_H(aEqXY80?0^$QOX?rT;21fYtPzUc&5@ z0z3`Gtub&FKVzz#RlWS2H@N-aav^h|$k|pJwp}>VgMpGT-jBcex0j)+uu-#PAX=%q zAC{y%ixhq9&#nE#DtsLG9}{L-G>Y1Y2F~4eW%nuf&X98gzxJXqe`V3{5yhvgNF1cY zP+RSkHp%;Ybp?gIqD9c`fH;vsmml59M^Iks)Gb*2KUFT@++~u;2-gm(@XN+I^dt-p zzpE({Lnnm{UR6sFWSaYGGbv&`CW+DO;Zi(K|G+IQe-joLr=XXJF2yWKt}`)aN{pi= zaJR%}HXeo)kA2oWk-gE5vZ-MmKySOHq*f?Z|NMn|#jtPcS1mwL1Ta9c;m#8bwX~ko z!mb;|iLOL_&@kRN7o4rg$vWWj%iQ}~e1;dKcl&d3vGV3aFs+h~;23y&IsQUye!F$MB!FV?0paH4Zbd?tpc-F9phBAh|Z}Xo}rH5Ks zr)B$eDuGI>_4&p1Ju*vwzbzfxHVTSA{zcY2ZpC`Prt<_yVCqL`IzSq>wEDL-ldP;8 z|7&-!aC72h_8=HAFl&^5dyQza({Cz3Zt}JAf(YJdZ)-ol9PI`cHlPr@>1{aXhXNY` z`Kw)sD-7ke)1{-v`uV&U)ridg*8!re2Tjin>PJc*xns&V?h23w-I(o| znFCUBhRImxd!RnrX$0Z;H`>@Efu6~8l=Lk>#s_*-#!_aOVIPbXJNHzkndB>DiGC__ z7Sz)}5Xk8&8m{Q+_-=WEX1$&X;p9K+VpKwhkLSO{&8@>1h4P`U+5n$2FN^Y zHT(Qdc5NY5s!5(eSLIrNx0j8oQOidU(|t!Z_9sJfab#~c!r?CY2=wcqiU1mBU?O)1 zHWKFCfZvWI;up8G00cC@SNIi8F}^1Fw3KbFS%bAqV=TNc0X#SjhgsGi?Y&I2Ha+Q4SjoDLlzrlpcVMrc#{=nn=XB0_H6hkqL?t z3@(~xG#jc%)b`wVW=PJ3qZur6DU6*_Qac?C#Q-qU37;}(OL_j}&BL7Q0PF7FulE;d z69jprd{oqx1=1yo6|eA`D)&k)wS!8nhDzYLVX+x)bu^0%OE`TSrA+&DZdeO*lVQ0P z--bY3Q7Tb8I-tAo?}SZ(@~n}wKMTD!`~CKJ^lPxhO^6#}#)(rwvD zIuk!0fAbd_47Y!H0m=$1Xo}OLZ3D>3?+W>vB>+I}HdzIBj@|Ij6K!t>J{Y2bd%VuY z=_A#kUz139gCPuvzU)48bt7%z2-6`$XA6V`HF+yy)cg<|Ns z6-8N0MQm7jV;7l3lHqv&I#)s)#Dm*Kxl%i1@TxV-PwwrC>arcc-4Jaiuuba;K?mxa z?Eq2*DJQw*S8K(ixD8ee1{Ewz{=n)Ex*mtB+>XUZ;8~t@gBR?wV<()yrcPAjc!wRS zS%yTC5%gik7{_1e(ID*qvGQd8vf^${o$NAP)0_Nr&v?!UEJWOsRmFKrNk2!hRgaV{ zd6zNSSKA{*r}G)vWkmE|`-5V+Mi^xj*$7~v3ZYkkWs5pSiYgnPNg1;*HjNA68eDBo z?DE|W$v`y0SuAX^^aCN0E2mcJN|N7q*&z7&U2+ABAP#J!L2Q{iE6C0xNIe-g^%QKd zt;;}!jUa}vvWu0UM3EL>6Lzu21R>>slcH%NUdmNl-#|V0fD%m;mr>Tcg=5~J>?D5t z9y$${9z~Qs5g5?9fNGeg4C^^KJ%YFT1~6-3Q>s*F3iuf15@By$SW|8>90ExXolMz2B?IzanhEiwMMbMOX-0t4fN_=*Wf_d|RJ{Px#uf8#dE*$xcl=KXV{Tj9|M9lcyh99D6B_@gDQ}khY44-A zc-9@TP4UpZkMZ%J%lgfekCb}^XDyY_iyf8zan5UkO@i9)g5qc_;mkf{bgRd~WzXS3 zSV%b{TUV|EXr4fjv5oCe9=``Y|*WNA$je`jfQp{t%Htkw3k%b1C)7g@hu!}7KH6axj^fe^4 zwi2?~aAq(m1icR1r z6Sb{OJn9(4&C+3J6Ml{Wo*Z^P!UM)T=X`!&CKzp~U&TL-C7aOu#j)SX$ll#0#!-f$ zONU>;TuwwTK)5w49TT<3a{f8DrVFBpEL8d?`^6jhH*&H>Mmj{WKvO`nM}=U21hzV0 z*slDkOoczfFM1!%JE9^$!WLrXGfX(v#>V1zWX*>JJ3q5zmNF_4R^1C7^C#ke3R$rL zPs95M&vgHf%Vsc&0hA&BrE6sX9L0<0L*wlam{mv&^cdLKyhs9ek}gm0h-Q$ZtT#Ek z6ea+J7W01>6<4oV1o$XL{srH_`}NGfF9H6rKLS_HZtmU#wgQ@nu258bwI<#CwT9h1 z*|3tN#j5NIgs3(PBuFR>x9{uEwu=Seq(yC-{S1IQ4M0E;_wHb2BsYqXppBpBe%6t4 zkTVrExH%X3FAPvMx7{P~G3DQFWo3!-V81lg;i!%9rU47CZ6r6J)R9x9!W0n`%%lEiB{I!?;WZz^GNg3Vo@g<{bs(;UeJy8IiW~@B1vvwVd=Ld z(g)jQVDtfpGx<_H9AbYjw*+9IYpi^{-F@6S1isR4n0Ym2vPF%m5Nm?+_%okNzrwAl z<@FR*XHrH@PE%+G3h*Fti~B^~2Z^H=N3fb{jp$8WTgRMKniTVfPqrEV(0Z8E%YMu4 zi<#Fd0K3J~D$IDnsHU!dG>#I(SjeM#ntbaj({x2>mZ>i(K$tKL+0IUgJrb9*O zVa#S`$N0!Fh!H5+m?&gCRivRIhwHS1U8AgSM!OiQPf}qMHMZOTs;6a+00P%)33>WP zW_IRrrnR;6*ZapKVW0UFM}UA}fZ^Yw<$oZ{K-^I6k+`|yEKj}FfkY=y-%U_l#Q;Iy z#aP8H{f#GjRIxmrG~}<1}VG93fk~vOhp(uC10P%~{trdktpouKjsjx7V7^Yd_Mh zN56_2t`_tg5h`?RpGE9#@RiAg8}al)gG)IOjGX)xmi!Er0*8YXxiD{r15S0Aq_%0h^zAaA-10R|m>47r18UlJn z%vnN|^i|wBhTJiF7y4PdWX&avP~yfB!JmbF!bC9uL2JNu>gY~8*L=eLSBcY45xR^a ziD>@qxB=X)JWo8pL@J@8XSNvzgJV<+#a~_feA7Qoo|N?tj*@bp?RJ0r8$)b2uSbOh zO~$6`SaMSjjNk5CM zlNqU>Sd>+MQ?`=25HQ2P)QCx#u?M8=Fi$mWoMMzUbjtxr822vA>vZhomNWsY|ez_(q zRSKLi&{brBfqG@kI44BrEx4e~L#L(NO+&;z<|b%YFj#RovxpMGAy8Tdzq@y(8?9r; z(p~pCW0l38l(jV2CDL1Kf^5G4l3X`qg~<5OSXIK=I+B+Iy(^sbjT2e~Ix6`OD|`jm zadq7ELRQy6&XyYgf?BrI9`R;{^z$-zSthm%Lt+g;HziCdhU|lktGdR51=w|+ZB7Rd zboXL#+@#~mKk78kUn?auYG3{zI>JVGW3 z6E7hN$*JfshfM!3Y#t>zYYS23119l{)K1euG)bqDOS5Js1#cT8>^xqC8SY5!QkU9F z!YnGlcV(gMy)xT&)mqy*KkA}eFcyczj+I{AP`m*)%{XlaE=Hpb7{YF~nRGS^dMSLC zq2kRbx8Hkusqzatku2I=6R~np>dpxI+Qke}6Pjn#5j5glc`^RN;XcY>d>wSPOKo}n z7=&BNJMpf%uf(Vnln26%bL|=fRRE8;Ggd*wBOti;#ahkATEg2P+GiTu_Tj1WDv3?bgTM}r zNF!@`=(Y8cU* z64hDQCMIVG;cwqOeb<{CDnCwd^4|b#K<>@FQw*@y(*kVK{RII1pZR285I*kE{%{+D z4)$%ofUO^>`4j|kLel&6{O7yQpztgHjK{e-n#PZ<$Gep4hyIr98%OJv{$j{a>s8X* zm78tz$VlOUCgA5N?=2^P>8%CwskF`K`e+{0$0J1MJ0j9yImM^g%b;X{hB&~BLy#t) zTY+hR;yRm>rd8|@VdS|zT6jQE(>dP|h1{&kqDj2RM>HQ$ninKnC4dCj)kzyr@>;nW z{*zn^=m3-qwd{cl&5HfSRg7UST#-+Yc0sanHWm|Tp|??Ufz%OW4A=(pH<3L@$awkK z3zmG5H3TXTeC*O++^+_HL8AOu4fR+^m=U4Dz>whopLqf^d7Ya9paU12)}&%1Ip1`b zh-x+|O|MuL@Q{qgpuvW%pMD{)vw8vCLJnybdWG#;5eDfC!WNG4={a}ZyufxnSo;*Z zZcmpXE6%8nn_6>ZW79_4;KUXunvvBi`l84LM;6Mmvi+iON|_yTbNgNvO{9$-WT}8p zVeDQ_)T;;L;&55v7B+Q}*=&lw zw7>Ky8p#XKDg#3ci+iPxf+b;;zxs+uX+cM!VPN9=*h=u_fi}{8@lsseg(TLMU38gY zETl6CtfX1tfEE3q7y0!K4Q*r}jF9g+xaiC_*g?2>1e)2|jgA=*w<6GgI%_|&NUsaD za~4O@Dn1Pdz(OSk6N_;8mV_QCLu@z)BH4XBM*})?IzxCTh(81!-DbcCkI9IGtTvLz!4N#gZc{R^F@mJ8$1*ijfq+f#Vj8Ng`l!SUh~I6@rk94bO!inti?r~EqpYJOL!P70kww!EW-v3xl~;YB>N0KH=JtJve#xWeqGCItx5s)U|aq7JyqA6lNckvHLJvj9TvjPdkZ{7YjYeHzD z@`wgt^4M;Sn;uLy#o$RNo`=-2%Vf5WW^;V=*K&I1ROh05yt1>iryH|an#y;RGQ7D) zfbV_3D4WWYVNW6h;%6vxuwVJ+I~gDHD06WAA|HOfohVD8u#ep!*CxAV?ok8d*|PG4 z`cj2rlEoRc+~__@;@U}~T(3#^JG|Z0kJ}2H&q$bd;QL!ZG&#Wwh0Z_-+ZqUmXIAPmP6^Zsy1|1fMeNGLUkjVcs z2kiBA2bQE&yz~VYN(>d3_+iZc>}>Q@(`+6LUruYw3Bt~Eg6z2yfXtO;}md(iXp0KZ2>ca zkEhwF+u7(87_YL-Vst0455-00pxWufP4^k0P5)lN_a3kuv;1Ap_O9o=OZ3NMCWMRH z%lON(%Tdz-XhmNlK2svLX?YqvO@*K8l|+Dv_x1@fOYcz)y5*+iC``co(B65>{f}H< zQGNf?XCC31FwjtlY^gf;UNe7<*Ju#^4>TQASDHYtL1;?e=)<9083MSY*N{$c4F;Tt zeYO%+&@nn8)%mo-A2m3>;-}D6fP<^6f5lr6tONweyIM zZGX^}>)>Jzl1DL^E_77cR4FRH4tN@@3uDyap|O6hN~MymELay-5~}wm*_1RRQ=D@d zo_7d@6{4%yBWbUGNcj46ik05RtrOO}5_UNCzx;2#Lph}t75`_?Oa4E5UNk0ZGQSu~ z^1L7`pd-97Kt7zBE{w}jf-dE*5KW}mRup2^FOTMAZGkeNT_02UfkAp~n=MfK@GDV%013Mq zK(^=*)`MB{$W72!>Z|uP1u0~{`MS0<5UCTT;GH*! znmJI-Hbs}8Ad8NDu;Dcga13^oV$jq9Pz0H~TM||h5JE`^GIgEwv@}F>3YpPJo_$YV zIcV^$xCBG3_3McRPUw4X9Guh?muWdNsqG5LgAF;Pl1zumCmRMRzY=GBk?B2#;aam5 z#I@7(2WCR}Qx*eQ;h*>}(pqLYtD_#4K#dF1f%(cu)j!tT(nSIDs{P;nEM&s~jfwcJ zXJM+CjRbzK1=w$MqxDRQj#Tb+KoX)d8l53(-C*cbiM__(ZgR)}F05PeGm?P{b!6hD z6rZS(jDaysfj|UKU}7ZX z<~<#+xU?C()pW?*g0yN9iKQ>nkc;Z;NWRQXg>j`;7$(cuT$%Kh#vTZ8^kNLz%~nEN zY-n||X{!^w*H+k%+)jmbm2GKnfQL1a&t)()kCmRAyw_^eZWF zU!fUq5Ve7+vlZeyX47~)B8%Hcbk9cs@c%|}M17NU zq<^FMH@N?e;*4S{07YdiQMAD6AS5OM7RDl@t||C$Ozt8c(B@*1c_PJlg3k|&DAP{? zCH_{BXeMU~nYz-XWu+FNfvSl%q z+Oe|?rajWa>Sj}a<>c`|zR4xJOLHW{>!Laar*iV87bZ>x;N~VW2zfwvuzHqFYD3gK z#wLHqX;=)60Oa+trQ)I!XKc>B$*J42K#|mvexu+=%?8FGT{`Bl{S{sv6bg>3ocyj; z-xLE5gsA#bs5fLdv-H!!wjsTdf`W8{&YZ?G?wSCCpV{~Ul@$7k%C z@A1Gc-{G0pF9~kELjD&v9f)$@WHJeRfNW$I6m}hR8O{JOLC-ld9f!^?3rtIdux8lO z9il3Gt8J~qrn#BNzt_0zz^ z(R$-4H<8hZ4d&~IZlgw!$^xJGRFf2Daaz)nZ;^$)pBSfu6Ml3auM>SZL~93g-XX9? z)e7DTxQl<-l?1{z=b96C16H*F!VZy0HcB|z!xr4nz&GY&=DLv>rBaH@P_Wxmx!h<5 z?*g+5*I@CoihoYy%In3kw-qsuLR1VQ2vb!f^&#e?l0ZS;Xlt1QQ(L%$A~&R&l1(-a zki?a-dnjCI@)+?eE5!FrJR4d|mwiJJEislkWN=tdqUCaGW4*jrT#!rv#LI7UKae@e zb4qO0H9c5NF+<;M0AIUIQOY*s&-jApgH~J|i)&WvT_UN4IHoqyEG@N}f+5EecQr`J zh|Px7inDI=Vwl9m^GJ?H68OuDCiQA?Yw=b?6=_AXF-+oJX{Qvb+?f4Q#fo7vY!1=O zCg-w)jsrK6Fm;YvDM(d-V(!^CNP+N67GUg`x+eVPVgr-`Y5u`=R9s#}Cj&wOr2lz6 z?AysyrENuYcc1q-XYw_87gZY|v(Sis&AhDC&<*C3P5UH4BQf)U_lJM#z%of_hwhZp zaq^Q@3`Dn;hvY&My+3>H4yFtWZ0+_b6&ya;q_URD$U6a1w9gRV$I_Y|A(yo`YF z*(d3WkK6@8bC4UqkCz30Wirg|meFJFCnFcs8_b)!P#|oVPd2bbepg)z>0TP8Xk;FL)Mvhq z6r&%cEj9)#*@q#DA3j>a?6plRI?F4J)_CR=mDcbT#iAeXW3NcS-+BW3N+1n17O?vc z>#2F7zh&12pU~M{9)Gr+q=1hSHA0_ePHzOUzl8(PGHRnba`m>_i-bCtAgkCJrHcyUmmtomgCM5J1Y1YX9&qA6ZLha}v1Y<;Geg}& zUp0U5M?D4%5Cs49dYn;m+X>t7obAQ`04OiJ8c<2q2}b3&vJ18z27WCKMphge2e}prXYZHheK7* z?YaIRxIYx2!RcpQ#B0jVuzZCo84cM!vJK1K=UG5yQ}o%w5KQeJ{N(DsW`Uo`k#%9p zgT!11W_}1SJ``!0B4Cjl3Lg@Z`%nJLnH;}<{om=q>Sif`_@9Hy9r1r1o>EB!K*)|o z1Zld!6+jgXGQvfHpRm%Ls{m&}(b$*J{FTUCU_@eMeK7P!UIz)gDDxrQ{V*>l^Z@%H ztr-Zf0Uh;II**5wMCj}D{vP+oiVyvb<+p$Ff2@;H(jfRN-9z+BY-}Z4i_Di>Dw-r2 z?OPR7BzEf}AxS1Jrxb0g_986~;0Ql^@g<^4f%VIC#rwtcaq^?NaY zWA6H?16JtE9bc?~W2{W8lG8CoMBuk8o_=%;f-}=$d*hyMTKmB5q0p(v?ij!2vyrb3 z`YO=^cQ|mK_uYkCYu@)?D`wHj4ZojCN%H;XFPH+YwR5J?i5YyT^;Q%OK*-bHEu4T% zQae;KY4!XB0Rle1VuOG4$Hs4@LkM_NLy9k?@R)^OeU@KkIN)c;+imFzE<$&O5y>M4 zUoNW`YC4#9hQMS&Fkbt3Rum2$Lgn42Gqbzte>c9V8KJ|Ie-o~MLe^0^Uu`4h(dn5w z@jPq3Uj5sF770BZ+N&au1PjL@`4$cMx1U1)pKpLs3^jRH9X(mDodpo9Zs@$Ag%^Yp zbui+-k@8KYf~-nfH|ZO4brUkOg9yHwoK86mwqzDm`BT)vaswxzNoFCTY|FKQ(Wz~X z#XGhsEn2p3iRLo@g7hN#QV2$hhn-9Q`}L^r_U!2A<#2L2*o>yp$6xdB; ztP}vhewI|}KD+j*ovl&VmW&v6dRYGyytxz!GhKkE+=~*C%mR?Nj<@tYxs}&-sA~3k7BpqhO7?ba+dJdqTesJx}mb)>VOBkCl3i`>I4N z-nOU%W-8H)jDXU|snTyr5vC{8Sxz?XSbag>ozF_{0KhAgR%;8y$7c+yQP6= zq>N6I!3S#$N}LrNo3X=o5m|KKs_Zd`>e~m z;)&jcq>?gGo3RC+@ao32)9y7@yK$qZkvOva(BlOd%Ly;=fB;=0pgjFF-2~5xKktR< z$}w`c49OgiYGkU0uq%HRkGxGzp;U%)LY6QwjCK=!iwad1hh5>Ay0I*g29-_E32=)S zQSQw?0`PLBYW{gEEeo>b=cH19h#LbML4PW3BaJ&FuEFjcDE*}+`ip&;llGhD8u#kW zJY)5h#nYd3h~MTf$gN>^%a0ii8J^` z^O>Bd?h0tz?TM*a5Yxv_gkTXdR1txPYq;$lKyC%v7s=GH5s8KG5t zS3S#wC}XC1W%K4c7C8Tyd4KtPiumJahN(|`&-e7eT2|NlC>Q2H37+)Fpsfcv%ZO4dvI5XYB=rr96tvF_O<1DzBf)08$STf55+2nCg}wp*d3w@tGPvVGPhcc zYk5)NwZb088Q~jG`ycuWJ}F>E3TFJVZKJbWDDmd=m@E*I^y9{=<&0PmGuy9fI^sra zwslX1^a<;rB==xi#vzp%Y)n_bGU{$(7(*12Yhw#1jqf2;0N{7K^7e?Z!xtQIf!lM4iO~G4(>ST5{l%S5r~)o{1>b@6BgzA={^S8U;hPGi1W(Ge?m)yVcNcQ>8aNzpqg-QB?8s zi_LCWUm)if;U6-T<^wPk9xD6J@bGq9j3ofAJjMX&a0A<~10=m}seV~tT)pW$qTqr% z-jfrdz$UZB5u~iqct8C^CCD|auv}L_wSib3be^YaN~TnY+c?yWr#7ObEzs?}rk+6c zDu(+Z(eJ@mvK+!{fn*ER*>w!z&Ozj4cgZ8)79oU<8QuU)Ldh-kvu%g+X?0l>iXH zxc&;2a*p&{&Pfe11sZ1CI&f0Mn8B)EA`IRFKr;Q@XAZlE(FSqy<12W2F++nnKv#td zJkdD*zv)kcQ;v79z-ac(#d^Z6y+Oq!-#9BCBAg9uZ^lw;^6UFj7qNqT@DH{M$YufE zrX=vb6A5+Ea=2Vg50GDk6aN-v6QFAR9meZ1P-JC;z%P3F+d3t;d~|0xARI@+HhrAo zsdq4OMMvqVidU7orAKuKTCp-FAVfC&fz7B90`F*`8&$+ZOCnaZ(zfxcK(c!sw3kt)Y+39KR*~4arsA_hy#p z8W>&ozc8)JV9?m@c8^OA691$+wynJ}AqUtVLqC#`4vTRtHE&F~E+oYm^5%Jtz$DuW z=rh5Uq~AHpS|x?ogk@?O! zXJ@8Pi*EiPgw%=6hKl7B@Y4IMdgovgYvMZD@gd4II1SEaUPH#^uiwv~)QH@rU;I4xeMK3K+iYS8)>@RhIjzS5n)y8*a;X!}q}UL!ytd7e2gN_98G*7%1J_$p zgtYlxpSBhfFNTn7n$~U?#V)mB&N-j1Er|rl4D+gbP)Y*2C+4S-o@vE|@AwSPX6akY z%sgCb%af62Xt4Ht3)djan+4idhc|$*MI9uzd|=D6)mR1N%IXDWQV`+q8_=7|urB)G z2X#f6{Qm))KxDr;^_)#D^I4jYS&;8O>{d(m>m_T02%xSOe%v2K5ZCE!Ac!U2Y$%9E zH0kVsVDtYYKn%nLH(txXJr{d0fP**`L_Hex)>j7+!cv{RhQTvhDC9aBxhGBzl9f;64#qH>3^`B?B@32_%5|KhSlCzw=e;pwm z6YmqWUR$ieQ73y`vt^l~_XqG^JVDhZ%1-9ifLRvwpk%g7i|l5t0l{jFfhWtk-g2m7 zuM#&@(n{r65Ff&a1NcRJgc&J4mPk7JAU=jW74>n!+5y{Pg^xIPq3C2N*UrJgy@c|) zY=*+c*$PA>CO#2F5yt`y#$OTme+${6eSKATc?|dQt}}Q_75z!p_6ir>#o=Mwu@ifW z1tu>l#X$wI{^9&oMRqg z?aOS>W-`nPZa1)Ujtnzvlx7?g0hDl_+*pZ3RaCl1*DYwS`E-=0Nqkn7fA?D~L~$z> zFS1j!HT7&8af9Q628SOPnG+P-;i66ac6I)kMbyCWk`(jDKKr4fov)PplY(1+s_0Ul zF5z+jU%(eB9SN0bPj@H2mR{ZTCH%f_`Z87L-ex7rJA(K_Z|RRJF{4cGWM-Yi=pQrT zj_6U(rMA8B@vuPOhb1#*gk?=XE#TVWu}w?V~}Rm%G=f2~MURB4*ptV$SF z3%vZ_@O`!KzcbKBT>oG-FZ9`&5zYPop$7Z{{!_5%!EOUTpu_MOwd!wquv-hpf8&1w zxPq&*8Zt6$YiIvsCa!qV(X{2E=JrD#c5{%Ot^_b8i+CWJ?7GVB(aXr$oTFw$a*Hsk61e>zicR!4YZR>$tSQr-|Y z;!7N+grsBlXEXhL!3Cv-jgQ
l$Fs>b|wbm-ag8z;J!7bk1p|Ued2OHwc^X<{5M{ zb1XY%clBB6BMED_U9g6&IQy29hOis2OV1z#U#`BPe2ynZvx%KnhMiY_ZC^G!R?JKnC>m3mB@>^Gb`{t)_pa_v&>k z1S`d{B(sYt-7JvhPIr)kWlXt8&{ow3y9~Qtp0QG0e+Rs++gZxB3R)?;+YY)LpF2Hv zRRJ3MKeTr*p^}AOMzT|S1JWb+X)eF4v%X3-$V`n#1oQ}{MWM`m{`k*qd(R)$F{Z!`01XIClF+R9cEXM~3$J+Ap9)xOvn+3Wt8 zq{s5=K9S-yg9FTdV`Sapjaeu8uzTT7rf>aee`Uq2_x$t&nf6ReMy<$qQ0vTwZAW+~ zvxROPNTzcsyEmOKy1z1b4{LcSAP%K`rvQ?5=S@8o9*7E{N9V_1auabBy>8c%>q_2ciyGwY())G!feI>jl>M!B#(VEt__8Y=JYNTcy?|ORIB;FfqdYnr5 zzy+5`9oFMLyspt5n`kB-mm}t_ z$#W2StgXF-kCyOD2bzAlU!p#V`X}(KC44Ftt(nB9Y0gP}Ce}WVXU1{1gy&+h-L38X z8`E>2E8+7^zjL^R-#ZJdgg+q{~&tWFXH(U{w%ho5nucmYR+K8ecD>N|t4l*bRW$4YoH>ThhpXH9DFzwWB- zuH9&c{cR=uD0W9+(MNG`LKc^#=1J^WZmwAtIDM$w+Gwt^4s|aTBVGIWhf%lOTz&fS zC$Y434N-RYSsY${dbwGWe_+^XTf8h#k|v(qXm+2O`jj#llBLQ?m>$}C7W+c7yd>e0 ztS-q7&-77RKArhUwCw`c zs1c+PmP=C*}Cfw0_C**6nvVkWJMc2?t$2Z zv>td)wg9pIwvcR{kZlJ-vRwxzqLAF%F(JERQF>zIr1Zr4f5Sc*m)&P@oY8fP_>kQ1 z?eK>=AJBRKC2Z-qgiY%IVgF%<0R6lnT9W-8p2PPmrKqnXBoFYOi&P{x?GF3;&mhuP zk_Th`<=dEOI`p=wZ!+hcnfiLiW%!=q#gByKRfkJrJ&9?SikZfc-9bsscuRF8umw*nbv-(VDZ^uQ~mN*rBM|9yVib{NKv|T9IQc zLuRZ)Cx(7%!lq|qI?fw(@TPB?nZ9|!>05r%hPQdsZ$ItU)l0 z^8W=;3`++HepfQ^;SK-*uai+`Wq(jm{9FV|!ge2!Sdo~Z6nLn?w3LLWX4su>m(K2N zW_K~fNa9a2CTKMA2l%6mw?$(VAAFg)U+3I&@65OFpT7clghhsTXJ^gY8|#R>eGz%q zy5)AQ70Xp&97vu9DzZc635shW1y2Medpxn-o^bm~7ADqum&-(~Sg~)1Jb$)>0mlfEPK4f7p=c=8&te0k43o$FggcT)zO#39EZo$f4tc4%C}o(g{ImHz+#etxlwcyC$4eOI8Ae~wo6_#b zV9-Be#7?=PWuky0-3#O`VT3GT?h7UkKVq_6{)E|c+%Dgx;~drpJbVufVL3WSkoy5p zO9u!Z?6ZZ)0RRA*0+S&Y9Fso}50lSR2$SHtCx11L!A`?442B&FbnL`4L>xd{m&2GS zyK#Zjgai_ty~ORZrEQe70|_yS*W$nf@K6Ys2_G!~pZ)*x_s{1S09?XQz{hU)kiW?# z>J8OK&ZREoSn8(o)?sU#I;$E(%mX>XNW5HO%=C)%CU#Yn^8#I@vGiqDq0eleac$@- zH-A&U4f(&n=$vD)r4TulK|YyHrZf3I03l%b*mObDt#u?|)Pz!%0gWT|63}}J>h4cI z@6`freRiY!_fj|%aJt4VDu;Ec?w4!QZ3@Q%QhFubwH%n7Upz55f)x4^Y#G2wKS9F% z0Fqx&O9u#b;C4Ca0000J0RR9{O9KQH0Fx;e6qD;|Ie&S4RQ0w0o|(zq$;|*^LWBW< zAw;uIQc+n3Q3DAOnFLCJIDo><%uO;fGdIox#2u|wYg@ZKYg6s3OWU+ARVmyFrixli zeM+r;*4o9|eY34y?4qrbp5MJQ$xJc{`2Ko)LUQl#ch32pefizzPCWJmfO9ORgag-H zvw8WI6@OhyaE}@bRro4`-4*o}K`q)Bjws2n7V}26kjm?EHKHmBl{b5pgg4l$2KOXV z(L{x>TZtsp`ij0DZ&c~?h6RlkA$8gE(Af+dU!KM9PDLWTuvd*Im?|$UZ#cK%>~kxw zc7ON~qN0J&iqaB@|H`_r;aIK7TddxXyU(G~e2ka7==YP}Q1kO^fyYI0LtOEMAW zuURu`R8-fPG!kks6%Qx-8^^<}bi#>gGDXmB%*I@TGj~VA>4c)Bwsmvs z1hiI0TY!Z!=EIYZ)+(WbP|Rm`uq#Aaj9FRKTx~m1i8Ev@#+huDZZ@t1)p=Gk;Uv@& z?CRwyb|pf2wST->SJrHIq8?rub!cE)nSY#wwMId+ag&nlWh1_iU}c8bf2#9^$jX!p z&#W!M*)q<;IRw|leF;mMrKDzhc`j1op6<97^?w98db&8Dd8!=fl(9{y&MshLA%eG%2To98=6u%2 zcI*%&b`mUA4Gvs7jzZQ>Q&QM1C@2I+QZqN52nzdeLrBIJ!QQ-Fr|#&FC6)a-8tUN$ zk$dQt-nh0;=@Qm8{39%=TxQ1XCiah5(us&Tpb1ga>Dra3>V#&l^|3m3Hh)~E#48r3l1lkc*Eg~vZRKjvwy zix1-?f}4*{$^FKR#XrVYP~ttQC=0)(zmG#|X3nujzG0G|z$c6Gar_rEf4Kvn;#_}u zDy$|Q_%yFHJMbAcZlXVtRHI&DVh8Tx9Ac13CBulNK^}h)#JAc$R30-+qON_dbJI7VT; zlIUfTC47Y&2Gg>M)qnjatL!ZPe8@JXkB@rv$d_xOWQ*&hi@-aEd8KXFFOuq&+G6lV$F zSA7b+VN)?-Rv{0yjCaviMNEV-WI;x zI+;5EEkNGk`A3U2Wl}j|b;0K0J#u>jBg`%5csLDi1+#)zvT*jQ=BS8Mbq2$Gi9nFDqldvyQx?wh#Sr>u}pnu!+$e=b9I7KTelQgzZSL&;RmxiX^0dXl|HOtv~{HqBvOk(*D_ zV(nU{LOW@mkn=e45L0T<$ttBK#v&~c)%`dv6bY4iJnvK#J0Xz9sxwt^LZ!uoGfxdi zqBA&_{Nrj_rZ(wRRZM46b#|P|2au>%raF=4Xn&Q1ypyC8;bzn*^uogr9JGYB)t4II zk7e@J)IDuBsnI!ft}q>sKQm1Ub6Y=JPFP%>KRry+qLGgqFcF|uMR|`h3Mq-)M;9?xd}>t(ZNrlS^hc?3@%3G50WS7r7|W673U*?c^w$JZ_nBVO1&A;;%P(5r zYHnHEwAH_5*SgJ3&Hk2MTRU1d?^?gHt;I<(ZX+p5nt3Ub)%-YvPBD_uPl!49^{Ry3cA&p8tKEF zeIf`!|}wa$J{XY8II7YVf~eYTj^#fw)8c!!grlk^vNmy@`#gQ2nC_!58aZ!LbFXYQ<@%CL_t2M1v57D{ zqC~qwN@f22PbC+I+X@mIr}34AZiyy8ztC1|RuwA?8D=zfZ*$Uv^pK1q`pOJDJ;ieu*CCXpW985RT_Ya|VwwdbN;QP7;-7wkS}aCPNfc z^#5(sJaTH>nu)t>lt`o_Ec!M2DjkyPART6v=!D__UnYBx_k$a0$OX_5p3yS@9h^FW$VSjERp<{_8D_)|f z^6v#kn-flHqRowV8BNL8C$=9GJeouC8!|mZ#|W2B6}Bg_!O57(9QCYBPZPI>axkuP zL2fqgL{?1YGWjWO{AD&hPsatxZ;A9B?sCw#IoQHH8pKs2*qrnoGkO0nC(Z(gtk$$7 zkB&;8a06q`;>2^7M1S9v>1C0rizCdTIEt%aO};8)N>qIHNebC^jA!c=`o1{z2ieQt z%qI)uYRk_G6V~~WxbE|0K4JKs^qTn0v6z0s4bHgBuq?*UxU!5knK?V+!X?+6a<-V4 zU(hdQ`Z@i|iHQ018zIY%{I4vNV4GI^olLLN>v>J$mR>d>SAUK_aJLXl#lWBgK+D(dG#;w|a6e=&$sqK>r(I0o$=5)AvS{R4mxby{{S5 z!io<1hp-!C8YGMxhbfz<%zY!hMQ;o3{L8`Ph;i9$WE zPhyh*={MTP0vM5n{%3|P*~iqbft_oOQgRBhehU|O?<~=^K;k) z_wX37MRa`JVu>lTJr(Uz!@Xj`7GIuBAzAKXeur(a zwl2$kjQ?NmO1M%w`qQ{d$F=5J9oCHaf9tJZYp{lb%B7@bxViMb!&%?+gx>r zaeH?44qhD)tDV`EJ9*`^VkO}E90R)UIgBq1;eUZ)JQVQRhVgK~dkA0EaU_kerEzo^ zPX&BMUbiicXO7@Gg3s=@9|3310eaqDB%Z%Vc#=u`Bpp8-p!?mD>s7-jGl%gS;|kQD z4UQb=^z3?ea2pSj{WE+pLpuy;`yfh&Ne-l`6V8I>B|)WvSZn|Ooe)~Rd&Wm#EG zMot`a^ZIGNqN1gaa)(>eY3{uz4!VoV9e=jnLfCSLA+670&~@a~HP~TfhH*Q5Okr^3 z3AWWudF%?}gC}-P&RGUxl|a#HzIZ;%Z4+#HhH*I1o~A{vJ9Vl&L^V3qv-yflYh#^w z%G%mqMh!Zh6<8|Wg;KAZU(eQQS?A!$OZ-wUx$V|+NeE)M?G}c%?=p7}3*W9Qqkk1? zI&X+p`-;So0WY(grWT#nm(fK!wV5XzgOgv~_95Eb>9z%2x9}SC)p>|6cJsrI0d%<8 zUz>b(*Dbux{%5d!sqK2ZN*IJXOx=Mp>P=JlsQI`@JRYRJ zPCj#plE(Svf?FR8!#Hlq$3EjNqkp|a)IZ7Y3}eO4RCp=7YYJ<~>0^tt)GVqY=T*lb z*#=>=icMd!+u<(G>cLpys4H?8n+rp9oiNc2!*pYyU1;MD4%VzT_+dwyZXUpVuVHnG zt^Oflb^3^Dc%430uhS<7aLzE@&WN=f&w=`3x`X}y(`mZ%5PfdLFx?Yq^MBSJqQNxX zU+=cpJw}fKkBEF|dmK_=gSEb+jJ{SoL{ExD$%)KJhjbfGAHltl%=Bmc9FXvkgyRxk z0N%k;33Ut4sTx6>Kp}O8BBmz^4}4xD**K8EYJw&%Hqjrkl1gAJlVeaBY2xdl$>Rvv>4Aur8$Utt})yJ^6$mG)c_loaCuz~JJvJY zb}p%VSc`ppc$@`E!HNBtX1Nm6EjM6>af_>h)UZsoMAg3 zXWA}Cl`V;C+kVv8u0^fwdeqr&V}4~ZMw``;9L%GgPX}suv8z54*dVf0n!f3+ zP1AGiu{yo5K?GT#t$((RUgQ`vJiLY-{iOj^Fv`n;wlsZ@-+vfrJBHHwCv0~^uD32} z8{}ENt^OD~hUwM7&J7IlW1W85#%?}8z+OH?zt-uuY5M&T{iRl?zn9TJb^15&mT(Zb zR&f-(Q>$B$iHf=o;x~JzZYh3rG+6xB+Kw_yN!_?$!5mmea6z%?71)5Qgu8Qd(DXJs z46C`=N^{t1^Vn(&*=m>ayT#}k__t*S|8;^W4J$oBkI)1E0Z>Z^2zH6*`H&I-0Qe@8 zAr>5y+7xV6os#$5X966=;WE;fue@Y6;h{4T zkm>{JQ|Rg-^8z{0Lb@`o(WTY>V`JzgGppG3+5f&;=$Iqu$%ptFLAG2kS8Mq>10i6( zvz^zpt(XKnSzoHsqhV5mN9V|^w^utfY|$j}AmH(cJu1egRPXyE={AAsRKNrh_>+Kf z^bw42P)i30X2bct%>V!Z>;RJ?795jqW*3thelCBtjYj6X1&q0A9G*~`I~5g8rOlAps8p|gdPJWDzfweQP%h#_wt+NGP`GR-Atts% z;{;a_FtYr2EFw!rM-rzrD=VyDZ`!n71LY`;x@&2h&>$N!j;$N8L2uZ~1}mj>ErqtM z(5EOZbf9v17d>|6tV!f1it($yI;0eAx&~gaa+>blcn*Xc3w31eNXa$`!&FHL) z460-D=~2)ZZ|O+JP;Jj@eAo9*$+#bJ)LlaE@d)e=}p3&m)dDn6}3?Z(}Gh4)SivS z2SxdtaLr{}AA!L#g$7o;=(1BNDCkg|UZ;mP)8){K=r93dn<_0mZ76>)bi4~!*BF(w}uJMt-wX>A2F`{QbXSM+XaYdU{ztBWKH=_P41noY$rV=di? ze})K!8)Lgo@gYdG3Ubx@Peh@?^JX^f%4a|ZdugACva}x_l1WMg2TuiSOn_7!on;(r z572u>UNY!xM`w<2b=DyCvXf^}l%m}2kxvtVpS9i^H ze1RRzl$`_8{+vGGp_}OD!sXK!81xrRf9mRXgZ>Z99V3VXj=HPc#o#|oAMwx!>7x)p zc62b3fgBA?b#{uAe4IWZ(z&&8td{JK;Z$;8WI%%br*KX*o=lrw`m|)Z57K9F&Y%`9 zZbBGt_tIa|=j4#X;>)nmIi-_2kpp#V`#e;cNp3Os7b3zbu2KvMchX%Rx`XZ(f2d+6 zc4vmYbPw(FP(9ttw4|=%%#rLqA>oUXpl-+lae3)W5~1tquZ3t}vkdt7iWS`NN1lLj zwGE_`@odHvy?E(s5|CEV*I7BQ6uXZ_HM|QXhbnnUU?>9Z4K7=iSd|C^npIpg5*Z5& zNA{Y50nrwSZ26+e^sI*nHY+tMgp1PNCqEfV0HQC-r)#5r8hgWIxw0_ zVq<130CydeyR5L$ln)!t`FVt`fS6PumWFqyBt{{G56YH6Bn8s)ehj(k>+;(JL#gBl z!aydKO-sB>YoOUIjFqE{z|K@W6j+tUX#Si)SJD!ZHCzms1?U?JtX`E~f4wTv7R^8* zWy68|#FephEE5>cWJc4WmV7L1mav$I(j^exA`#1qMw;~EglH!@1YN~&L%H}6!8?a6 zX$^>x1hQ#}ClZK)Nw8bKG#E&mrdCQImPw1W_r_pUh=E68Iz!3SNCftNL528pbp>cp z$*{jc$HWpJ1okb>ZqFt%f3Xo0pwP@mmkAF|N;;nf;L)2M9ZeyenegU?9~gu(cb_fSvC$NBM@nNG_Jo2{u3PkF>{fvj6rf(rkf0>DlBw{B2G-$Xf zksUEp+U!8}cj&va{(I9dDqCXE58&#k$GVd#b5qm+7Z+Gr+5JWDj5pc>TE*@?J#*Er`24J2WJL zXj?W@mPKBJq>#eO2bnJW({ot)L7?xK^eahXzZQj7USk=+e|qS3`VG>l&i-YjwA6J~ zmi~-+^DX+VSjT@sv6U_~=yx#XeLxP-+1ru~e@}mqkI%?1f8;t(6wAb=(w6^MKx}WD|uaFW_- zma7M`)L<8iQ+5~XxcngoS6Sw&6Ccllxw4mMGjWTHYv2^v4%rWy`?n3vP=0iJc`nx? zsPR0ez#prZe+{0G0F}-Tr1f?$FO*DQ&wgCl?Bc}&%Q`D!0$#{VWhE-!#T9NTq#}a@ z;7B}YXDY^YR^4==)t@TE<-Eef%XlSXZ6?{)+tJzCo2Tp?OBkyG*Sj~ab#XmRWao~} zP3v9UXi2<%EF)oQekHL05pfeY3&kyY6ed86QJrS_fBb?SBA*Pmw8wTA5E7$Ab=GxS zi)*;m!)tk+R7iKpw+m2OmzzX8G9wD+O2XQ}7m8_IgdLY{Z`;_lsefnhrtSS(wsmiE z@g?F@T6{axW@<}PqRZ@4ss?W3b~i6zRIA1n+qYf5sbhzWQJvNxDM~Ob)yaIBwQ|cT z)LABVf4N1-RvZIQ6^gywZH@ImYOjqom z>Y-lV!8?)L_{wSr_kp9jQx?=PDz@b%iN_LqlY7jTE=_Q^nTZTVv{($j8tyYv8en>+ z+)ncQuwytmxCP)0#ly<3WO7e-v_i==)5aZRf1~D~HfddX=Dqc&&vu{$??sq|pswL- zJ-mzitDSruKt&WYOt)5yjrrQWqQ?IJ=uf8t6tXD)Qb^#MP7RH`JiyVpL=5yaO9wic zmm9r2#9<^v-Yr=XDcar~kM42t^-Sk>B(ngD`UOOrU|>{lE7kh}fWd&g=2m0~M4>xlx~`5S#x%i%GwRovC;J*S*CG!NJ zMXe%OCtzh%BvDkp4KqH=RHz(k_)91^7}c>yqj@Rj;$C`+{!s$q{pD{B+sX*}8bn_r zMDGM6M<8-x(!~z|-_7{$ZtyXAe+Ek;&*rGp!w>QV{9rrOAw{H%AF|9Z|BU11;{v1) zF{&F6#2Pg>o0;gaSml|5h&*mEk&EFv#@tz6|5Rb*Si=ckx$bmTg$Vde~X`jQ8puv zWL%7z$Gfqt26yo@r@YwCSVNYZ-nYf{zEdi`iox@=YRbdk6O{9P$T+1R3wI>F{H#=Q zEBHCUwzf=0p5Vbo3plYJG@1hib*xIe_+PA8M5Lg%<|N|ezrlQCX)BVxs7nj}cP|BK znOMzBqCc&8ffrtW85n{3fA}ZZY^G3``ieAO0j`Qboqx*z;YQ^9832h;kxiLJ6L=K} z0Z06z%pS}38vHtxJSqe6gqMFMM)gZZjp3{Kl;Y+8v;fC%bR5VRm<%xc7XMbpQ7X*V zHqZ_&ZyOrwPJ8)x*3|F4{3uHS%zuD9kqnWFlBu!>MV$gV^W$LZe;qzqMQuC7~$@pI6pXDna(c- zgz^e-=FK_)s%0LvNG+G*skq%DH9x=7qZX=F+Iu2{U9p7eRjUz^Fj$8VsSy38P&g$1 zZ$)NF0WO7d!APc?#qV6I$$HMdBL9--RV^sj`37}vA(L4@e;-KCHz)~(j4R{g5lmD~qxq$`4R>P}x>siact)deU}R0#C#ivdyWOS{yC;Ii<82uiJT zdk~rRnm1(4L{wgqyy{|giR`}=&OQ)JfN$(`?Qzo{IsEVhzO9kHz!kGbtUccsGSP%e?i@hFA6FwYp$C1lj3ImLYNUQal$q7 z3~rYPQJ3mxy72$%*>L)K@^YvmI8qG&QcX`@sZ@3}e`5}ox%Z&l4UT5zF{lNLPJPs} zMj@HqY8co^p;~sw3g>y%9>GMPP=J07CDVq-0MeMzY(~&S02VwFOUpk}NQajS$} zt1)wrxlbntHL8@ISBKf zg+LMAf72Zq-62FURf|LRWJde3H#AwwGX`@IkAT*&=3yqnxJ{dX?}R?1TAQIko=7g(&FHQw6y6#I{T>3jc!SQ z)=qv1H#*VIZ^+T=+sHFXEnCOw67<%Dw?0YEfAOg|8jjPtaQ6hQfAnHotKDZ`dJipa z@Y&A~ha5TDaF{$nKfYYjce9Vle!EcAtQ^i|0GNSE;l88BJU*U)Qk>~#ctolU)SAMo2J zDF=m2hM|v#9uspVIy-y^Q}0n)@<&9aFS~ z46Tb5`ZH_L2ic$xiDJ$ojTtdQe;7J61*tnuSxWRaxu4s9zfvg}a)LiD}*R{deuCNLWWIAH>J!USHxcenhGk@xdqa4pnc*r2U_ z4VrI;yPlwi#z!x*UA%P3JrrnMdVa_*?$O|f#&%EAledw>cK^*b=;6m$fB)6{i;}@j zC&_2fKQJ%;9q^dk?$qXo7%PQU-nib%N2PBZr>Dj1Cg|Ha`o8%2zlfiI6kmX{AEy_^ zQdYoH8eujsP11j8=lt1WOLxEM8=v|PtZN|Hbevuf_sCe@KHr$2H*@sgA^RM=n5YBB z{0^HBwt8ZGf9e-aVQmFDdIx{Tr{2G{N!p@Kz~@gMm5(wrsez}=qIS*h zJl!sU*+{#bR_V5mT6KE?U98*9@IGm~X_szyQ9pfBhkzm74pUOMS^9`>uxWUx(dj%YB;` zP9YW8^#K^qpJB)qhl+>VCGzAi!CCgwtPWzQ*q#SAH;4@%K?~Q0b!!W7+>52iM(WkZ zhQ?#;>^??k!Gq4HXulVcyk$>)(XXtv+(bJ(xT@uHC3kGUm4G3I_CVqSk=J?zkuQ|?Z!+yiw3KO`^=j@;0^))MV z9Fmyicl0ADA{(D61MoaXs4wpqsb4UBb$ zj7GoHZ^&_ta@sNODjq0SC?~>>}l{DO=ah8 zpMGBctG#&iG4f-F-x+Ltm`$RsB3GT?x)4HnSTky9MmeVU?* zl?I`LfJ9G=idr5)wnMlr?0e`j?&h9J&d%e#BelH$mdH9ghn#-rlhm6Zop1m2=V|kC zJ`fIzTHX&{fB2>x-y&-NP`LX^TIzQ;P4dU^XAV7e%OoEfC(l;DvwNHpK{O#ChX>&8 z5x?^ZIyZ<6)9?_Z00DQO!ydW)V-3jUM`WsOjJqEz5Dt!O`8Lh*{v6*?%Xf*~jA?u{ z1oK1`clll5_Y0a|Y`nL$w79^SVS&y!wLIQ1!C!uif9AsLP4xob2Y=tI+Z*Y_((*I> z1Jw-Ac$r_-?XUSay8SOcsoRr^)md;Bn{w*5O3l;lLUopI*QqwO9c{bXBJ0)Vs#~|) z)h^v$t0KCMs;qAJt6QbD;t1bOzA1z(gan7dsJFcaFF@m?si)5B-182N zy6B5Ke^=Fz>+}_RQb(Li$$k1X=3Mmqd={%NKcJA_tQ9hSiv~*itR+~qKfG-qk~L2LPe{BoC$fBKMO43+x|V^li%Y&p4XIKN(i zc5%2sU!&yhL&aecsXPC$Q~-bhjkl%^KsyHCf2RX!3VhoTNCh$0;9D)|T#Gci1HANM zv>&@aj5EK8koy(j$D_aj&m&L0j1=)xNcSYwFp2=Kr@4GS3beIQ><;qrFwN&@XaWD4 z7OEcdqXt~0ZllHOMcg+>-c|BIA%i=lAlT6uoaC<_4fiyJkI~{nI5=7i2eteSdw zyVz&f-&}L}tycTe2HX&G+(A{f{I>`egozLmDIbPIb>pfrjvYCusXe%a8jk()%m|fpuiLdch^O=T`Pe$g%?5DWe?@-# zV-WJqFqxl2%pa%E==QU8M7KxjMcuwcujuxd^rmht<=kskE)M+%9^@7E2`VU!u#O(a z2^2^tXpX@To}hDdoM;xrTUZ1Q+x6C1`KbK!eVGz$QNEZ~Sc{DcNBn`dNO@%8ZV-yH zR({a5EXU=CZ=lq*eITgOq@$wBe_kk4nIK`LR{qgplvXG$j!g28!vX+a0RVV{kll~? z9S`u&{TO;J$8U(j{Ptt~X2@x4HJq)kM!nbNGY-=tz-Azl|87OHf4kM)*yJ;O(A$Zi zE{~z376B1kf>Ixc3|%1qO3=f)aL6qsa;wMITIH+qdG4XJ{f4g!RkBx8e^}*r`@OnM zu7XBi^1A_YjE5BTV1022lzx|da|xCP_5jpRH8ct*$e?wA zF_X$0%&FO6YMkADK9}wfoR#W*GIt%6|M^1ceONhk7CgA-tkc)d$q7jAXk$;*AlZ6{XbwWkx}4fjt1il^HjSH}wMv+#TGatox#zgrBt?hXe-d)SbL+#qYSrbo z{@z;EW$%aYI(sM7ww&sPWOM30hbbd+=n*Lhh|Q_1A%OmVh`?_^2*=emSU91sZGt$o zan=Fps_Q~7yF`VM+lE-@cR_$E3hllCbXkMHi{zvse3{=gsjknd5pay2t4Z3XCn!*~ z6gz~LJjGIijc^W?f9lh*jbAw;FeeC4C-HwTUAK4ef8DyhhmYv?7=KH*-{SA` zkCBkR&(G=pPxv){4gJ^nE$ORU)W>Z|X}VR)1n9b7_obEo*VO~MFRk<+RFk?dt@IyO zkLbR%(tlh%r~A@M|A*>Fx-YHtf2w|=`_fAPb@hhsODp|1)k)o#7JZw?RxN$qqHn=L zm7!(t&{hq>fBN30nv>)eFv#*I<<7@l9eM?-Sx*y)zw@1jJUmohV9;8YcdQ!yjl$3@=Sdi`#Bm@MJ?(XgdkOh0u=9h``)YM})2q!=Q0O`g3Kzx1%8)RPrUyLAR*}vlzW8$kw zBQZyHyT|6rqRhN>uyAzg>o8uD-+YVM10B8|S+PegLww#?scOh^ArF3w_hwKI2a?xl0|aLWarTj~HAm90!%D>v!U|29R`KJQdl1 zd%}u)sV(Dsel&^$`Q$FqbXXrY(h^{DVge;#XrwdGJ69kS-7~@fUr=`VA7C&HUx}H%m{kq0cj)b@B9{y{xVyi_u;O)r3IA(*QxwrL3xqyTIayW z<|z9t8(9$Q?VPX8A4u&_%=q}c=$d_>Pta}#$azGQWtE&bXWmpzb!Ssy5fzmX3czg| zYvR5gBOM4Qafnyi2&@^KO$RTl#N|F^HX?!X(WWU(8~bgcM~&4t2xY1!-Q-n5Z$9@kG8PYK8s4@Fg{Fb`w= zl-d)7`BI}t!vZCwwFUI{`4=RjP&j5?Ns_Ruoa8rdeqAU`guj6d&@4Aa*x}3Gehy&6 zZRp#)S-xvzdOBvva&QYPv3vh_Lymu@ap`A2g$&7biV_45lYnhLPhM8N%Q4$jG$qpc zr2a^`D>$10gRQV1OqSu?A*X|F46w$g!mnA0^M5&3TQTZ?6@lhM}I_VMGk=S0W` zh6ZfpiUzVF=M&M*Fyh*lRN@~zvWbKEi>Im-c)KA4(z4#O@l!a01WWSSliTb_ z3%*z@82t&$ieh4CgB+76${nduaCl>sGA1^#AJ#q!eVnZQrRBP9ut%`C#%=Hmg7^ff zBtKjl4iycda6=Y4n&S)s$CD}B5>_a|Ej0=%hy~%(29TgB-v>$ATIx^QDUB)T-Wd8| zFxgIqww|_p$@!AQE`Lti_DobWt%i=f>Wh!yvbWdm&3U9DLVB+oCeF`g!>`_y%vP6V zbrBUs$p#U$rj7b8n5y_8;DTb;cIPri58yY? zg_-e_52GxFWW2YAutguVUUqPdAE!EngT|tml}H7!9O^!wUFvoI8tX9q&e!*BvC-KhDa#R!r3 zJ2^%cR?{~xsOj}e)d`zHB^3NdxReR7^!r-8((thQC~wIL10tf)uxNjVQ(v z4K`N0zWfYZXVLqa24O+k|YoO=RbY~9v z80WVZ>Le3BWTMdBdl9tCJIwCx#d^}a9kU0D^nhF4>!mF)4Rk$AX+X1IS4C&3`M4K? z*Qe%GN5BHL)b>q_zc8kb+1SF?Q1cEMW{h+0jC(;1IfTF(BiZv^Tg~`ab*pKj5}rXt<0Nw97O>F)be)^7AC)IL!|}W< ztt*<37qD2WO=9DFHe(-!=8mxD|=PzCwB`dx}WnJh^&V_`QVEzs!gLzrl)SuShSVm+vb@H-o z6$;q&UF+S_aL#Z_P6;9Kde{8QztLSItuCCUN26uD8lXr#7UcL9?j>wHUvBf(_OPe6 z;wB+=FE8T^$r+QHY$1=N2g$~Vgr@xpNeJTrJo9Xq*EwXDM7$|L85s!!jPxhC#KqZ< zv6}tryJA_M{+h9gg%nx6#C)G9Nx@9+E`LKDcal}7xF;+JWJ^Bx&--|Pt_0$tb}$17 z*kMeGz_)b8XojP|#=EJVIG5HS|+=()`STL`` z+Hu%*2=(RUTCfTn;1q=WY-{v)A~QjvF)Lr?4Ul+}8#$HHP|Eq0D3a21PtP$s|r z(1r}xu{?8+XJuB^S*E5UegAtDto%GDUt z^TAej`Vv}P%Rb+%?oewNy7$N1b(i{%6_ByAby z#kuk0HeDyN>gsm7Wp&99B*I*S;@$GAelwQjN!Fm_20`V==koZ9z!%=OF+uBIn0B$o zzms)g?ZOp;rW5ap%te%{2%celNEJVGOf!Iu?(Rc5&KIiX9U?)sm6r(sxkij*Qv@3{8fIZXN7kuj#R7ak5|AV$)Aw|&;;X^xIl4~>hzq5lbW%=q zaLM>%RkKyz?<}g-ua~0U`s}Y{kAL^>ORsQr!`I*Xx1gTjXnX9xF?NM4w}+}F_~(7acM*hI{knytJTy(>1~k6o9i zL`7UCxQIN9NGpE*DT`?{%*1hm2?g>twv7VWdU`R7a7M%JyBS(E>1Ic1sjfO1bB-on zy`qsNl(x<$X1PbkFb=fL__i=NN4L_hsiCH_%GP?1XRpJa@Z^g4TOrH-#Av6u?;f7k zcvKz!K58IITec`OMZR_*Et%osY~qmkOkvxV^#KaMe!Mn~gIWL3)M0rf$rH%T7>~At z%WYV$w_l-g+QwXYnSfU#MY|ZXK$j2i(~;;)9f_xsjY+?~f-K151MRpF%Gq8Gxw>Fx zsYY_~u?pnR<0GVxyzR)i6>?kT&&nM6`Mrf*pBb40W;}@eed8iXS-L7iYT8z#O)>fj zj}&o|gJ^?2onGP-o^)qr=mHg_?zy=R^i5O(kF6_!7J)NA=aDLiN%k_S2_(WYC-9lr z_Lq@C#+A}|UbXV$+=%f{?cK9Em8_2wq7S1)vvYOTi9Y$HEr25F56y5bj|d{uZ=%xe z2SV-HvYnm{`o6_BR13;d?@6_6jAmziWoYoqYJ|r_yqs0IH|#~wkJR}%NN!=->aLhK z;j$4fi*(H=l*51!;)Sr5zGSA;Suq0z)Z^dA-&#Mdn!BCpK3?qLZ2T#wp|MbU0rtm= z#1FMKPeUE!xTWw%eKQz_f7+L5tSCXZIV<(XtA<9!WmkNQTJnX;&zAV^Na_SjNB+J8 zfPp3DP@0BxU7A}oJ{HrtE5v5Wi)|H)aDXq+U0&U_Gc!g`Yb)_3e8kWV`H9z%4ks~V znyUow1nh*Rs#^4?7h=Wj;(&c}8Syy|L7>}g+4-*y3mn;b%&T&P7r7jJb*NF5z@ns{ zf=#QD9)s;|?v76OCBZ*7s&z5kza-b{F@?lFt=z^k{0W^S5{>FMJ%VUazfXB>^|XYW zrb-K%-qUCuR9NANlExk(9PVM)f8vF2>_GQ263i_%Xyo3Ycn(~Rr7wTtEK+X^?O$)eD6^NvyOvgbJ!tD!1IajBc&J5TKv5d#d6L}^>od|En6O5!XHuY9dQl!og6)sQ9%wG}vd@lp zg-Ca0qsoP2yAuU@;syiSY;N^WJ$TgNbk9Mqh2Gpt;dF~D9%KQa7REu(AWw>$ZNry5b%Ydz#+~{ zf}5c}-vE;3&yJ%3K~|giuf^-M{1dghd;4{Id}x+w_IDt7fl=4K2SxHNI14JG!Cps> z%_3(nI~-xW&}%ZCjwR-B+e6w?yT(UZ^|=bke$lb@+8l#UXFX#%k>Q?C`3YgzCRjpX zrgQ`PbajFDFAT+2wMr|Dhue-Tng6(>(-^0Vj>2Wn|2)1^-9SJj01yDs(9qzo^n(E9 zb$QT;atREZ;Q^f__?iX)bf#PkND=CudMDDM312t?a9^(feQ*MkwPmN_ZON|2Uc78JPnB{0}%3C}*`A`ClLF|L)EnG`m_6 zc<-kSueS(zd9DZL?V0bBVXI*R-n-z!fqHnxA`c)f)_dqf4Il8YIplw9mzM($QA06n zDUt8p)1Vx+gh&~@0O(g<4){J${5Aao09YP`-w?QiKv4LBd35J^1N~V`gcup)0DOJNAD% zN${RO&;zza^rI{9Z*J+{zk6I019-fiZ>GzO+Ht=qz z@BSz~hc`Yy_{qlhAoy^`J@>YS2l)44`M+t}1)d)4f77GBt{dD*3{{R~zIOhNW 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% From 468309f8866b9ecfdbcb84cfd64681268d8fec48 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Tue, 10 Nov 2026 19:35:13 +0200 Subject: [PATCH 75/75] build: sealite 1.0.1, native libraries half the size --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index adc675c2..b076379c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,7 @@ 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.0" +sealite = "1.0.1" sqliteKtx = "2.7.1" iconsLucide = "1.1.0" # The OS secret stores behind :core:keystore on the desktop: DPAPI and the Keychain through JNA, the freedesktop