From b1faa30d0396943a74f16a7784ecfbf0e810b87a Mon Sep 17 00:00:00 2001 From: pasichdev Date: Fri, 25 Sep 2026 11:00:13 +0300 Subject: [PATCH 1/4] fix: close the remaining 2.0.0 security issues - The PIN is a wiped CharArray on the PIN change screen, in the settings checks and in backup re-authentication (#21) - One biometric prompt at a time on the lock screen and the settings switch; a prompt lost to activity recreation no longer leaves the fingerprint button stuck, and a late answer is dropped (#7, #13) - Tests: Back on the lock screen, recreation during a prompt, FLAG_SECURE after onCreate and recreation, and a scan that fails on share intents, notifications, alarms or clipboard writes outside SensitiveClip (#13, #15, #16) - Docs: the auto-lock delay in SECURITY.md, the FAQ, the store listing and README (#9); note links documented as a deliberate exception (#25); biometric toggle policy; README crypto overview brought up to date --- README.md | 22 +-- SECURITY.md | 25 +++- .../encly/core/security/SeedPhraseManager.kt | 2 - .../presentation/screen/LockFormState.kt | 32 ++--- .../screen/PinCodeConfigScreen.kt | 88 ++++++++---- .../screen/backup/BackupDialogs.kt | 19 +-- .../presentation/screen/pincode/PinBuffer.kt | 51 +++++++ .../presentation/viewmodel/BackupFlows.kt | 22 ++- .../viewmodel/BiometricPromptGuard.kt | 47 +++++++ .../presentation/viewmodel/LockViewModel.kt | 45 +++--- .../viewmodel/SecuritySettingsViewModel.kt | 55 ++++++-- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-pl/strings.xml | 2 +- app/src/main/res/values-pt/strings.xml | 2 +- app/src/main/res/values-uk/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- .../encly/MainActivitySecureWindowTest.kt | 100 +++++++++++++ .../presentation/viewmodel/BackupFlowsTest.kt | 4 +- .../viewmodel/BackupViewModelTest.kt | 34 +++-- .../viewmodel/LockViewModelTest.kt | 51 ++++++- .../SecuritySettingsViewModelTest.kt | 90 +++++++++++- .../encly/release/NoPlaintextExportTest.kt | 99 +++++++++++++ .../com/pasich/encly/testutil/MockActivity.kt | 27 ++++ .../ui/screens/LockScreenRecreationTest.kt | 132 ++++++++++++++++++ .../pasich/encly/ui/screens/LockScreenTest.kt | 106 +++++++++++++- .../encly/ui/screens/SecurityScreensTest.kt | 48 ++++++- .../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 +- 39 files changed, 986 insertions(+), 149 deletions(-) create mode 100644 app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt create mode 100644 app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt create mode 100644 app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt create mode 100644 app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt create mode 100644 app/src/test/java/com/pasich/encly/testutil/MockActivity.kt create mode 100644 app/src/test/java/com/pasich/encly/ui/screens/LockScreenRecreationTest.kt diff --git a/README.md b/README.md index f476bb5..90dcae1 100644 --- a/README.md +++ b/README.md @@ -100,18 +100,22 @@ a seed or PIN: 1. Onboarding creates a random 256-bit **DEK** (data-encryption key). 2. SQLCipher is opened with that DEK. -3. The mandatory PIN is processed with **PBKDF2-HMAC-SHA256 (600k)** and a random salt. - The resulting KEK wraps the DEK using **AES-256-GCM**. Encly does not store a PIN hash. +3. The mandatory PIN is processed with **PBKDF2-HMAC-SHA256 (600k)** and a random salt, + and mixed (HKDF) with an HMAC from a non-exportable **Android Keystore** key (StrongBox + when the phone has one), so PIN guesses can only run on this device. The resulting KEK + wraps the DEK using **AES-256-GCM**. Encly does not store a PIN hash. 4. If biometrics are enabled, the same DEK gets a second AES-GCM slot protected by an auth-per-use AndroidKeyStore key. Unwrapping requires `BiometricPrompt.CryptoObject` with `BIOMETRIC_STRONG`. -5. In user-managed recovery mode, a 12-word BIP39 seed derives a recovery KEK and wraps - the same DEK in a separate AES-GCM recovery slot. -6. When Encly goes to the background, SQLCipher is closed and Encly's in-memory DEK copy - is zeroized. The next entry must unwrap the DEK again. - -The auto-managed onboarding option deliberately has **no recovery seed**. Losing the PIN -and local unlock material in that mode makes the encrypted database unrecoverable. +5. With a recovery phrase (offered during setup, or later in Settings → Security), a 12-word + BIP39 seed derives a recovery KEK and wraps the same DEK in a separate AES-GCM recovery + slot. The same words are the only key to encrypted backups. +6. After Encly leaves the screen (after the auto-lock delay in Settings → Security, 15 s by + default, or at once when the screen turns off), SQLCipher is closed and Encly's in-memory + DEK copy is zeroized. The next entry must unwrap the DEK again. + +Skipping the recovery phrase leaves the vault with **no recovery seed** and no backups. +Losing the PIN in that case makes the encrypted database unrecoverable. The app also disables Android backup/device transfer for protected data and has no system notifications, plaintext note sharing, calendar export, or seed export; the only diff --git a/SECURITY.md b/SECURITY.md index 55de448..2cdedae 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -63,6 +63,14 @@ Biometric unlock is optional. A successful biometric prompt by itself is therefore insufficient to open the database. The authenticated cryptographic operation must release the DEK. +- The system screen lock (device PIN, pattern or password) is never accepted in place of a + biometric: the prompt allows `BIOMETRIC_STRONG` only, and falling back means Encly's own + PIN. +- Turning biometric unlock **on** needs a biometric prompt that wraps the DEK through the + new key's CryptoObject; turning it **off** needs a Class 3 biometric confirmation, and + deletes the slot and the key. Only one such prompt can be open at a time, and the switch + is disabled on devices without enrolled strong biometrics. + ### Recovery slot User-managed onboarding creates an optional BIP39 recovery slot. @@ -180,8 +188,14 @@ fileKey = HKDF-SHA256(ikm = backupRoot, salt = <32 random bytes per file>, in - The database is not considered committed until mandatory PIN setup succeeds. - Interrupted first-run setup restarts onboarding and discards incomplete vault slots. - When the app backgrounds, SQLCipher is closed and Encly's in-memory DEK copy is - zeroized. ViewModels holding decrypted content (notes list, tasks, editor, a decrypted - backup, new recovery words) drop it at that moment, not only when the UI resumes. + zeroized, after the user's auto-lock delay (Settings → Security: immediately, 15 s by + default, 30 s, 1 min or 2 min; measured on `elapsedRealtime`, so deep sleep counts). The + screen turning off or the device locking closes it at once, whatever the delay. ViewModels + holding decrypted content (notes list, tasks, editor, a decrypted backup, new recovery + words) drop it at that moment, not only when the UI resumes. +- Within the delay the vault stays open in the background: the DEK and decrypted content are + in memory, and the recents thumbnail is still blocked by `FLAG_SECURE`. Choose + "Immediately" to close it the moment Encly leaves the screen. - The next foreground entry must unwrap the DEK again through PIN, biometric, or recovery. - A process started with a committed, closed vault starts in the locked state, and a central @@ -262,6 +276,13 @@ The app requests no `INTERNET` permission and performs no analytics or sync. - The About screen offers links (privacy policy, GitHub issues, developer email and, only in the `play` flavor, the Play Store listing). They open in another app **only after an explicit tap**; Encly itself sends nothing. +- **Links in notes are a deliberate exception** to "nothing leaves the vault". A link block + opens only from its sheet, which first shows the full address, after an explicit tap on + "Open", through the system chooser. Only `http`, `https` and `mailto` are saved or opened; + addresses with user info, backslashes, whitespace or control characters are refused, and + hosts are shown in punycode, so the card cannot name a different host than the one that + opens. The receiving app (a browser, a mail client) then sees that one address. Encly + fetches no previews: a link card is built from the stored address alone. ## Threat model 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 466742e..ae4e4cb 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 @@ -112,8 +112,6 @@ class SeedPhraseManager @Inject constructor(private val store: VaultStore) { return !recoveryEnabled || hasRecoverySeed() } - fun isUserManuallyCreatedKeyByDecryption(): Boolean = hasRecoverySeed() - @Synchronized fun copyBootstrapKey(): ByteArray? = bootstrapDek?.copyOf() diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt b/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt index 786b550..5f7163a 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/LockFormState.kt @@ -8,9 +8,8 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH -import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.RecoveryPhraseState +import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.lockoutSecondsLeft import com.pasich.encly.presentation.viewmodel.PinUnlockResult import com.pasich.encly.presentation.viewmodel.SeedUnlockResult @@ -26,15 +25,11 @@ internal class LockFormState { /** The recovery-phrase form is shown instead of the PIN pad. */ var useRecovery by mutableStateOf(false) - /** - * The digits typed so far live in a CharArray that is wiped when taken or cleared, never - * in a String: only their count is Compose state. - */ - private val pinDigits = CharArray(PIN_LENGTH) + /** The digits typed so far, wiped when taken or cleared (see [PinBuffer]). */ + private val pinDigits = PinBuffer() /** How many PIN digits are typed. */ - var pinLength by mutableIntStateOf(0) - private set + val pinLength: Int get() = pinDigits.length @get:StringRes var pinError by mutableStateOf(null) @@ -57,27 +52,18 @@ internal class LockFormState { /** A digit on the keypad; the first digit of a new attempt clears the last error. */ fun typeDigit(digit: Int) { - if (pinLength >= PIN_LENGTH || lockedOut || digit !in 0..MAX_DIGIT) return + if (pinDigits.isFull || lockedOut || digit !in 0..MAX_DIGIT) return if (pinLength == 0) pinError = null - pinDigits[pinLength] = '0' + digit - pinLength++ + pinDigits.add(digit) } - fun deleteDigit() { - if (pinLength > 0) { - pinLength-- - pinDigits[pinLength] = '\u0000' - } - } + fun deleteDigit() = pinDigits.deleteLast() /** The full PIN, taken out of the form to be checked. The caller wipes the copy. */ - fun takePin(): CharArray = pinDigits.copyOf(pinLength).also { clearPin() } + fun takePin(): CharArray = pinDigits.take() /** Forgets the typed digits. */ - fun clearPin() { - SensitiveDataCleaner.clear(pinDigits) - pinLength = 0 - } + fun clearPin() = pinDigits.clear() fun onPinResult(result: PinUnlockResult, lockoutRemainingMillis: Long) { when (result) { 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 379eb96..27cadfd 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 @@ -22,6 +22,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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -39,11 +40,12 @@ import androidx.compose.ui.semantics.semantics import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH +import com.pasich.encly.core.security.SensitiveDataCleaner import com.pasich.encly.presentation.designsystem.EnclyIconTile import com.pasich.encly.presentation.designsystem.EnclyIcons import com.pasich.encly.presentation.designsystem.EnclyTopBar import com.pasich.encly.presentation.navigation.NavRoutes +import com.pasich.encly.presentation.screen.pincode.PinBuffer import com.pasich.encly.presentation.screen.pincode.PinEntry import com.pasich.encly.presentation.screen.pincode.PinEntryActions import com.pasich.encly.presentation.screen.pincode.PinEntryScaffold @@ -64,12 +66,17 @@ enum class PinAnimationState { /** * The three-step PIN change: current PIN (skipped after a recovery-phrase unlock), new PIN, - * confirmation. Holds the screen state so the composable only renders it. + * confirmation. Holds the screen state so the composable only renders it. The PINs are + * CharArrays that are wiped once used, never Strings (see [PinBuffer]). */ private class PinChangeState(val isReset: Boolean) { var step by mutableIntStateOf(if (isReset) 1 else 0) - var firstPin by mutableStateOf("") - var currentInput by mutableStateOf("") + + /** The new PIN typed at step 1, until the confirmation is compared with it. */ + private var firstPin: CharArray? = null + + /** The digits of the current step. */ + val input = PinBuffer() var errorText by mutableStateOf(null) var animationState by mutableStateOf(PinAnimationState.Entering) var lockoutSeconds by mutableLongStateOf(0L) @@ -80,32 +87,55 @@ private class PinChangeState(val isReset: Boolean) { /** Digits are refused while the current PIN is locked out. */ val keysEnabled: Boolean get() = !(step == 0 && lockoutSeconds > 0L) + /** A digit on the keypad; refused during a lockout. */ + fun typeDigit(digit: Int) { + if (keysEnabled) input.add(digit) + } + + /** The PIN of the current step is complete: check it, keep it, or compare it. */ fun onPinComplete(viewModel: SecuritySettingsViewModel) { - val pin = currentInput - currentInput = "" + // The ViewModel wipes what it is given; everything else is wiped here. + val pin = input.take() when (step) { 0 -> viewModel.verifyCurrentPin(pin) { ok -> onCurrentPinChecked(ok, viewModel) } 1 -> { + firstPin?.let(SensitiveDataCleaner::clear) firstPin = pin errorText = null step = 2 } - else -> if (pin == firstPin) { - viewModel.activationPinAuth(pin) { ok -> - if (ok) { - animationState = PinAnimationState.SuccessAnimation - } else { - restartNewPin(R.string.pin_update_failed) - } + else -> confirmNewPin(pin, viewModel) + } + } + + private fun confirmNewPin(pin: CharArray, viewModel: SecuritySettingsViewModel) { + val first = firstPin + firstPin = null + val matches = first != null && pin.contentEquals(first) + first?.let(SensitiveDataCleaner::clear) + if (matches) { + viewModel.activationPinAuth(pin) { ok -> + if (ok) { + animationState = PinAnimationState.SuccessAnimation + } else { + restartNewPin(R.string.pin_update_failed) } - } else { - restartNewPin(R.string.pin_mismatch_retry) } + } else { + SensitiveDataCleaner.clear(pin) + restartNewPin(R.string.pin_mismatch_retry) } } + /** Typed or kept digits do not outlive the screen. */ + fun clear() { + input.clear() + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null + } + private fun onCurrentPinChecked(ok: Boolean, viewModel: SecuritySettingsViewModel) { val lockout = lockoutSecondsLeft(viewModel.pinLockoutRemainingMillis()) errorText = null @@ -128,7 +158,8 @@ private class PinChangeState(val isReset: Boolean) { private fun restartNewPin(@StringRes error: Int) { errorText = error shakeKey++ - firstPin = "" + firstPin?.let(SensitiveDataCleaner::clear) + firstPin = null step = 1 } } @@ -149,11 +180,12 @@ fun PinCodeConfigScreen( pinState.lockoutSeconds = it } - LaunchedEffect(pinState.currentInput) { - if (pinState.currentInput.length == PIN_LENGTH && pinState.animationState == PinAnimationState.Entering) { + LaunchedEffect(pinState.input.length) { + if (pinState.input.isFull && pinState.animationState == PinAnimationState.Entering) { pinState.onPinComplete(securityViewModel) } } + DisposableEffect(pinState) { onDispose { pinState.clear() } } LaunchedEffect(pinState.animationState) { if (pinState.animationState == PinAnimationState.SuccessAnimation) { @@ -183,18 +215,14 @@ fun PinCodeConfigScreen( ) { currentStep -> MainPinContent( text = pinStepText(currentStep, pinState), - currentInput = pinState.currentInput, + entered = pinState.input.length, entry = PinEntryState( enabled = pinState.keysEnabled, shakeKey = pinState.shakeKey, compact = !pinState.isReset, ), - onInput = { - if (pinState.currentInput.length < PIN_LENGTH && pinState.keysEnabled) { - pinState.currentInput += it - } - }, - onDelete = { pinState.currentInput = pinState.currentInput.dropLast(1) }, + onInput = pinState::typeDigit, + onDelete = pinState.input::deleteLast, ) } } else { @@ -249,9 +277,9 @@ private class PinEntryState(val enabled: Boolean, val shakeKey: Int, val compact @Composable private fun MainPinContent( text: PinStepText, - currentInput: String, + entered: Int, entry: PinEntryState, - onInput: (String) -> Unit, + onInput: (Int) -> Unit, onDelete: () -> Unit, ) { val message = text.message @@ -263,12 +291,12 @@ private fun MainPinContent( compact = entry.compact, ) { PinEntry( - entered = currentInput.length, - error = message != null && currentInput.isEmpty(), + entered = entered, + error = message != null && entered == 0, shakeKey = entry.shakeKey, enabled = entry.enabled, actions = PinEntryActions( - onDigit = { onInput(it.toString()) }, + onDigit = onInput, onBackspace = onDelete, ), ) diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt index 7e8322a..9c15233 100644 --- a/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt +++ b/app/src/main/java/com/pasich/encly/presentation/screen/backup/BackupDialogs.kt @@ -18,6 +18,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 @@ -32,7 +33,6 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity import com.pasich.encly.R -import com.pasich.encly.core.security.PIN_LENGTH import com.pasich.encly.presentation.designsystem.CalloutTone import com.pasich.encly.presentation.designsystem.DialogAction import com.pasich.encly.presentation.designsystem.EnclyCallout @@ -52,6 +52,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 @@ -157,7 +158,9 @@ private fun MessageDialog( @Composable private fun ReauthDialog(actions: BackupDialogActions, step: BackupStep.Reauth) { val activity = LocalActivity.current as? FragmentActivity - var input by remember { mutableStateOf("") } + // 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), actions.reauth::pinLockoutRemainingMillis) { @@ -181,18 +184,16 @@ private fun ReauthDialog(actions: BackupDialogActions, step: BackupStep.Reauth) ) { PinEntry( entered = input.length, - error = error != null && input.isEmpty(), + error = error != null && input.length == 0, shakeKey = step.failures, enabled = !lockedOut, actions = PinEntryActions( onDigit = { digit -> - if (input.length < PIN_LENGTH && !lockedOut) input += digit - if (input.length == PIN_LENGTH) { - actions.reauth.submitPin(input) - input = "" - } + if (!lockedOut) input.add(digit) + // The flow wipes the PIN it is given. + if (input.isFull) actions.reauth.submitPin(input.take()) }, - onBackspace = { if (input.isNotEmpty()) input = input.dropLast(1) }, + onBackspace = input::deleteLast, onBiometric = if (step.biometric && activity != null) { { actions.reauth.withBiometric(activity) } } else { diff --git a/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt new file mode 100644 index 0000000..f578bee --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/screen/pincode/PinBuffer.kt @@ -0,0 +1,51 @@ +package com.pasich.encly.presentation.screen.pincode + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import com.pasich.encly.core.security.PIN_LENGTH +import com.pasich.encly.core.security.SensitiveDataCleaner + +/** + * The PIN digits typed on a keypad. They live in a CharArray that is wiped when taken or + * cleared, never in a String (a String cannot be wiped): only their count is Compose state. + */ +@Stable +internal class PinBuffer { + private val digits = CharArray(PIN_LENGTH) + + /** How many digits are typed. */ + var length by mutableIntStateOf(0) + private set + + val isFull: Boolean get() = length == PIN_LENGTH + + /** Adds [digit] (0-9); ignored once the PIN is complete. Returns whether it was added. */ + fun add(digit: Int): Boolean { + if (length >= PIN_LENGTH || digit !in 0..MAX_DIGIT) return false + digits[length] = '0' + digit + length++ + return true + } + + fun deleteLast() { + if (length > 0) { + length-- + digits[length] = '\u0000' + } + } + + /** The typed digits, taken out of the buffer, which is cleared. The caller wipes the copy. */ + fun take(): CharArray = digits.copyOf(length).also { clear() } + + /** Forgets the typed digits. */ + fun clear() { + SensitiveDataCleaner.clear(digits) + length = 0 + } + + private companion object { + const val MAX_DIGIT = 9 + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BackupFlows.kt index dae6751..1c8afb4 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 @@ -166,17 +166,25 @@ class ReauthFlow( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() /** - * During a PIN lockout nothing is verified (every attempt would be refused, even the right - * PIN), so no "wrong PIN" is shown either: the dialog shows the remaining lockout instead. + * Checks [pin], which is wiped. During a PIN lockout nothing is verified (every attempt + * would be refused, even the right PIN), so no "wrong PIN" is shown either: the dialog shows + * the remaining lockout instead. */ - fun submitPin(pin: String) { - val step = state.step as? BackupStep.Reauth ?: return - if (pinLockoutRemainingMillis() > 0L) { - state.go(step.copy(error = null)) + fun submitPin(pin: CharArray) { + val step = state.step as? BackupStep.Reauth + if (step == null || pinLockoutRemainingMillis() > 0L) { + SensitiveDataCleaner.clear(pin) + if (step != null) state.go(step.copy(error = null)) return } state.launchBusy { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPin(pin.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.verifyPin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (ok) { onAuthenticated(step.action) } else { diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt new file mode 100644 index 0000000..56cf797 --- /dev/null +++ b/app/src/main/java/com/pasich/encly/presentation/viewmodel/BiometricPromptGuard.kt @@ -0,0 +1,47 @@ +package com.pasich.encly.presentation.viewmodel + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +/** + * At most one biometric prompt at a time, for a ViewModel that outlives its activity. + * + * A second request while a prompt is open is ignored: a double tap must not stack prompts. The + * guard is released by the prompt's answer, or when the activity that showed it is destroyed. + * androidx.biometric drops the answer of a prompt whose activity is gone (BiometricPrompt resets + * its callback on ON_DESTROY), so without that a rotation during a prompt would leave the guard + * closed for good and the ViewModel, which survives the rotation, could never prompt again. + * + * Main thread only, as the prompts themselves. + */ +internal class BiometricPromptGuard { + private var current: Any? = null + + val inFlight: Boolean get() = current != null + + /** + * Runs [prompt] unless one is already open. [prompt] calls the `release` it is given with + * its answer; `release` returns false when that answer is stale (the guard was released by + * the activity's end meanwhile, and a newer prompt may be open), and the caller then drops it. + */ + fun launch(host: LifecycleOwner, prompt: (release: () -> Boolean) -> Unit) { + // A destroyed activity can show no prompt, and would never release the guard. + if (current != null || host.lifecycle.currentState == Lifecycle.State.DESTROYED) return + val token = Any() + current = token + val observer = object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + owner.lifecycle.removeObserver(this) + if (current === token) current = null + } + } + host.lifecycle.addObserver(observer) + prompt { + host.lifecycle.removeObserver(observer) + val mine = current === token + if (mine) current = null + mine + } + } +} diff --git a/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt b/app/src/main/java/com/pasich/encly/presentation/viewmodel/LockViewModel.kt index a1204c2..d8e3292 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 @@ -39,8 +39,11 @@ class LockViewModel @Inject constructor( private val _busy = MutableStateFlow(false) val busy: StateFlow = _busy.asStateFlow() - @Volatile - private var biometricInFlight = false + /** The open biometric prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + + /** A biometric prompt is open; a new request is ignored meanwhile. */ + val biometricInFlight: Boolean get() = biometricPrompt.inFlight fun strategy(): AuthStrategy = securityManager.authStrategy() @@ -89,24 +92,32 @@ class LockViewModel @Inject constructor( } } + /** + * Unlocks with the biometric slot. Ignored while a prompt is open; an answer that arrives + * after the prompt's activity was destroyed (the screen it would report to is gone) is dropped + * and its key wiped. + */ fun authenticateBiometric(activity: FragmentActivity, onResult: (Boolean) -> Unit) { - if (biometricInFlight) return - biometricInFlight = true - securityManager.requestBiometricKey(activity) { dek -> - biometricInFlight = false - if (dek == null) { - onResult(false) - return@requestBiometricKey - } - launchUnlock(onResult) { - val ok = withContext(Dispatchers.IO) { - try { - securityManager.unlockWithRawKey(dek) - } finally { - SensitiveDataCleaner.clear(dek) + biometricPrompt.launch(activity) { release -> + securityManager.requestBiometricKey(activity) { dek -> + if (!release()) { + dek?.let(SensitiveDataCleaner::clear) + return@requestBiometricKey + } + if (dek == null) { + onResult(false) + return@requestBiometricKey + } + launchUnlock(onResult) { + val ok = withContext(Dispatchers.IO) { + try { + securityManager.unlockWithRawKey(dek) + } finally { + SensitiveDataCleaner.clear(dek) + } } + publish(ok, ok, false) } - publish(ok, ok, false) } } } 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 fc2759d..556b770 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 @@ -11,6 +11,7 @@ import com.pasich.encly.core.security.AutoLockDelay import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager +import com.pasich.encly.core.security.SensitiveDataCleaner import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -21,6 +22,7 @@ import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel +@Suppress("TooManyFunctions") // One small entry point per setting on the Security page. class SecuritySettingsViewModel @Inject constructor( private val securityManager: SecurityManager, private val keyboardPrivacy: KeyboardPrivacy, @@ -30,6 +32,12 @@ class SecuritySettingsViewModel @Inject constructor( private val _uiState = MutableStateFlow(SecuritySettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** The open enrol or disable prompt, if any (see [BiometricPromptGuard]). */ + private val biometricPrompt = BiometricPromptGuard() + + /** An enrol or disable prompt is open; taps on the switch are ignored meanwhile. */ + val biometricInFlight: Boolean get() = biometricPrompt.inFlight + /** "Strict keyboard privacy" (see KeyboardPrivacy). */ val strictKeyboard: StateFlow = keyboardPrivacy.strict @@ -66,9 +74,16 @@ class SecuritySettingsViewModel @Inject constructor( fun pinLockoutRemainingMillis(): Long = securityManager.pinLockoutRemainingMillis() - fun verifyCurrentPin(target: String, onResult: (Boolean) -> Unit) { + /** Checks [pin] against the vault; [pin] is wiped. */ + fun verifyCurrentPin(pin: CharArray, onResult: (Boolean) -> Unit) { viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.verifyPin(target.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.verifyPin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (!ok) { _uiState.value = _uiState.value.copy(error = UiText.of(R.string.pin_current_wrong)) } @@ -76,9 +91,16 @@ class SecuritySettingsViewModel @Inject constructor( } } - fun activationPinAuth(target: String, onResult: (Boolean) -> Unit = {}) { + /** Makes [pin] the vault's new PIN; [pin] is wiped. */ + fun activationPinAuth(pin: CharArray, onResult: (Boolean) -> Unit = {}) { viewModelScope.launch { - val ok = withContext(Dispatchers.Default) { securityManager.configurePin(target.toCharArray()) } + val ok = withContext(Dispatchers.Default) { + try { + securityManager.configurePin(pin) + } finally { + SensitiveDataCleaner.clear(pin) + } + } if (ok) { _uiState.value = _uiState.value.copy(authType = AuthType.PIN) } else { @@ -91,19 +113,28 @@ class SecuritySettingsViewModel @Inject constructor( /** * Enabling biometrics performs the auth-bound CryptoObject enrollment itself. * Disabling an existing biometric slot requires a fresh strong-biometric confirmation. + * Taps while either prompt is open are ignored: a second enrolment would replace the key the + * first one is wrapping. */ fun toggleBiometric(activity: FragmentActivity, enable: Boolean) { - if (enable) { - securityManager.enrollBiometric(activity) { ok -> - _uiState.value = _uiState.value.copy( - biometricEnable = ok && securityManager.isBiometricEnabled(), - error = if (ok) null else UiText.of(R.string.biometric_enroll_failed), - ) - } - return + biometricPrompt.launch(activity) { release -> + if (enable) enrollBiometric(activity, release) else disableBiometric(activity, release) } + } + + private fun enrollBiometric(activity: FragmentActivity, release: () -> Boolean) { + securityManager.enrollBiometric(activity) { ok -> + release() + _uiState.value = _uiState.value.copy( + biometricEnable = ok && securityManager.isBiometricEnabled(), + error = if (ok) null else UiText.of(R.string.biometric_enroll_failed), + ) + } + } + private fun disableBiometric(activity: FragmentActivity, release: () -> Boolean) { securityManager.confirmBiometric(activity) { confirmed -> + release() if (confirmed) { securityManager.disableBiometric() _uiState.value = _uiState.value.copy(biometricEnable = false) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b288fb1..b0e0d43 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -108,7 +108,7 @@ Inhalt konnte nicht geladen werden - Encly speichert deine aktuellen Änderungen, schließt die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Wenn du zurückkehrst, musst du erneut entsperren. + Encly speichert deine Änderungen und schließt nach der unter Einstellungen → Sicherheit gewählten Verzögerung (standardmäßig 15 Sekunden) die verschlüsselte Datenbank und löscht den Sitzungsschlüssel. Bildschirm aus oder Telefon sperren tut das sofort. Danach entsperrst du erneut. Was passiert, wenn ich die App verlasse? Nutze den Biometrie-Schalter unter Einstellungen → Sicherheit. Das Anlegen oder Entfernen des biometrischen Slots erfordert eine starke biometrische Authentifizierung. Wie aktiviere oder deaktiviere ich die Biometrie? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index dcf2d03..828ba95 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -108,7 +108,7 @@ No se pudo cargar el contenido - Encly guarda tus cambios actuales, cierra la base de datos cifrada y borra la clave de sesión. Al volver, tendrás que desbloquear de nuevo. + Encly guarda tus cambios y, tras el retraso elegido en Ajustes → Seguridad (15 segundos por defecto), cierra la base de datos cifrada y borra la clave de sesión. Apagar la pantalla o bloquear el teléfono lo hace al instante. Después, desbloqueas de nuevo. ¿Qué pasa cuando salgo de la aplicación? Usa el interruptor de biometría en Ajustes → Seguridad. Crear o eliminar la ranura biométrica requiere autenticación biométrica fuerte. ¿Cómo activo o desactivo la biometría? diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2cd5c59..ccd82a6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -108,7 +108,7 @@ Impossible de charger le contenu - Encly enregistre vos modifications en cours, ferme la base de données chiffrée et efface la clé de session. Vous devez déverrouiller à nouveau à votre retour. + Encly enregistre vos modifications puis, après le délai choisi dans Paramètres → Sécurité (15 secondes par défaut), ferme la base chiffrée et efface la clé de session. Éteindre l’écran ou verrouiller le téléphone le fait immédiatement. Vous déverrouillez ensuite à nouveau. Que se passe-t-il quand je quitte l\'application ? Utilisez l\'interrupteur de biométrie dans Paramètres → Sécurité. La création ou la suppression de l\'emplacement biométrique nécessite une authentification biométrique forte. Comment activer ou désactiver la biométrie ? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 79d04cc..ebc0a9d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -108,7 +108,7 @@ Impossibile caricare il contenuto - Encly salva le modifiche in corso, chiude il database crittografato e cancella la chiave di sessione. Al ritorno dovrai sbloccare di nuovo. + Encly salva le modifiche e, dopo il ritardo scelto in Impostazioni → Sicurezza (15 secondi per impostazione predefinita), chiude il database crittografato e cancella la chiave di sessione. Spegnere lo schermo o bloccare il telefono lo fa subito. Poi sblocchi di nuovo. Cosa succede quando esco dall\'app? Usa l\'interruttore della biometria in Impostazioni → Sicurezza. Creare o rimuovere lo slot biometrico richiede un\'autenticazione biometrica forte. Come attivo o disattivo la biometria? diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fd549cf..cbeb94b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -108,7 +108,7 @@ Kan de inhoud niet laden - Encly slaat je huidige wijzigingen op, sluit de versleutelde database en wist de sessiesleutel. Als je terugkomt, moet je opnieuw ontgrendelen. + Encly slaat je wijzigingen op en sluit na de vertraging die je in Instellingen → Beveiliging kiest (standaard 15 seconden) de versleutelde database en wist de sessiesleutel. Het scherm uitzetten of de telefoon vergrendelen doet dit meteen. Daarna ontgrendel je opnieuw. Wat gebeurt er als ik de app verlaat? Gebruik de biometrieschakelaar in Instellingen → Beveiliging. Voor het maken of verwijderen van het biometrische slot is sterke biometrische verificatie nodig. Hoe zet ik biometrie aan of uit? diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 7b565d6..3e01d4d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -108,7 +108,7 @@ Nie udało się wczytać treści - Encly zapisuje bieżące zmiany, zamyka zaszyfrowaną bazę danych i usuwa klucz sesji. Po powrocie trzeba ponownie odblokować aplikację. + Encly zapisuje zmiany i po opóźnieniu wybranym w Ustawienia → Bezpieczeństwo (domyślnie 15 sekund) zamyka zaszyfrowaną bazę i czyści klucz sesji. Wyłączenie ekranu lub zablokowanie telefonu robi to od razu. Potem odblokowujesz ponownie. Co się dzieje, gdy wychodzę z aplikacji? Użyj przełącznika biometrii w Ustawienia → Bezpieczeństwo. Utworzenie lub usunięcie slotu biometrycznego wymaga silnego uwierzytelnienia biometrycznego. Jak włączyć lub wyłączyć biometrię? diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e07889a..cec5a1c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -108,7 +108,7 @@ Não foi possível carregar o conteúdo - O Encly salva suas alterações atuais, fecha o banco de dados criptografado e apaga a chave da sessão. Ao voltar, você precisa desbloquear novamente. + O Encly salva as suas alterações e, após o atraso escolhido em Configurações → Segurança (15 segundos por padrão), fecha o banco de dados criptografado e apaga a chave de sessão. Desligar a tela ou bloquear o telefone faz isso na hora. Depois, você desbloqueia de novo. O que acontece quando saio do app? Use a chave de biometria em Configurações → Segurança. Criar ou remover o slot biométrico exige autenticação biométrica forte. Como ativo ou desativo a biometria? diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7e58fe6..2a326cd 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -108,7 +108,7 @@ Не вдалося завантажити вміст - Encly зберігає поточні зміни, закриває зашифровану базу та очищає сесійний ключ. Після повернення потрібне повторне розблокування. + Encly зберігає зміни й після затримки, вибраної в Налаштування → Безпека (за замовчуванням 15 секунд), закриває зашифровану базу та очищає сесійний ключ. Вимкнення екрана чи блокування телефона робить це одразу. Після цього потрібно розблокувати знову. Що відбувається після згортання застосунку? У Налаштування → Безпека використайте перемикач біометрії. Щоб увімкнути або вимкнути розблокування біометрією, потрібно підтвердити його надійною біометрією (наприклад, відбитком пальця). Як увімкнути або вимкнути біометрію? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b30c10f..6b75428 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,7 +124,7 @@ Couldn\'t load the content - Encly saves your current changes, closes the encrypted database and wipes the session key. You need to unlock again when you come back. + Encly saves your changes and, after the delay chosen in Settings → Security (15 seconds by default), closes the encrypted database and wipes the session key. Turning off the screen or locking the phone does this at once. After that you unlock again. What happens when I leave the app? Use the biometrics switch in Settings → Security. Creating or removing the biometric slot requires strong biometric authentication. How do I turn biometrics on or off? diff --git a/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt b/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt new file mode 100644 index 0000000..a8209a4 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/MainActivitySecureWindowTest.kt @@ -0,0 +1,100 @@ +package com.pasich.encly + +import android.Manifest +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.view.WindowManager +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Every window of the app is FLAG_SECURE (no screenshots, recordings, casting or recents + * thumbnails of notes), from onCreate on, before any frame, and again for the new instance + * after a recreation (rotation, language or theme change). + * + * The flags are read the moment onCreate returns (onActivityPostCreated), before the window is + * attached and Compose runs: AppTheme sets the flag again later, and must not hide a missing one + * in onCreate. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MainActivitySecureWindowTest { + private val application: Application get() = ApplicationProvider.getApplicationContext() + + /** The window flags of each MainActivity instance right after its onCreate. */ + private val flagsAfterCreate = mutableListOf>() + + private val recorder = object : Application.ActivityLifecycleCallbacks { + override fun onActivityPostCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is MainActivity) flagsAfterCreate += activity to activity.window.attributes.flags + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + + @Before + fun setUp() { + // Declared in the manifest and granted at install; Robolectric grants nothing by itself. + shadowOf(application).grantPermissions(Manifest.permission.HIDE_OVERLAY_WINDOWS) + application.registerActivityLifecycleCallbacks(recorder) + } + + @After + fun tearDown() { + application.unregisterActivityLifecycleCallbacks(recorder) + } + + @Test + fun theWindowIsSecureRightAfterOnCreate() { + val controller = Robolectric.buildActivity(MainActivity::class.java).create() + try { + assertEquals(1, flagsAfterCreate.size) + assertSecure(flagsAfterCreate.single().second) + assertSecure(controller.get().window.attributes.flags) + } finally { + controller.destroy() + } + } + + @Test + fun theWindowIsSecureAgainAfterARecreation() { + val controller = Robolectric.buildActivity(MainActivity::class.java).setup() + try { + val first = controller.get() + + controller.recreate() + + val second = controller.get() + assertNotSame(first, second) + assertEquals(listOf(first, second), flagsAfterCreate.map { it.first }) + flagsAfterCreate.forEach { (_, flags) -> assertSecure(flags) } + assertSecure(second.window.attributes.flags) + } finally { + controller.pause().stop().destroy() + } + } + + private fun assertSecure(flags: Int) { + assertTrue( + "FLAG_SECURE missing from the window flags ${Integer.toHexString(flags)}", + flags and WindowManager.LayoutParams.FLAG_SECURE != 0, + ) + } +} 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 692ced3..2960f8a 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 @@ -132,7 +132,7 @@ class BackupFlowsTest { fun aWrongPinNeverSealsTheVault() { viewModel.start(BackupAction.EXPORT) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitFor { (it as? BackupStep.Reauth)?.error != null } verify(security, never()).copyBackupRootKey() @@ -438,7 +438,7 @@ class BackupFlowsTest { private fun startAndAuthenticate(action: BackupAction) { viewModel.start(action) - viewModel.reauthFlow.submitPin(PIN) + viewModel.reauthFlow.submitPin(PIN.toCharArray()) } private fun export() { 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 3055219..d07486a 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -55,7 +56,7 @@ class BackupViewModelTest { fun theRightPinDuringALockoutIsNotReportedAsWrong() { `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) val step = viewModel.uiState.value.step as BackupStep.Reauth assertNull(step.error) @@ -63,11 +64,26 @@ class BackupViewModelTest { verify(security, never()).verifyPin(anyCharArray()) } + @Test + fun theSubmittedPinIsWipedWhetherItIsCheckedOrNot() { + `when`(security.pinLockoutRemainingMillis()).thenReturn(30_000L) + val refused = "123456".toCharArray() + viewModel.reauthFlow.submitPin(refused) + assertArrayEquals(CharArray(refused.size), refused) + + `when`(security.pinLockoutRemainingMillis()).thenReturn(0L) + `when`(security.verifyPin(anyCharArray())).thenReturn(false) + val checked = "000000".toCharArray() + viewModel.reauthFlow.submitPin(checked) + waitForStep { (it as? BackupStep.Reauth)?.error != null } + assertArrayEquals(CharArray(checked.size), checked) + } + @Test fun aWrongPinOutsideALockoutIsReported() { `when`(security.verifyPin(anyCharArray())).thenReturn(false) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.error != null } assertEquals(R.string.lock_wrong_pin, (viewModel.uiState.value.step as BackupStep.Reauth).error) @@ -77,7 +93,7 @@ class BackupViewModelTest { fun theRightPinOutsideALockoutProceeds() { `when`(security.verifyPin(anyCharArray())).thenReturn(true) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.PickImportFile } } @@ -89,7 +105,7 @@ class BackupViewModelTest { `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it is BackupStep.ShowNewPhrase } assertEquals(WORDS.split(' '), (viewModel.uiState.value.step as BackupStep.ShowNewPhrase).words) @@ -99,9 +115,9 @@ class BackupViewModelTest { fun eachWrongPinCountsAFailureSoTheDotsShakeAgain() { `when`(security.verifyPin(anyCharArray())).thenReturn(false) - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.failures == 1 } - viewModel.reauthFlow.submitPin("000000") + viewModel.reauthFlow.submitPin("000000".toCharArray()) waitForStep { (it as? BackupStep.Reauth)?.failures == 2 } } @@ -112,7 +128,7 @@ class BackupViewModelTest { `when`(security.hasRecoverySeed()).thenReturn(true) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.Idle } assertEquals(BackupMessage.Text(R.string.backup_phrase_exists), viewModel.uiState.value.message) @@ -124,7 +140,7 @@ class BackupViewModelTest { `when`(security.hasRecoverySeed()).thenReturn(false) `when`(security.generateMnemonicCode()).thenReturn(WORDS.toCharArray()) viewModel.start(BackupAction.CREATE_PHRASE) - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it is BackupStep.ShowNewPhrase } viewModel.phraseFlow.writtenDown() @@ -143,7 +159,7 @@ class BackupViewModelTest { viewModel.eraseAllData() // not re-authenticated yet: ignored verify(security, never()).wipeAndReset() - viewModel.reauthFlow.submitPin("123456") + viewModel.reauthFlow.submitPin("123456".toCharArray()) waitForStep { it == BackupStep.Idle && viewModel.uiState.value.erased } verify(security).wipeAndReset() 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 cef4136..b533d86 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 @@ -10,6 +10,7 @@ import com.pasich.encly.core.security.AuthStrategy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.core.security.SessionLockManager import com.pasich.encly.core.security.VaultUnlockResult +import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyByteArray import com.pasich.encly.testutil.anyCallback @@ -30,6 +31,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.timeout @@ -46,7 +48,8 @@ class LockViewModelTest { private lateinit var security: SecurityManager private lateinit var sessionLock: SessionLockManager private lateinit var viewModel: LockViewModel - private val activity: FragmentActivity = mock(FragmentActivity::class.java) + private val host = MockActivity() + private val activity: FragmentActivity = host.activity @Before fun setUp() { @@ -195,6 +198,52 @@ class LockViewModelTest { viewModel.authenticateBiometric(activity) {} verify(security, times(1)).requestBiometricKey(eqValue(activity), anyCallback()) + assertTrue(viewModel.biometricInFlight) + } + + @Test + fun aRotationDuringThePromptReleasesItForTheRecreatedScreen() { + viewModel.authenticateBiometric(activity) {} + assertTrue(viewModel.biometricInFlight) + + // androidx.biometric drops the answer of a prompt whose activity is gone: without this the + // ViewModel, which outlives the rotation, would never prompt again. + host.destroy() + + assertFalse(viewModel.biometricInFlight) + val recreated = MockActivity().activity + viewModel.authenticateBiometric(recreated) {} + verify(security).requestBiometricKey(eqValue(recreated), anyCallback()) + } + + @Test + fun aLateAnswerFromTheDestroyedActivityIsDroppedAndItsKeyWiped() { + val answer = arrayOfNulls<(ByteArray?) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (ByteArray?) -> Unit + null + }.`when`(security).requestBiometricKey(eqValue(activity), anyCallback()) + val results = mutableListOf() + viewModel.authenticateBiometric(activity) { results += it } + host.destroy() + + val key = ByteArray(KEY_LENGTH) { 7 } + answer[0]!!(key) + + assertTrue(results.isEmpty()) + assertArrayEquals(ByteArray(KEY_LENGTH), key) + verify(security, never()).unlockWithRawKey(anyByteArray(), ArgumentMatchers.anyBoolean()) + } + + @Test + fun aDestroyedActivityGetsNoPrompt() { + host.destroy() + + viewModel.authenticateBiometric(activity) {} + + verify(security, never()).requestBiometricKey(eqValue(activity), anyCallback()) + assertFalse(viewModel.biometricInFlight) } @Test 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 af30531..bab8051 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 @@ -10,8 +10,10 @@ import com.pasich.encly.core.security.BiometricStatus import com.pasich.encly.core.security.KeyboardPrivacy import com.pasich.encly.core.security.SecurityManager import com.pasich.encly.testutil.InMemorySharedPreferences +import com.pasich.encly.testutil.MockActivity import com.pasich.encly.testutil.answerCallback import com.pasich.encly.testutil.anyCallback +import com.pasich.encly.testutil.anyCharArray import com.pasich.encly.testutil.eqValue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -23,14 +25,17 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull 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.times import org.mockito.Mockito.verify import org.mockito.Mockito.`when` @@ -39,7 +44,8 @@ import org.mockito.Mockito.`when` class SecuritySettingsViewModelTest { private lateinit var security: SecurityManager private lateinit var viewModel: SecuritySettingsViewModel - private val activity: FragmentActivity = mock(FragmentActivity::class.java) + private val host = MockActivity() + private val activity: FragmentActivity = host.activity @Before fun setUp() { @@ -118,6 +124,21 @@ class SecuritySettingsViewModelTest { assertEquals(UiText.of(R.string.pin_update_failed), viewModel.uiState.value.error) } + @Test + fun theCheckedAndTheNewPinAreWipedAfterUse() = runTest { + viewModel.uiState.first { it.loaded } + `when`(security.verifyPin(anyCharArray())).thenReturn(true) + `when`(security.configurePin(anyCharArray())).thenReturn(false) + + val current = "111111".toCharArray() + assertTrue(checkCurrent(current)) + assertArrayEquals(CharArray(current.size), current) + + val new = "222222".toCharArray() + assertFalse(activate(new)) + assertArrayEquals(CharArray(new.size), new) + } + @Test fun afterARecoveryUnlockTheNewPinIsSetWithoutTheOldOne() { `when`(security.canResetPinWithoutCurrent()).thenReturn(true) @@ -177,13 +198,76 @@ class SecuritySettingsViewModelTest { assertEquals(UiText.of(R.string.biometric_change_not_confirmed), viewModel.uiState.value.error) } - private suspend fun checkCurrent(pin: String): Boolean { + @Test + fun tapsWhileTheEnrolPromptIsOpenAreIgnored() = runTest { + viewModel.uiState.first { it.loaded } + // The prompt never answers: it stays open. + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = false) + + assertTrue(viewModel.biometricInFlight) + verify(security, times(1)).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, never()).confirmBiometric(eqValue(activity), anyCallback()) + } + + @Test + fun tapsWhileTheDisablePromptIsOpenAreIgnored() = runTest { + viewModel.uiState.first { it.loaded } + viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(activity, enable = false) + viewModel.toggleBiometric(activity, enable = true) + + verify(security, times(1)).confirmBiometric(eqValue(activity), anyCallback()) + verify(security, never()).enrollBiometric(eqValue(activity), anyCallback()) + verify(security, never()).disableBiometric() + } + + @Test + fun onceThePromptAnswersTheSwitchWorksAgain() = runTest { + viewModel.uiState.first { it.loaded } + val answer = arrayOfNulls<(Boolean) -> Unit>(1) + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + answer[0] = invocation.arguments.last() as (Boolean) -> Unit + null + }.`when`(security).enrollBiometric(eqValue(activity), anyCallback()) + + viewModel.toggleBiometric(activity, enable = true) + viewModel.toggleBiometric(activity, enable = true) + answer[0]!!(false) + + assertFalse(viewModel.biometricInFlight) + viewModel.toggleBiometric(activity, enable = true) + verify(security, times(2)).enrollBiometric(eqValue(activity), anyCallback()) + } + + @Test + fun aRotationDuringThePromptDoesNotLeaveTheSwitchStuck() = runTest { + viewModel.uiState.first { it.loaded } + viewModel.toggleBiometric(activity, enable = true) + assertTrue(viewModel.biometricInFlight) + + // androidx.biometric drops the answer of a prompt whose activity is gone. + host.destroy() + + assertFalse(viewModel.biometricInFlight) + val recreated = MockActivity().activity + viewModel.toggleBiometric(recreated, enable = true) + verify(security).enrollBiometric(eqValue(recreated), anyCallback()) + } + + private suspend fun checkCurrent(pin: String): Boolean = checkCurrent(pin.toCharArray()) + + private suspend fun checkCurrent(pin: CharArray): Boolean { val result = CompletableDeferred() viewModel.verifyCurrentPin(pin) { result.complete(it) } return result.await() } - private suspend fun activate(pin: String): Boolean { + private suspend fun activate(pin: String): Boolean = activate(pin.toCharArray()) + + private suspend fun activate(pin: CharArray): Boolean { val result = CompletableDeferred() viewModel.activationPinAuth(pin) { result.complete(it) } return result.await() diff --git a/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt new file mode 100644 index 0000000..de77ff6 --- /dev/null +++ b/app/src/test/java/com/pasich/encly/release/NoPlaintextExportTest.kt @@ -0,0 +1,99 @@ +package com.pasich.encly.release + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Plaintext never leaves the vault by a side door (issue #15): no share sheet, no FileProvider + * handing out files, no notifications or alarms that could show or schedule note content, and no + * clipboard write except through SensitiveClip, which marks the clip sensitive and clears it. + * The one way out is an encrypted backup written through the system document picker. + * + * Scans every source and resource under app/src/main; comments are skipped, so a comment may + * still name what is banned. + */ +class NoPlaintextExportTest { + + private val mainRoot = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } + + @Test + fun nothingInTheAppExportsPlaintext() { + val offenders = mainRoot.walkTopDown() + .filter { it.isFile && it.extension in SCANNED } + .flatMap { file -> + val path = file.relativeTo(mainRoot).invariantSeparatorsPath + file.readLines().mapIndexedNotNull { index, line -> + if (isComment(line)) return@mapIndexedNotNull null + BANNED.filter { (_, rule) -> rule.matches(path, line) } + .map { (name, _) -> "$path:${index + 1} ($name)" } + .takeIf { it.isNotEmpty() } + }.flatten() + } + .toList() + assertTrue( + "Plaintext must not leave the app except as an encrypted backup (see SECURITY.md): $offenders", + offenders.isEmpty(), + ) + } + + @Test + fun patternsCatchTheSideDoorsButNotTheirNeighbours() { + val caught = listOf( + "share sheet" to "Intent(Intent.ACTION_SEND).apply {", + "share sheet" to "val i = Intent(ACTION_SEND_MULTIPLE)", + "share sheet" to """""", + "FileProvider" to "FileProvider.getUriForFile(context, authority, file)", + "notification" to "NotificationCompat.Builder(context, CHANNEL)", + "notification" to """""", + "alarm" to "context.getSystemService(AlarmManager::class.java)", + "clipboard write" to "clipboard.setPrimaryClip(ClipData.newPlainText(\"\", text))", + "clipboard write" to "clipboard.setClipEntry(entry)", + "clipboard write" to "val clipboard = LocalClipboardManager.current", + ) + caught.forEach { (name, line) -> + assertTrue("$name: $line", BANNED.getValue(name).matches(OTHER_FILE, line)) + } + + // The support e-mail link (a mailto: ACTION_SENDTO) and SensitiveClip's own writes. + listOf( + OTHER_FILE to "val intent = Intent(Intent.ACTION_SENDTO).apply {", + SENSITIVE_CLIP to "clipboard.setPrimaryClip(clip)", + SENSITIVE_CLIP to "delegate.setClipEntry(clipEntry)", + ).forEach { (path, line) -> + assertFalse("$path: $line", BANNED.values.any { it.matches(path, line) }) + } + assertTrue(isComment(" // no FileProvider: nothing is shared")) + assertTrue(isComment("")) + } + + private class Rule(private val pattern: Regex, private val allowedIn: Set = emptySet()) { + fun matches(path: String, line: String): Boolean = + pattern.containsMatchIn(line) && allowedIn.none { path.endsWith(it) } + } + + private companion object { + val SCANNED = setOf("kt", "java", "xml") + const val SENSITIVE_CLIP = "java/com/pasich/encly/presentation/components/SensitiveClip.kt" + const val OTHER_FILE = "java/com/pasich/encly/presentation/screen/SomeScreen.kt" + + val BANNED = mapOf( + // ACTION_SEND and ACTION_SEND_MULTIPLE, in code or an intent filter; not ACTION_SENDTO. + "share sheet" to Rule(Regex("""ACTION_SEND(?!TO)\w*|android\.intent\.action\.SEND(?!TO)\w*""")), + "FileProvider" to Rule(Regex("""\bFileProvider\b""")), + "notification" to Rule(Regex("""\bNotificationCompat\b|\bPOST_NOTIFICATIONS\b""")), + "alarm" to Rule(Regex("""\bAlarmManager\b""")), + "clipboard write" to Rule( + Regex("""\bsetPrimaryClip\b|\bsetClipEntry\b|\bLocalClipboardManager\b"""), + allowedIn = setOf(SENSITIVE_CLIP), + ), + ) + + fun isComment(line: String): Boolean { + val code = line.trimStart() + return code.startsWith("//") || code.startsWith("*") || code.startsWith("/*") || + code.startsWith(" Pin["PIN wraps random DEK"] Pin --> Open["Open SQLCipher"] Open --> Session["Unlocked session"] - Session --> Background["App backgrounds"] + Session --> Background["Left the app past the auto-lock delay, or screen off"] Background --> Lock["Close DB and clear DEK"] Lock --> Unlock["PIN / biometric / recovery"] Unlock --> Open @@ -55,7 +56,8 @@ visible destination, an opaque shield covers the previous screen, so the first f returning never shows plaintext. An unlock that completes after the app left the foreground is closed again at once. Open task and tag editors save on pause, like the note editor, because the re-lock discards them. The note that was open when the app re-locked is recorded (`RelockReturn`) and -opened again after the unlock, with fresh ViewModels. +opened again after the unlock, with fresh ViewModels. Pages move on Material's shared X axis; +the full unlock reveal plays only on the first unlock after launch, later unlocks cross-fade. ## Encrypted backups @@ -94,7 +96,8 @@ flowchart LR end ``` -Onboarding offers "Restore from backup" next to the two vault types: it decrypts and validates +Onboarding offers "Restore from backup" next to the two setup choices (with a recovery phrase, +or PIN only): it decrypts and validates the file with the typed words, creates the vault with those words as its recovery seed, and `AuthSetupViewModel` writes the backup (replace, into the empty vault) right after the PIN setup opens the database and before onboarding is committed (`SecurityManager.openInitialVault` → From d494957fbab724f09ba90d74d9b0fdee340875f0 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Fri, 25 Sep 2026 11:12:38 +0300 Subject: [PATCH 3/4] docs(privacy): describe copying accurately Selected text can be copied too, and every copy is marked sensitive and cleared after about a minute. --- PRIVACY.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 6ca2fcb..1abec5c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -55,10 +55,10 @@ block, the address you wrote). ## Sharing Encly does not share, sell or transfer any data to anyone. It has no share flow for note -content, and the only thing it copies to the clipboard is a link from a note's link block, when -you choose to copy it (marked as sensitive on Android 13 and later, so it is hidden from the -clipboard preview). Apart from that, the only way data leaves the app is an encrypted backup file -that you export yourself (below). +content. It puts something on the clipboard only when you copy it yourself: text you select, or a +link from a note's link block. Every copy is marked as sensitive (on Android 13 and later it is +hidden from the clipboard preview) and cleared after about a minute. Apart from that, the only way +data leaves the app is an encrypted backup file that you export yourself (below). ## Encrypted backup file From 77da6c9e36386afeebd6420c6b10a84b7bc51b74 Mon Sep 17 00:00:00 2001 From: pasichdev Date: Fri, 25 Sep 2026 11:21:40 +0300 Subject: [PATCH 4/4] fix(editor): no outline around the block being edited The focused block drew a 2 dp primary frame. The caret marks focus now; the block padding is unchanged, so text does not move. --- .../encly/presentation/designsystem/Editor.kt | 13 +++---------- .../encly/presentation/editor/EditorBlocksHost.kt | 5 ++--- 2 files changed, 5 insertions(+), 13 deletions(-) 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 cbe3497..82aacb5 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 @@ -1,7 +1,6 @@ package com.pasich.encly.presentation.designsystem import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,9 +32,6 @@ import com.pasich.encly.ui.theme.EnclyTheme private val ToolSize = 44.dp private val ToolShape = RoundedCornerShape(14.dp) -/** Outline of the block being edited: 2 dp `primary`, radius 12, bleeding 8 dp into the gutter. */ -private val BlockOutlineShape = RoundedCornerShape(12.dp) - /** * 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). @@ -108,13 +104,10 @@ private val BlockBleed = 8.dp private val BlockInset = 4.dp /** - * The frame of one editor block: inner padding 4/8 and, while [active], a 2 dp outline in [color] - * with radius 12. The editor column sits [BlockBleed] inside the gutter, so text lines up with - * the title and the outline bleeds into the gutter (design spec §4.4). + * 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(active: Boolean, color: Color): Modifier = - (if (active) border(2.dp, color, BlockOutlineShape) else this) - .padding(horizontal = BlockBleed, vertical = BlockInset) +fun Modifier.editorBlockFrame(): Modifier = padding(horizontal = BlockBleed, vertical = BlockInset) /** A thin vertical rule between groups of toolbar buttons. */ @Composable diff --git a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt index 7951f06..10266ae 100644 --- a/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt +++ b/app/src/main/java/com/pasich/encly/presentation/editor/EditorBlocksHost.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -133,7 +132,7 @@ private fun EditorBlock( 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) - // Only for the outline of the block being edited; the focus callback below is unchanged. + // Whether the block is being edited: its placeholder shows then. var hasFocus by remember { mutableStateOf(false) } Box( @@ -150,7 +149,7 @@ private fun EditorBlock( !focusState.hasFocus -> callbacks.onFocusLost() } } - .editorBlockFrame(active = hasFocus && !isLocked, color = MaterialTheme.colorScheme.primary), + .editorBlockFrame(), ) { BlockContent( block = block,