diff options
Diffstat (limited to 'app/src/main/java')
107 files changed, 8223 insertions, 6829 deletions
diff --git a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt deleted file mode 100644 index 56ee8e0e..00000000 --- a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.data.password - -import app.passwordstore.data.passfile.Totp - -class FieldItem -private constructor( - val type: ItemType, - val label: String, - val value: String, - val action: ActionType, -) { - enum class ActionType { - COPY, - HIDE, - } - - enum class ItemType() { - USERNAME, - PASSWORD, - OTP, - FREEFORM, - } - - companion object { - fun createOtpField(label: String, totp: Totp): FieldItem { - return FieldItem( - ItemType.OTP, - label.format(totp.remainingTime.inWholeSeconds), - totp.value, - ActionType.COPY, - ) - } - - fun createPasswordField(label: String, password: String): FieldItem { - return FieldItem(ItemType.PASSWORD, label, password, ActionType.HIDE) - } - - fun createUsernameField(label: String, username: String): FieldItem { - return FieldItem(ItemType.USERNAME, label, username, ActionType.COPY) - } - - fun createFreeformField(label: String, content: String): FieldItem { - return FieldItem(ItemType.FREEFORM, label, content, ActionType.COPY) - } - } -} diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 82798a05..5d32108f 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -6,22 +6,16 @@ package app.passwordstore.data.password import android.content.Context import android.content.Intent -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.main.LaunchActivity import java.io.File -data class PasswordItem( - val name: String, - val parent: PasswordItem? = null, - val type: Char, - val file: File, - val rootDir: File, -) : Comparable<PasswordItem> { +data class PasswordItem(val name: String, val type: Char, val file: File, val rootDir: File) : + Comparable<PasswordItem> { val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") - val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString()) override fun equals(other: Any?): Boolean { return (other is PasswordItem) && (other.file == file) @@ -42,9 +36,7 @@ data class PasswordItem( /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("NAME", toString()) - intent.putExtra("FILE_PATH", file.absolutePath) - intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath) intent.action = LaunchActivity.ACTION_DECRYPT_PASS return intent } @@ -54,24 +46,12 @@ data class PasswordItem( const val TYPE_CATEGORY = 'c' const val TYPE_PASSWORD = 'p' - @JvmStatic - fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic fun newCategory(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_CATEGORY, file, rootDir) } - @JvmStatic fun newPassword(name: String, file: File, rootDir: File): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + return PasswordItem(name, TYPE_PASSWORD, file, rootDir) } } } diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt index 35c744a2..c9c4e928 100644 --- a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt +++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt @@ -13,14 +13,14 @@ import app.passwordstore.passgen.diceware.RandomIntGenerator import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.components.FragmentComponent +import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.qualifiers.ApplicationContext import java.io.InputStream import java.security.SecureRandom import javax.inject.Qualifier @Module -@InstallIn(FragmentComponent::class) +@InstallIn(ViewModelComponent::class) object DicewareModule { @Provides diff --git a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt index aa85d002..b7d32b71 100644 --- a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt +++ b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt @@ -11,9 +11,10 @@ import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent @Module -@InstallIn(ActivityComponent::class) +@InstallIn(ActivityComponent::class, ViewModelComponent::class) interface TotpModule { @Binds fun bindTotpFinder(impl: UriTotpFinder): TotpFinder } diff --git a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt deleted file mode 100644 index 57428ffb..00000000 --- a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.adapters - -import android.text.method.PasswordTransformationMethod -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R -import app.passwordstore.data.passfile.Totp -import app.passwordstore.data.password.FieldItem -import app.passwordstore.databinding.ItemFieldBinding -import app.passwordstore.ui.compose.R as composeR -import com.google.android.material.textfield.TextInputLayout - -class FieldItemAdapter( - private var fieldItemList: List<FieldItem>, - private val showPassword: Boolean, - private val copyTextToClipboard: (text: String?) -> Unit, -) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder { - val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return FieldItemViewHolder(binding.root, binding) - } - - override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) { - holder.bind(fieldItemList[position], showPassword, copyTextToClipboard) - } - - override fun getItemCount(): Int { - return fieldItemList.size - } - - fun updateOTPCode(totp: Totp, labelFormat: String) { - var otpItemPosition = -1 - fieldItemList = - fieldItemList.mapIndexed { position, item -> - if (item.type == FieldItem.ItemType.OTP) { - otpItemPosition = position - return@mapIndexed FieldItem.createOtpField(labelFormat, totp) - } - - return@mapIndexed item - } - - notifyItemChanged(otpItemPosition) - } - - class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : - RecyclerView.ViewHolder(itemView) { - - fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) { - with(binding) { - itemText.hint = fieldItem.label - itemTextContainer.hint = fieldItem.label - itemText.setText(fieldItem.value) - - when (fieldItem.action) { - FieldItem.ActionType.COPY -> { - itemTextContainer.apply { - endIconDrawable = - ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy) - endIconMode = TextInputLayout.END_ICON_CUSTOM - setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.transformationMethod = null - } - FieldItem.ActionType.HIDE -> { - itemTextContainer.apply { - endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - itemText.apply { - transformationMethod = - if (!showPassword) { - PasswordTransformationMethod.getInstance() - } else { - null - } - if (fieldItem.type == FieldItem.ItemType.PASSWORD) { - typeface = - ResourcesCompat.getFont( - binding.root.context, - composeR.font.jetbrainsmono_nl_regular, - ) - } - setOnClickListener { copyTextToClipboard(itemText.text.toString()) } - } - } - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt deleted file mode 100644 index 54be64d6..00000000 --- a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.adapters - -import android.text.SpannableString -import android.text.style.RelativeSizeSpan -import android.view.MotionEvent -import android.view.View -import androidx.appcompat.widget.AppCompatImageView -import androidx.appcompat.widget.AppCompatTextView -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter -import app.passwordstore.util.viewmodel.stableId -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.withContext - -open class PasswordItemRecyclerAdapter( - coroutineScope: CoroutineScope, - dispatcherProvider: DispatcherProvider, -) : - SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>( - R.layout.password_row_layout, - ::PasswordItemViewHolder, - coroutineScope, - dispatcherProvider, - PasswordItemViewHolder::bind, - ) { - - fun makeSelectable(recyclerView: RecyclerView) { - makeSelectable(recyclerView, ::PasswordItemDetailsLookup) - } - - override fun onItemClicked( - listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onItemClicked(listener) as PasswordItemRecyclerAdapter - } - - override fun onSelectionChanged( - listener: (selection: Selection<String>) -> Unit - ): PasswordItemRecyclerAdapter { - return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter - } - - class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - private val name: AppCompatTextView = itemView.findViewById(R.id.label) - private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) - private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator) - var itemDetails: ItemDetailsLookup.ItemDetails<String>? = null - - suspend fun bind(item: PasswordItem, dispatcherProvider: DispatcherProvider) { - val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = - if (parentPath.isNotEmpty()) { - "$parentPath\n$item" - } else { - "$item" - } - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - name.text = spannable - if (item.type == PasswordItem.TYPE_CATEGORY) { - folderIndicator.visibility = View.VISIBLE - val count = - withContext(dispatcherProvider.io()) { - item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 - } - childCount.visibility = if (count > 0) View.VISIBLE else View.GONE - childCount.text = "$count" - } else { - childCount.visibility = View.GONE - folderIndicator.visibility = View.GONE - } - itemDetails = - object : ItemDetailsLookup.ItemDetails<String>() { - override fun getPosition() = absoluteAdapterPosition - - override fun getSelectionKey() = item.stableId - } - } - } - - class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : - ItemDetailsLookup<String>() { - - override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { - val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null - return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 70525083..3f0fb5ef 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -8,24 +8,42 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.SharedPreferences import android.os.Build import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource import androidx.core.content.edit -import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.PasswordDialog +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath +import app.passwordstore.ui.entry.PassphraseDialog +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.asLog import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache import app.passwordstore.util.features.Features @@ -47,62 +65,157 @@ import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat +/** + * Decrypts one entry for the autofill service and returns the dataset to fill. Its transparent, + * FLAG_SECURE window shows only the "no keys" and passphrase dialogs. + */ @AndroidEntryPoint -class AutofillDecryptActivity : BasePGPActivity() { +class AutofillDecryptActivity : AppCompatActivity() { + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + @Inject lateinit var repository: CryptoRepository + @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var features: Features @Inject lateinit var passphraseCache: PGPPassphraseCache private lateinit var directoryStructure: DirectoryStructure + private lateinit var filePath: String + private lateinit var clientState: Bundle + private lateinit var action: AutofillAction private var clearCache = true - override fun onStart() { - super.onStart() - val filePath = + /** The screen lock prompt's outcome: the passphrase is cached only after a success. */ + private var auth: BiometricResult = BiometricResult.CanceledByUser + + /** Set once the flow started; reset after a key import, so the next window focus restarts it. */ + private var started = false + + /** Shows the "No keys imported" dialog. */ + private var noKeys by mutableStateOf(false) + + /** The recipients the passphrase dialog asks for; null while it is not shown. */ + private var passphraseFor by mutableStateOf<List<PGPIdentifier>?>(null) + + // The import's activity is still on top when its result arrives: the flow restarts on the next + // window focus, so the screen lock prompt is not shown (and evicted) behind it. + private val keyImport = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) started = false else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and + // recents. + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see + // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field + // again. Keep the flow's state in a ViewModel if this ever matters. + if (savedInstanceState != null) { + finish() + return + } + filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } finish() return } - val clientState = + clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } finish() return } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true) + action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match directoryStructure = AutofillPreferences.directoryStructure(this) logcat { action.toString() } - requireKeysExist { - if ( - features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this) - ) { - BiometricAuthenticator.authenticate( - this, - R.string.biometric_prompt_title_gpg_passphrase_cache, - ) { authResult -> - decrypt(filePath, clientState, action, authResult) - } - } else { - decrypt(filePath, clientState, action, BiometricResult.CanceledByUser) + setContent { + APSTheme { + if (noKeys) + AlertDialog( + onDismissRequest = { finish() }, + title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) }, + text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + noKeys = false + keyImport.launch( + MainActivity.setupIntent(this@AutofillDecryptActivity, SetupStart.PgpImport) + ) + } + ) { + Text(stringResource(R.string.button_label_import)) + } + }, + ) + val identifiers = passphraseFor + if (identifiers != null) + PassphraseDialog( + wrong = false, + cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), + clearCache = clearCache, + onSubmit = onSubmit@{ passphrase, clear -> + // Cleared before decrypting: a second submit in the same frame does nothing. + if (passphraseFor == null) return@onSubmit + passphraseFor = null + clearCache = clear + lifecycleScope.launch(dispatcherProvider.main()) { + decryptWithPassphrase(identifiers, passphrase, typed = true) + } + }, + onCancel = { finish() }, + ) } } } - private fun decrypt( - filePath: String, - clientState: Bundle, - action: AutofillAction, - authResult: BiometricResult, - ) { + // The flow waits for window focus, as LaunchActivity's prompt does: Android 14's SystemUI + // dismisses a screen lock prompt shown before its activity is on top. It runs once (again only + // after a key import); the old onStart start ran it on every return to this activity. + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus || started || isFinishing) return + started = true + start() + } + + /** Asks for a key import first while no PGP key is imported. */ + private fun start() { + if (isFinishing) return + lifecycleScope.launch { if (repository.hasKeys()) unlock() else noKeys = true } + } + + private fun unlock() { + if ( + features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this) + ) { + BiometricAuthenticator.authenticate( + this, + R.string.biometric_prompt_title_gpg_passphrase_cache, + ) { authResult -> + decrypt(authResult) + } + } else { + decrypt(BiometricResult.CanceledByUser) + } + } + + private fun decrypt(authResult: BiometricResult) { + auth = authResult val gpgIdentifiers = getPGPIdentifiers( getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString()) - ) ?: return + ) + ?: run { + finish() + return + } lifecycleScope.launch(dispatcherProvider.main()) { when (authResult) { // Internally handled by the prompt dialog @@ -111,8 +224,7 @@ class AutofillDecryptActivity : BasePGPActivity() { is BiometricResult.CanceledBySystem, is BiometricResult.CanceledByUser, is BiometricResult.Failure, - is BiometricResult.HardwareUnavailableOrDisabled -> - askPassphrase(filePath, gpgIdentifiers, clientState, action) + is BiometricResult.HardwareUnavailableOrDisabled -> askPassphrase(gpgIdentifiers) is BiometricResult.Success -> { /* clear passphrase cache on first use after application startup or if screen was off; also make sure to purge a stale cache after caching has been disabled via PGP settings */ @@ -127,56 +239,30 @@ class AutofillDecryptActivity : BasePGPActivity() { gpgIdentifiers.first(), ) if (cachedPassphrase != null) { - decryptWithPassphrase( - File(filePath), - gpgIdentifiers, - clientState, - action, - cachedPassphrase, - ) + decryptWithPassphrase(gpgIdentifiers, cachedPassphrase) } else { - askPassphrase(filePath, gpgIdentifiers, clientState, action) + askPassphrase(gpgIdentifiers) } } } } } - private suspend fun askPassphrase( - filePath: String, - identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, - ) { + private suspend fun askPassphrase(identifiers: List<PGPIdentifier>) { if (!repository.isPasswordProtected(identifiers)) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "") + decryptWithPassphrase(identifiers, password = "") return } - val dialog = - PasswordDialog.newInstance( - cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), - clearCache = clearCache, - ) - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> - if (key == PasswordDialog.PASSWORD_RESULT_KEY) { - val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!! - clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY) - lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, value) - } - } - } + passphraseFor = identifiers } + /** Caches [password] only when the user [typed] it, as the entry view does. */ private suspend fun decryptWithPassphrase( - filePath: File, identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, password: String, + typed: Boolean = false, ) { - val credentials = decryptCredential(filePath, password, identifiers) + val credentials = decryptCredential(File(filePath), password, identifiers, typed) if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -201,6 +287,7 @@ class AutofillDecryptActivity : BasePGPActivity() { file: File, password: String, identifiers: List<PGPIdentifier>, + typed: Boolean, ): Credentials? { runCatching { file.readBytes().inputStream() } .onFailure { e -> @@ -218,7 +305,9 @@ class AutofillDecryptActivity : BasePGPActivity() { .onSuccess { result -> return runCatching { runCatching { - if (features.isEnabled(EnablePGPPassphraseCache)) { + if ( + typed && cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth) + ) { passphraseCache.cachePassphrase(this, identifiers.first(), password) settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt new file mode 100644 index 00000000..acbacd40 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.EntryRow +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.list.ListRow +import app.passwordstore.ui.settings.PreferenceSwitch +import kotlinx.collections.immutable.ImmutableList + +/** + * The autofill picker: which site or app is being filled ([origin], as the user can check it), a + * search over the store's entries, the matching [rows] (null while the first search runs), and the + * switches for phishing-resistant search ([showStrict]: websites only), recording the pick as a + * match ([matchLabel]) and clearing earlier matches. [onPick] takes the entry's absolute path. + */ +@Composable +fun AutofillFilterScreen( + origin: String, + query: String, + rows: ImmutableList<ListRow>?, + showStrict: Boolean, + strict: Boolean, + matchLabel: String, + match: Boolean, + clear: Boolean, + onQueryChange: (String) -> Unit, + onStrictChange: (Boolean) -> Unit, + onMatchChange: (Boolean) -> Unit, + onClearChange: (Boolean) -> Unit, + onPick: (path: String) -> Unit, + modifier: Modifier = Modifier, +) { + val fillInto = stringResource(R.string.oreo_autofill_select_and_fill_into) + val listState = rememberLazyListState() + // As the old picker did: every new result starts at its best match. + LaunchedEffect(rows) { listState.scrollToItem(0) } + Surface(modifier.fillMaxSize()) { + Column(Modifier.padding(vertical = SpacingLarge)) { + Image( + painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.size(60.dp).align(Alignment.CenterHorizontally), + ) + Text( + buildAnnotatedString { + append(fillInto) + append("\n") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(origin) } + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge), + textAlign = TextAlign.Center, + ) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth().padding(SpacingLarge), + label = { Text(stringResource(R.string.action_search)) }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null) + }, + trailingIcon = + if (query.isNotEmpty()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search)) + } + } + } else null, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + ) + Box(Modifier.weight(1f).fillMaxWidth()) { + when { + rows == null -> Unit + rows.isEmpty() -> + Text( + stringResource(R.string.oreo_autofill_filter_no_results), + modifier = Modifier.padding(horizontal = SpacingLarge), + style = MaterialTheme.typography.titleMedium, + ) + else -> + // One lazy item per row: a cleared search lists the whole store. The surface wraps + // a short list and is capped by the box for a long one. + GroupedSurface(Modifier.fillMaxWidth()) { + LazyColumn(state = listState) { + items(rows, key = { it.path }) { row -> + EntryRow( + name = row.label, + subtitle = row.subtitle, + onClick = { onPick(row.path) }, + ) + } + } + } + } + } + if (showStrict) + PreferenceSwitch( + stringResource(R.string.oreo_autofill_strict_domain_search), + strict, + onStrictChange, + ) + PreferenceSwitch(matchLabel, match, onMatchChange) + PreferenceSwitch( + stringResource(R.string.oreo_autofill_matches_clear_existing), + clear, + onClearChange, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index b027657e..c607b81f 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -10,36 +10,36 @@ import android.content.Intent import android.content.IntentSender import android.os.Build import android.os.Bundle -import android.view.View import android.view.autofill.AutofillManager -import android.widget.TextView +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.underline -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.list.ListRow import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure import app.passwordstore.util.viewmodel.FilterMode import app.passwordstore.util.viewmodel.ListMode import app.passwordstore.util.viewmodel.SearchMode -import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel import com.github.androidpasswordstore.autofillparser.FormOrigin import dagger.hilt.android.AndroidEntryPoint +import java.io.File import javax.inject.Inject -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import logcat.LogPriority.ERROR import logcat.logcat @@ -85,11 +85,12 @@ class AutofillFilterView : AppCompatActivity() { } private lateinit var formOrigin: FormOrigin - private lateinit var directoryStructure: DirectoryStructure - private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) private val model: SearchableRepositoryViewModel by viewModels() + /** Set by the first tap on a row: a double tap must not start a second decryption. */ + private var picked = false + private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { @@ -100,7 +101,10 @@ class AutofillFilterView : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) + // The picker decides which entry fills the requesting app: drop touches under an overlay, as + // the + // publisher warning does. + window.decorView.filterTouchesWhenObscured = true setFinishOnTouchOutside(true) val params = window.attributes @@ -129,111 +133,77 @@ class AutofillFilterView : AppCompatActivity() { return } } - directoryStructure = AutofillPreferences.directoryStructure(this) - supportActionBar?.hide() - bindUI() - updateSearch() setResult(RESULT_CANCELED) - } - private fun bindUI() { - with(binding) { - rvPassword.apply { - adapter = - SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder, - lifecycleScope, - dispatcherProvider, - ) { item, _ -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { - "At least one of identifier and accountPart should always be non-null" - } - title.text = - if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - .onItemClicked { _, item -> decryptAndFill(item) } - layoutManager = LinearLayoutManager(context) - } - search.apply { - val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) - setText(initialSearch, TextView.BufferType.EDITABLE) - addTextChangedListener { updateSearch() } - } - origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") - bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } - } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = formOrigin is FormOrigin.Web - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = - getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext), + val isWeb = formOrigin is FormOrigin.Web + val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + val root = PasswordRepository.getRepositoryDirectory() + val directoryStructure = AutofillPreferences.directoryStructure(this) + // ponytail: cold flow, so each return to STARTED walks the store again; fine for a picker open + // a few seconds. Share it with stateIn in the ViewModel if a large store makes it slow. + val rows = + model.searchResult + .map { result -> + autofillRows(result.passwordItems.map { it.file }, root, directoryStructure) + } + .flowOn(dispatcherProvider.io()) + val origin = formOrigin.getPrettyIdentifier(applicationContext, untrusted = true) + val matchLabel = + getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext), + ) + + setContent { + APSTheme { + var query by rememberSaveable { mutableStateOf(initialQuery) } + var strict by rememberSaveable { mutableStateOf(isWeb) } + var match by rememberSaveable { mutableStateOf(true) } + var clear by rememberSaveable { mutableStateOf(false) } + // During the first composition, before rows is collected, so the ViewModel's default root + // listing (folders included) is never shown, and with the restored query and switch after + // recreation. The LaunchedEffect repeats it with equal arguments, which emits nothing. + remember { search(query, strict) } + LaunchedEffect(query, strict) { search(query, strict) } + val shown by rows.collectAsStateWithLifecycle<ImmutableList<ListRow>?>(initialValue = null) + AutofillFilterScreen( + origin = origin, + query = query, + rows = shown, + showStrict = isWeb, + strict = strict, + matchLabel = matchLabel, + match = match, + clear = clear, + onQueryChange = { query = it }, + onStrictChange = { strict = it }, + onMatchChange = { match = it }, + onClearChange = { clear = it }, + onPick = { path -> decryptAndFill(File(path), match, clear) }, ) - lifecycleScope.launch { handleSearchResults() } - } - } - - private suspend fun handleSearchResults() { - model.searchResult.collect { result -> - val list = result.passwordItems - (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { - binding.rvPassword.scrollToPosition(0) - } - // Switch RecyclerView out for a "no results" message if the new list is empty and - // the message is not yet shown (and vice versa). - if ( - (list.isEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPasswordEmpty.id) || - (list.isNotEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPassword.id) - ) { - binding.rvPasswordSwitcher.showNext() } } } - private fun updateSearch() { + /** The old picker's search: fuzzy or strict domain, recursive, entries only. */ + private fun search(query: String, strict: Boolean) { model.search( - binding.search.text.toString().trim(), - filterMode = - if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + query.trim(), + filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy, searchMode = SearchMode.RecursivelyInSubdirectories, listMode = ListMode.FilesOnly, ) } - private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) - AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) - AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) + private fun decryptAndFill(file: File, match: Boolean, clear: Boolean) { + if (picked) return + picked = true + if (clear) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (match) AutofillMatcher.addMatchFor(applicationContext, formOrigin, file) // intent?.extras? is checked to be non-null in onCreate decryptAction.launch( - AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) + AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this) ) } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt new file mode 100644 index 00000000..9f436ea2 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import app.passwordstore.ui.list.ListRow +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult +import app.passwordstore.util.settings.DirectoryStructure +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * The autofill picker's rows for [files] (entries under [root], already ranked), in their order. + * The name is the origin identifier per [structure], the part that must match the site or app, so + * it stands out as the old picker's bold part did; the subtitle holds the account part and the + * folders above the identifier. Without an identifier the account part is the name. + */ +fun autofillRows( + files: List<File>, + root: File, + structure: DirectoryStructure, +): ImmutableList<ListRow> = + files + .map { file -> + val relative = file.relativeTo(root) + val identifier = structure.getIdentifierFor(relative) + val account = structure.getAccountPartFor(relative) + val subtitle = + if (identifier == null) null + else + listOfNotNull(account, structure.getPathToIdentifierFor(relative)) + .joinToString(" · ") + .ifEmpty { null } + ListRow(file.absolutePath, identifier ?: account ?: file.nameWithoutExtension, subtitle, 0) + } + .toImmutableList() + +/** + * Whether autofill may cache the passphrase it just decrypted with: only with the cache feature on + * ([cacheEnabled]) and after a successful screen lock prompt ([auth]), as the entry view does. A + * passphrase typed after a cancelled, failed or unavailable prompt is never kept. + */ +fun cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean = + cacheEnabled && auth is BiometricResult.Success diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt index 5b55143a..803896a5 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -13,21 +13,20 @@ import android.os.Build import android.os.Bundle import android.service.autofill.FillResponse import android.text.format.DateUtils -import android.view.View import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.core.content.IntentCompat import app.passwordstore.R -import app.passwordstore.databinding.ActivityOreoAutofillPublisherChangedBinding +import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPublisherChangedException import app.passwordstore.util.extensions.asLog import app.passwordstore.util.extensions.getApplicationInfoCompat import app.passwordstore.util.extensions.getPackageInfoCompat -import app.passwordstore.util.extensions.viewBinding import com.github.androidpasswordstore.autofillparser.FormOrigin import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.runCatching import logcat.LogPriority.ERROR import logcat.logcat @@ -65,15 +64,15 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { } } - private lateinit var appPackage: String - private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) setFinishOnTouchOutside(true) + // Tapjacking guard: a touch that reaches this window through another app's window (an overlay + // drawn to trick the user into re-enabling) is dropped. ViewGroup.dispatchTouchEvent checks the + // decor view's own flag before dispatching to any child, so the ComposeView is covered. + window.decorView.filterTouchesWhenObscured = true - appPackage = + val appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } @@ -81,63 +80,57 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { return } supportActionBar?.hide() - showPackageInfo() - with(binding) { - okButton.setOnClickListener { finish() } - advancedButton.setOnClickListener { - advancedButton.visibility = View.GONE - warningAppAdvancedInfo.visibility = View.VISIBLE - resetButton.visibility = View.VISIBLE - } - resetButton.setOnClickListener { - AutofillMatcher.clearMatchesFor( - this@AutofillPublisherChangedActivity, - FormOrigin.App(appPackage), - ) - val fillResponse = - IntentCompat.getParcelableExtra( - intent, - EXTRA_FILL_RESPONSE_AFTER_RESET, - FillResponse::class.java, + val (packageInfo, appInfo, hash) = + runCatching { + Triple( + packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA), + packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA), + computeCertificatesHash(this, appPackage), ) - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) }, + } + .getOrElse { e -> + logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") } + finish() + return + } + val appName = + getString( + R.string.oreo_autofill_warning_publisher_app_name, + packageManager.getApplicationLabel(appInfo), + ) + val installed = + getString( + R.string.oreo_autofill_warning_publisher_install_time, + DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime), + ) + val details = + getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, hash) + setContent { + APSTheme { + PublisherChangedScreen( + appName = appName, + installed = installed, + details = details, + onKeepDisabled = { finish() }, + onReset = { reset(appPackage) }, ) - finish() } } } - private fun showPackageInfo() { - runCatching { - with(binding) { - val packageInfo = - packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA) - val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) - warningAppInstallDate.text = - getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) - val appInfo = - packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA) - warningAppName.text = - getString( - R.string.oreo_autofill_warning_publisher_app_name, - packageManager.getApplicationLabel(appInfo), - ) - - val currentHash = - computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) - warningAppAdvancedInfo.text = - getString( - R.string.oreo_autofill_warning_publisher_advanced_info_template, - appPackage, - currentHash, - ) - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") } - finish() - } + /** Forgets the app's matches and fills with the response the service prepared for this case. */ + private fun reset(appPackage: String) { + AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage)) + val fillResponse = + IntentCompat.getParcelableExtra( + intent, + EXTRA_FILL_RESPONSE_AFTER_RESET, + FillResponse::class.java, + ) + setResult( + RESULT_OK, + Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) }, + ) + finish() } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index 9476b344..eb533411 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -9,12 +9,17 @@ import android.content.Context import android.content.Intent import android.content.IntentSender import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.PasswordCreationActivity +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.editor.EditorDestination +import app.passwordstore.ui.editor.EditorForm +import app.passwordstore.ui.editor.SaveResult +import app.passwordstore.ui.editor.passContent import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder @@ -23,10 +28,15 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.Credentials import com.github.androidpasswordstore.autofillparser.FormOrigin import dagger.hilt.android.AndroidEntryPoint -import java.io.File +import javax.inject.Inject import logcat.LogPriority.ERROR import logcat.logcat +/** + * Saves credentials the autofill service caught (or generates a password for a form) in the Compose + * editor, prefilled from the form's origin, then records the autofill match and, for a generated + * password, fills it in. + */ @AndroidEntryPoint class AutofillSaveActivity : AppCompatActivity() { @@ -102,60 +112,68 @@ class AutofillSaveActivity : AppCompatActivity() { } } + private val generate by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + + @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val repo = PasswordRepository.getRepositoryDirectory() - val saveIntent = - Intent(this, PasswordCreationActivity::class.java).apply { - putExtras( - bundleOf( - "REPO_PATH" to repo.absolutePath, - "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, - PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), - PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), - PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false), - ) + // The whole window is the editor: keep it out of screenshots and recents. + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + val initial = + EditorForm( + name = intent.getStringExtra(EXTRA_NAME).orEmpty(), + dir = intent.getStringExtra(EXTRA_FOLDER_NAME).orEmpty().trim('/'), + password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(), + ) + setContent { + APSTheme { + EditorDestination( + activity = this, + initial = initial, + original = null, + onClose = { + setResult(RESULT_CANCELED) + finish() + }, + onSaved = ::finishSaved, + generate = generate, ) } - registerForActivityResult(StartActivityForResult()) { result -> - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - val createdPath = data.getStringExtra("CREATED_FILE")!! - formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) } - val password = data.getStringExtra("PASSWORD") - val resultIntent = - if (password != null) { - // Password was generated and should be filled into a form. - val username = data.getStringExtra("USERNAME") - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return@registerForActivityResult - } - val credentials = Credentials(username, password, null) - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this, - credentials, - clientState, - AutofillAction.Generate, - ) - Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - } - } else { - // Password was extracted from a form, there is nothing to fill. - Intent() + } + } + + /** Records the autofill match and, for a generated password, returns it to fill the form. */ + private fun finishSaved(saved: SaveResult.Saved) { + formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) } + val resultIntent = + if (generate) { + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) + ?: run { + logcat(ERROR) { "AutofillSaveActivity started without EXTRA_CLIENT_STATE" } + finish() + return } - setResult(RESULT_OK, resultIntent) - } else { - setResult(RESULT_CANCELED) - } - finish() + // Read back from the saved content, as the old editor did, so a username in the extras + // counts too. + val entry = passwordEntryFactory.create(passContent(saved.form).encodeToByteArray()) + val username = + entry.username ?: AutofillPreferences.directoryStructure(this).getUsernameFor(saved.file) + val credentials = Credentials(username, entry.password, null) + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this, + credentials, + clientState, + AutofillAction.Generate, + ) + Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + } else { + // The password came from the form: there is nothing to fill. + Intent() } - .launch(saveIntent) + setResult(RESULT_OK, resultIntent) + finish() } } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt deleted file mode 100644 index 24a28564..00000000 --- a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.autofill - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.R - -class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { - - val title: TextView = itemView.findViewById(R.id.title) - val subtitle: TextView = itemView.findViewById(R.id.subtitle) -} diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt new file mode 100644 index 00000000..6287408e --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingLargest + +/** + * Warns that the app being filled ([appName], installed [installed]) is signed by another publisher + * than when an entry was first matched to it. [onKeepDisabled] fills nothing. [onReset] forgets the + * old matches and fills; it is offered only after "Advanced information", which shows [details] + * (package and certificate hash), so re-enabling always takes two deliberate taps. + */ +@Composable +fun PublisherChangedScreen( + appName: String, + installed: String, + details: String, + onKeepDisabled: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + var advanced by rememberSaveable { mutableStateOf(false) } + val center = Modifier.fillMaxWidth() + Surface(modifier) { + Column( + Modifier.verticalScroll(rememberScrollState()).padding(SpacingLargest), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painterResource(R.drawable.ic_warning_red_24dp), + contentDescription = + stringResource(R.string.oreo_autofill_warning_publisher_warning_sign_description), + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.error, + ) + Text( + stringResource(R.string.oreo_autofill_warning_publisher_dataset_title), + modifier = center.semantics { heading() }, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + ) + Text( + stringResource(R.string.oreo_autofill_warning_publisher_header), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + appName, + modifier = center, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + ) + Text( + installed, + modifier = center, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + ) + // ponytail: stringResource drops the footer's <b> in every locale but Italian (whose footer + // has none); the red title carries the emphasis. Keep spans with getText if it is missed. + Text( + stringResource(R.string.oreo_autofill_warning_publisher_footer), + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onKeepDisabled, modifier = center) { + Text( + stringResource(R.string.oreo_autofill_warning_publisher_changed_disable_autofill_button) + ) + } + if (!advanced) { + OutlinedButton(onClick = { advanced = true }, modifier = center) { + Text(stringResource(R.string.oreo_autofill_warning_publisher_advanced_info_button)) + } + } else { + // The details come first, so Re-enable never sits where Advanced information was and a + // double tap cannot re-enable autofill. + SelectionContainer { + Text( + details, + modifier = center, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } + OutlinedButton(onClick = onReset, modifier = center) { + Text(stringResource(R.string.oreo_autofill_warning_publisher_reenable_button)) + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt deleted file mode 100644 index 2b3f4dab..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.ClipData -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.Bundle -import android.os.PersistableBundle -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.data.crypto.CryptoRepository -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.pgp.PGPKeyImportActivity -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.clipboard -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.services.ClipboardService -import app.passwordstore.util.settings.Constants -import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Suppress("Registered") -@AndroidEntryPoint -open class BasePGPActivity : AppCompatActivity() { - - /** Full path to the repository */ - val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! } - - /** Full path to the password file being worked on */ - val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! } - - /** - * Name of the password file - * - * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org - */ - val name: String by unsafeLazy { File(fullPath).nameWithoutExtension } - - /** Action to invoke if [keyImportAction] succeeds. */ - private var onKeyImport: (() -> Unit)? = null - private val keyImportAction = - registerForActivityResult(StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - onKeyImport?.invoke() - onKeyImport = null - } else { - finish() - } - } - - /** [SharedPreferences] instance used by subclasses to persist settings */ - @SettingsPreferences @Inject lateinit var settings: SharedPreferences - @Inject lateinit var repository: CryptoRepository - @Inject lateinit var dispatcherProvider: DispatcherProvider - - /** - * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or - * recent apps screen. - */ - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - } - - /** - * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing - * [showSnackbar] as false. - */ - fun copyTextToClipboard( - text: String?, - showSnackbar: Boolean = true, - @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, - ) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clip.description.extras = - PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } - clipboard.setPrimaryClip(clip) - if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - snackbar(message = resources.getString(snackbarTextRes)) - } - } - - /** - * Function to execute [onKeysExist] only if there are PGP keys imported in the app's key manager. - */ - fun requireKeysExist(onKeysExist: () -> Unit) { - lifecycleScope.launch { - val hasKeys = repository.hasKeys() - if (!hasKeys) { - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@BasePGPActivity) - .setTitle(resources.getString(R.string.no_keys_imported_dialog_title)) - .setMessage(resources.getString(R.string.no_keys_imported_dialog_message)) - .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ -> - onKeyImport = onKeysExist - keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java)) - } - .show() - } - } else { - onKeysExist() - } - } - } - - /** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide - * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of - * clearing the clipboard. - */ - fun copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password) - val clearAfter = - settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() - ?: Constants.DEFAULT_DECRYPTION_TIMEOUT - - if (clearAfter != 0) { - val service = - Intent(this, ClipboardService::class.java).apply { - action = ClipboardService.ACTION_START - putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) - } - startForegroundService(service) - } - } - - /** - * Get a list of available [PGPIdentifier]s for the current password repository. This method - * throws when no identifiers were able to be parsed. If this method returns null, it means that - * an invalid identifier was encountered and further execution must stop to let the user correct - * the problem. - */ - fun getPGPIdentifiers(subDir: String): List<PGPIdentifier>? { - val repoRoot = PasswordRepository.getRepositoryDirectory() - val gpgIdentifierFile = - File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) - ?: File(repoRoot, ".gpg-id").apply { createNewFile() } - val gpgIdentifiers = - gpgIdentifierFile - .readLines() - .filter { it.isNotBlank() } - .map { line -> - PGPIdentifier.fromString(line) - ?: run { - // The line being empty means this is most likely an empty `.gpg-id` - // file we created. Skip the validation so we can make the user add a - // real ID. - if (line.isEmpty()) return@run - // Apparently `gpg-id` being the first line is also acceptable? - if (line == "gpg-id") return@run - if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { - snackbar(message = resources.getString(R.string.short_gpg_id)) - } else { - snackbar(message = resources.getString(R.string.invalid_gpg_id)) - } - return null - } - } - .filterIsInstance<PGPIdentifier>() - if (gpgIdentifiers.isEmpty()) { - error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") - } - return gpgIdentifiers - } - - @Suppress("ReturnCount") - private fun File.findTillRoot(fileName: String, rootPath: File): File? { - val gpgFile = File(this, fileName) - if (gpgFile.exists()) return gpgFile - - if (this.absolutePath == rootPath.absolutePath) { - return null - } - val parent = parentFile - return if (parent != null && parent.exists()) { - parent.findTillRoot(fileName, rootPath) - } else { - null - } - } - - companion object { - - const val EXTRA_FILE_PATH = "FILE_PATH" - const val EXTRA_REPO_PATH = "REPO_PATH" - - /** Gets the relative path to the repository */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** Gets the Parent path, relative to the repository */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" - .replace("/+".toRegex(), "/") - } - - /** /path/to/store/social/facebook.gpg -> social/facebook */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt new file mode 100644 index 00000000..fbaa534f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PersistableBundle +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.extensions.clipboard +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.services.ClipboardService +import app.passwordstore.util.settings.Constants +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File + +/** Gets the relative path to the repository */ +private fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + +/** Gets the Parent path, relative to the repository */ +fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" + .replace("/+".toRegex(), "/") +} + +/** /path/to/store/social/facebook.gpg -> social/facebook */ +fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } +} + +/** + * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a + * snackbar, which can be disabled by passing [showSnackbar] as false. + */ +fun FragmentActivity.copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, +) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + clipboard.setPrimaryClip(clip) + if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + snackbar(message = resources.getString(snackbarTextRes)) + } +} + +/** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts + * off an instance of [ClipboardService] to provide a way of clearing the clipboard. + */ +fun FragmentActivity.copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password) + val clearAfter = + sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() + ?: Constants.DEFAULT_DECRYPTION_TIMEOUT + + if (clearAfter != 0) { + val service = + Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + startForegroundService(service) + } +} + +/** + * Get a list of available [PGPIdentifier]s for the current password repository. This method throws + * when no identifiers were able to be parsed. If this method returns null, an invalid identifier + * was encountered and reported with a toast: further execution must stop to let the user correct + * the problem. + */ +fun Context.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? { + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = + File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) + ?: File(repoRoot, ".gpg-id").apply { createNewFile() } + val gpgIdentifiers = + gpgIdentifierFile + .readLines() + .filter { it.isNotBlank() } + .map { line -> + PGPIdentifier.fromString(line) + ?: run { + // The line being empty means this is most likely an empty `.gpg-id` + // file we created. Skip the validation so we can make the user add a + // real ID. + if (line.isEmpty()) return@run + // Apparently `gpg-id` being the first line is also acceptable? + if (line == "gpg-id") return@run + val message = + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id + else R.string.invalid_gpg_id + // A toast on the application context, not a snackbar: the autofill decryption + // finishes its window right after, and the toast must outlive it. + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + return null + } + } + .filterIsInstance<PGPIdentifier>() + if (gpgIdentifiers.isEmpty()) { + error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") + } + return gpgIdentifiers +} + +@Suppress("ReturnCount") +private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt deleted file mode 100644 index 8b2109bf..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.core.content.edit -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.lifecycleScope -import app.passwordstore.Application.Companion.screenWasOff -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.data.password.FieldItem -import app.passwordstore.databinding.DecryptLayoutBinding -import app.passwordstore.ui.adapters.FieldItemAdapter -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache -import app.passwordstore.util.features.Features -import app.passwordstore.util.settings.Constants -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.ByteArrayOutputStream -import java.io.File -import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class DecryptActivity : BasePGPActivity() { - - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - @Inject lateinit var passphraseCache: PGPPassphraseCache - @Inject lateinit var features: Features - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } - private var passwordEntry: PasswordEntry? = null - private var retries = 0 - private var clearCache = true - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - } - if ( - features.isEnabled(EnablePGPPassphraseCache) && - BiometricAuthenticator.canAuthenticate(this@DecryptActivity) - ) { - BiometricAuthenticator.authenticate( - this@DecryptActivity, - R.string.biometric_prompt_title_gpg_passphrase_cache, - ) { authResult -> - requireKeysExist { decrypt(isError = false, authResult) } - } - } else { - requireKeysExist { decrypt(isError = false, BiometricResult.CanceledByUser) } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - menu.findItem(R.id.edit_password).isVisible = true - if (!entry.password.isNullOrBlank()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> onBackPressedDispatcher.onBackPressed() - R.id.edit_password -> editPassword() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Automatically finishes the activity after [PreferenceKeys.GENERAL_SHOW_TIME] seconds decryption - * succeeded to prevent information leaks from stale activities. - */ - private fun startAutoDismissTimer() { - lifecycleScope.launch { - val timeout = - settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() - ?: Constants.DEFAULT_DECRYPTION_TIMEOUT - if (timeout != 0) { - delay(timeout.seconds) - finish() - } - } - } - - /** - * Edit the current password and hide all the fields populated by encrypted data so that when the - * result triggers they can be repopulated with new data. - */ - private fun editPassword() { - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.action = Intent.ACTION_VIEW - intent.putExtra("FILE_PATH", relativeParentPath) - intent.putExtra("REPO_PATH", repoPath) - intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) - intent.putExtra(PasswordCreationActivity.EXTRA_USERNAME, passwordEntry?.username) - intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra( - PasswordCreationActivity.EXTRA_EXTRA_CONTENT, - passwordEntry?.extraContentWithoutUsername, - ) - intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) - startActivity(intent) - finish() - } - - private fun shareAsPlaintext() { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - type = "text/plain" - } - // Always show a picker to give the user a chance to cancel - startActivity( - Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)) - ) - } - - private fun decrypt(isError: Boolean, authResult: BiometricResult) { - val gpgIdentifiers = getPGPIdentifiers(relativeParentPath) ?: return - lifecycleScope.launch(dispatcherProvider.main()) { - when (authResult) { - // Internally handled by the prompt dialog - is BiometricResult.Retry -> {} - // If the dialog is dismissed for any reason, prompt for passphrase - is BiometricResult.CanceledByUser, - is BiometricResult.CanceledBySystem, - is BiometricResult.Failure, - is BiometricResult.HardwareUnavailableOrDisabled -> - askPassphrase(isError, gpgIdentifiers, authResult) - is BiometricResult.Success -> { - /* clear passphrase cache on first use after application startup or if screen was off; - also make sure to purge a stale cache after caching has been disabled via PGP settings */ - clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true) - if (screenWasOff && clearCache) { - passphraseCache.clearAllCachedPassphrases(this@DecryptActivity) - screenWasOff = false - } - val cachedPassphrase = - passphraseCache.retrieveCachedPassphrase(this@DecryptActivity, gpgIdentifiers.first()) - if (cachedPassphrase != null) { - decryptWithPassphrase(cachedPassphrase, gpgIdentifiers, authResult) - } else { - askPassphrase(isError, gpgIdentifiers, authResult) - } - } - } - } - } - - private suspend fun askPassphrase( - isError: Boolean, - gpgIdentifiers: List<PGPIdentifier>, - authResult: BiometricResult, - ) { - if (retries < MAX_RETRIES) { - retries += 1 - } else { - finish() - } - if (!repository.isPasswordProtected(gpgIdentifiers)) { - decryptWithPassphrase(passphrase = "", gpgIdentifiers, authResult) - return - } - val dialog = - PasswordDialog.newInstance( - cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), - clearCache = clearCache, - ) - if (isError) { - dialog.setError() - } - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> - if (key == PasswordDialog.PASSWORD_RESULT_KEY) { - val passphrase = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!! - clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY) - lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(passphrase, gpgIdentifiers, authResult) { - runCatching { - if (authResult is BiometricResult.Success) { - passphraseCache.cachePassphrase( - this@DecryptActivity, - gpgIdentifiers.first(), - passphrase, - ) - settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) } - } - } - .onFailure { e -> logcat { e.asLog() } } - } - } - } - } - } - - private suspend fun decryptWithPassphrase( - passphrase: String, - identifiers: List<PGPIdentifier>, - authResult: BiometricResult, - onSuccess: suspend () -> Unit = {}, - ) { - val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() } - val outputStream = ByteArrayOutputStream() - val result = repository.decrypt(passphrase, identifiers, message, outputStream) - if (result.isOk) { - val entry = passwordEntryFactory.create(result.value.toByteArray()) - passwordEntry = entry - createPasswordUI(entry) - startAutoDismissTimer() - onSuccess() - } else { - logcat(ERROR) { result.error.stackTraceToString() } - decrypt(isError = true, authResult = authResult) - } - } - - private suspend fun createPasswordUI(entry: PasswordEntry) = - withContext(dispatcherProvider.main()) { - val labelFormat = resources.getString(R.string.otp_label_format) - val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - invalidateOptionsMenu() - - val items = arrayListOf<FieldItem>() - if (!entry.password.isNullOrBlank()) { - items.add(FieldItem.createPasswordField(getString(R.string.password), entry.password!!)) - if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { - copyPasswordToClipboard(entry.password) - } - } - - if (entry.hasTotp()) { - items.add(FieldItem.createOtpField(labelFormat, entry.totp.first())) - } - - if (!entry.username.isNullOrBlank()) { - items.add(FieldItem.createUsernameField(getString(R.string.username), entry.username!!)) - } - - entry.extraContent.forEach { (key, value) -> - items.add(FieldItem.createFreeformField(key, value)) - } - - val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) } - binding.recyclerView.adapter = adapter - binding.recyclerView.itemAnimator = null - - if (entry.hasTotp()) { - lifecycleScope.launch { entry.totp.collect { adapter.updateOTPCode(it, labelFormat) } } - } - } - - private companion object { - private const val MAX_RETRIES = 3 - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt deleted file mode 100644 index d8610307..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder - -/** Composable to show allow editing an existing [PasswordEntry]. */ -@Composable -fun EditPasswordScreen( - entryName: String, - entry: PasswordEntry, - onNavigateUp: () -> Unit, - @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - } - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column( - modifier = - Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize() - ) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - ExtraContent(entry = entry) - } - } - } -} - -@Composable -private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) { - TextField( - value = entry.extraContentString, - onValueChange = {}, - label = { Text("Extra content") }, - modifier = modifier.fillMaxWidth(), - ) -} - -@Preview -@Composable -private fun EditPasswordScreenPreview() { - APSTheme { - EditPasswordScreen( - entryName = "Test Entry", - entry = createTestEntry(), - onNavigateUp = {}, - onSave = {}, - ) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray(), - ) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt deleted file mode 100644 index 7652c98e..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.graphics.ImageDecoder -import android.os.Build -import android.os.Bundle -import android.provider.MediaStore -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.databinding.PasswordCreationActivityBinding -import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment -import app.passwordstore.ui.dialogs.OtpImportDialogFragment -import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment -import app.passwordstore.util.autofill.AutofillPreferences -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.isInsideRepository -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.DirectoryStructure -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.BinaryBitmap -import com.google.zxing.LuminanceSource -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.HybridBinarizer -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.google.zxing.qrcode.QRCodeReader -import dagger.hilt.android.AndroidEntryPoint -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.nio.file.Paths -import javax.inject.Inject -import kotlin.io.path.absolutePathString -import kotlin.io.path.createDirectories -import kotlin.io.path.deleteIfExists -import kotlin.io.path.exists -import kotlin.io.path.isSameFileAs -import kotlin.io.path.nameWithoutExtension -import kotlin.io.path.pathString -import kotlin.io.path.relativeTo -import kotlin.io.path.writeBytes -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -@AndroidEntryPoint -class PasswordCreationActivity : BasePGPActivity() { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private var oldCategory: String? = null - private var copy: Boolean = false - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure_generic)) - } - } - - private val imageImportAction = - registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> - if (imageUri == null) { - snackbar(message = getString(R.string.otp_import_failure_no_selection)) - return@registerForActivityResult - } - val bitmap = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) - .copy(Bitmap.Config.ARGB_8888, true) - } else { - @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) - } - val intArray = IntArray(bitmap.width * bitmap.height) - // copy pixel data from the Bitmap into the 'intArray' array - bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) - val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) - val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) - - val reader = QRCodeReader() - runCatching { - val result = reader.decode(binaryBitmap) - val text = result.text - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$text") - else binding.extraContent.append(text) - snackbar(message = getString(R.string.otp_import_success)) - binding.otpImportButton.isVisible = false - } - .onFailure { snackbar(message = getString(R.string.otp_import_failure_generic)) } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@PasswordCreationActivity, - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_from_file), - getString(R.string.otp_import_manual_entry), - ) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@PasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> imageImportAction.launch("image/*") - 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - - if ( - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.EncryptedUsername || suggestedUsername != null - ) { - usernameInputLayout.visibility = View.VISIBLE - if (suggestedUsername != null) username.setText(suggestedUsername) - else if (suggestedName != null) username.requestFocus() - } - - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we use the filename - // as username and insert it into the username input field. - val login = filename.text.toString() - filename.text?.clear() - username.setText(login) - usernameInputLayout.apply { visibility = View.VISIBLE } - } else { - // User wants to disable username encryption, so we take the username - // from the username text field and insert it into the filename input field. - val login = username.text.toString() - username.text?.clear() - filename.setText(login) - usernameInputLayout.apply { visibility = View.GONE } - } - } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - listOf(binding.filename, binding.username, binding.extraContent).forEach { - it.doAfterTextChanged { updateViewState() } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.save_password -> { - copy = false - requireKeysExist { encrypt() } - } - R.id.save_and_copy_password -> { - copy = true - requireKeysExist { encrypt() } - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_DICEWARE) { - KEY_PWGEN_TYPE_CLASSIC -> - PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_DICEWARE -> - DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator") - } - } - - private fun updateViewState() = - with(binding) { - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val usernameIsEncrypted = username.text.toString().isNotEmpty() - isEnabled = hasUsernameInFileName xor usernameIsEncrypted - isChecked = usernameIsEncrypted - } - // Use PasswordEntry to parse extras for OTP - val entry = - passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray()) - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt() { - with(binding) { - val oldName = suggestedName - val editName = filename.text.toString().trim() - var editUsername = username.text.toString() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (!editUsername.isEmpty()) { - editUsername = "\nusername: $editUsername" - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - // pass enters the key ID into `.gpg-id`. - val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with - val content = "$editPass$editUsername\n$editExtra" - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/')) - passwordDirectory.createDirectories() - if (!passwordDirectory.exists()) { - snackbar( - message = - "Failed to create directory ${passwordDirectory.relativeTo(Paths.get(repoPath)).pathString}" - ) - return - } - - "${passwordDirectory.pathString}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(dispatcherProvider.main()) { - runCatching { - val result = - withContext(dispatcherProvider.io()) { - val outputStream = ByteArrayOutputStream() - repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream) - } - .getOrElse { e -> - logcat(ERROR) { e.asLog("Failed to encrypt password file") } - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_encrypt_fail_message, e.message) - ) - .setPositiveButton(android.R.string.ok, null) - .show() - return@runCatching - } - val passwordFile = Paths.get(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ( - (!editing || (editing && suggestedName != passwordFile.nameWithoutExtension)) && - passwordFile.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!passwordFile.toFile().isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.toByteArray()) } - - // associate the new password name with the last name's timestamp in history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$suggestedName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(passwordFile.absolutePathString().base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) - - if (shouldGeneratePassword) { - val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = - entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile()) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if ( - directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldName != editName - ) { - val oldPath = Paths.get(repoPath, oldCategory?.trim('/') ?: "", "$oldName.gpg") - if (!oldPath.isSameFileAs(passwordFile) && !oldPath.deleteIfExists()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - logcat(ERROR) { e.asLog("Failed to write password file") } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - logcat(ERROR) { e.asLog() } - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_DICEWARE = "diceware" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_USERNAME = "USERNAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt deleted file mode 100644 index d845d689..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.KeyEvent -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.R -import app.passwordstore.databinding.DialogPasswordEntryBinding -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** [DialogFragment] to request a password from the user and forward it along. */ -class PasswordDialog : DialogFragment() { - - private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) } - private var isError: Boolean = false - private var clearCacheChecked: Boolean = true - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(R.string.password) - - val cacheEnabled = requireArguments().getBoolean(CACHE_ENABLED_EXTRA, false) - binding.autoClearCache.isVisible = cacheEnabled - - if (cacheEnabled) { - clearCacheChecked = requireArguments().getBoolean(AUTO_CLEAR_CACHE_EXTRA) - binding.autoClearCache.isChecked = clearCacheChecked - binding.autoClearCache.setOnCheckedChangeListener { _, isChecked -> - clearCacheChecked = isChecked - } - } - - builder.setPositiveButton(android.R.string.ok) { _, _ -> setPasswordAndDismiss() } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - if (isError) { - binding.passwordField.error = getString(R.string.git_operation_wrong_password) - } - binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } - binding.passwordEditText.setOnKeyListener { _, keyCode, _ -> - if (keyCode == KeyEvent.KEYCODE_ENTER) { - setPasswordAndDismiss() - return@setOnKeyListener true - } - false - } - } - dialog.window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - return dialog - } - - fun setError() { - isError = true - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - private fun setPasswordAndDismiss() { - val password = binding.passwordEditText.text.toString() - setFragmentResult( - PASSWORD_RESULT_KEY, - bundleOf(PASSWORD_PHRASE_KEY to password, PASSWORD_CLEAR_KEY to clearCacheChecked), - ) - dismissAllowingStateLoss() - } - - companion object { - - private const val CACHE_ENABLED_EXTRA = "CACHE_ENABLED" - private const val AUTO_CLEAR_CACHE_EXTRA = "AUTO_CLEAR_CACHE" - - const val PASSWORD_RESULT_KEY = "password_result" - const val PASSWORD_PHRASE_KEY = "password_phrase" - const val PASSWORD_CLEAR_KEY = "password_clear" - - fun newInstance(cacheEnabled: Boolean, clearCache: Boolean): PasswordDialog { - val extras = - bundleOf(CACHE_ENABLED_EXTRA to cacheEnabled, AUTO_CLEAR_CACHE_EXTRA to clearCache) - val fragment = PasswordDialog() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt deleted file mode 100644 index f2a67706..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.capitalize -import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.data.passfile.PasswordEntry -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.CopyButton -import app.passwordstore.ui.compose.PasswordField -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.ui.compose.theme.SpacingMedium -import app.passwordstore.util.time.UserClock -import app.passwordstore.util.totp.UriTotpFinder -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking - -/** Composable to show a decrypted [PasswordEntry]. */ -@Composable -fun ViewPasswordScreen( - entryName: String, - entry: PasswordEntry, - onNavigateUp: () -> Unit, - modifier: Modifier = Modifier, -) { - Scaffold( - topBar = { - APSAppBar( - title = entryName, - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = onNavigateUp, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - } - ) { paddingValues -> - Box(modifier = modifier.padding(paddingValues)) { - Column( - modifier = - Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize() - ) { - if (entry.password != null) { - PasswordField( - value = entry.password!!, - label = stringResource(R.string.password), - initialVisibility = false, - readOnly = true, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - if (entry.hasTotp()) { - val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() }) - TextField( - value = totp.value, - onValueChange = {}, - readOnly = true, - label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") }, - trailingIcon = { CopyButton(totp.value, R.string.copy_label) }, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - if (entry.username != null) { - TextField( - value = entry.username!!, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.username)) }, - trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) }, - modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } - ExtraContent(entry = entry) - } - } - } -} - -@Composable -private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) { - entry.extraContent.forEach { (label, value) -> - TextField( - value = value, - onValueChange = {}, - readOnly = true, - label = { Text(label.capitalize(Locale.current)) }, - trailingIcon = { CopyButton(value, R.string.copy_label) }, - modifier = modifier.padding(bottom = SpacingMedium).fillMaxWidth(), - ) - } -} - -@Preview -@Composable -private fun ViewPasswordScreenPreview() { - APSTheme { - ViewPasswordScreen(entryName = "Test Entry", entry = createTestEntry(), onNavigateUp = {}) - } -} - -private fun createTestEntry() = - PasswordEntry( - UserClock(), - UriTotpFinder(), - """ - |My Password - |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 - |login: msfjarvis - |URL: example.com - """ - .trimMargin() - .encodeToByteArray(), - ) diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt deleted file mode 100644 index e689f3bd..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.content.Context -import android.content.DialogInterface.OnDismissListener -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import app.passwordstore.R -import app.passwordstore.databinding.BasicBottomSheetBinding -import app.passwordstore.util.extensions.viewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -/** - * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API - * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen. - */ -class BasicBottomSheet -private constructor( - val title: String?, - val message: String, - val positiveButtonLabel: String?, - val negativeButtonLabel: String?, - val positiveButtonClickListener: View.OnClickListener?, - val negativeButtonClickListener: View.OnClickListener?, - val onDismissListener: OnDismissListener?, -) : BottomSheetDialogFragment() { - - private val binding by viewBinding(BasicBottomSheetBinding::bind) - - private var behavior: BottomSheetBehavior<FrameLayout>? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (savedInstanceState != null) dismiss() - return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - if (!title.isNullOrEmpty()) { - binding.bottomSheetTitle.isVisible = true - binding.bottomSheetTitle.text = title - } - binding.bottomSheetMessage.text = message - if (positiveButtonClickListener != null) { - positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl } - binding.bottomSheetOkButton.isVisible = true - binding.bottomSheetOkButton.setOnClickListener { - positiveButtonClickListener.onClick(it) - dismiss() - } - } - if (negativeButtonClickListener != null) { - binding.bottomSheetCancelButton.isVisible = true - negativeButtonLabel?.let { buttonLbl -> - binding.bottomSheetCancelButton.text = buttonLbl - } - binding.bottomSheetCancelButton.setOnClickListener { - negativeButtonClickListener.onClick(it) - dismiss() - } - } - if (onDismissListener != null) { - dialog.setOnDismissListener(onDismissListener) - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } - - class Builder(val context: Context) { - - private var title: String? = null - private var message: String? = null - private var positiveButtonLabel: String? = null - private var negativeButtonLabel: String? = null - private var positiveButtonClickListener: View.OnClickListener? = null - private var negativeButtonClickListener: View.OnClickListener? = null - private var onDismissListener: OnDismissListener? = null - - fun setTitleRes(@StringRes titleRes: Int): Builder { - this.title = context.resources.getString(titleRes) - return this - } - - fun setTitle(title: String): Builder { - this.title = title - return this - } - - fun setMessageRes(@StringRes messageRes: Int): Builder { - this.message = context.resources.getString(messageRes) - return this - } - - fun setMessage(message: String): Builder { - this.message = message - return this - } - - fun setPositiveButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.positiveButtonClickListener = listener - this.positiveButtonLabel = buttonLabel - return this - } - - fun setNegativeButtonClickListener( - buttonLabel: String? = null, - listener: View.OnClickListener, - ): Builder { - this.negativeButtonClickListener = listener - this.negativeButtonLabel = buttonLabel - return this - } - - fun setOnDismissListener(onDismissListener: OnDismissListener): Builder { - this.onDismissListener = onDismissListener - return this - } - - fun build(): BasicBottomSheet { - require(message != null) { "Message needs to be set" } - return BasicBottomSheet( - title, - message!!, - positiveButtonLabel, - negativeButtonLabel, - positiveButtonClickListener, - negativeButtonClickListener, - onDismissListener, - ) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt deleted file mode 100644 index 2605e0ef..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Bundle -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenDicewareBinding -import app.passwordstore.injection.prefs.PasswordGeneratorPreferences -import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH -import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges - -@AndroidEntryPoint -class DicewarePasswordGeneratorDialogFragment : DialogFragment() { - - @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator - @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-") - binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.passwordLengthText.afterTextChanges(), - binding.passwordSeparatorText.afterTextChanges(), - ) - .collect { _ -> generatePassword(binding) } - } - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generatePassword(binding) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) } - } - } - } - - private fun generatePassword(binding: FragmentPwgenDicewareBinding) { - val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5 - val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-' - setPreferences(length, separator) - binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator) - } - - private fun setPreferences(length: Int, separator: Char) { - prefs.edit { - putInt(DICEWARE_LENGTH, length) - putString(DICEWARE_SEPARATOR, separator.toString()) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt deleted file mode 100644 index 669e126e..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.os.Bundle -import android.view.WindowManager -import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import app.passwordstore.R -import app.passwordstore.ui.passwords.PasswordStore -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import java.io.File - -class FolderCreationDialogFragment : DialogFragment() { - - private lateinit var newFolder: File - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) - alertDialogBuilder.setTitle(R.string.title_create_folder) - alertDialogBuilder.setView(R.layout.folder_dialog_fragment) - alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) - alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() } - val dialog = alertDialogBuilder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) - } - } - return dialog - } - - private fun createDirectory(currentDir: String) { - val dialog = requireDialog() - val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) - val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container) - newFolder = File("$currentDir/${folderNameView.text}") - folderNameViewContainer.error = - when { - newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) - newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) - else -> null - } - if (folderNameViewContainer.error != null) return - newFolder.mkdirs() - (requireActivity() as PasswordStore).refreshPasswordList(newFolder) - // TODO(msfjarvis): Restore this functionality - /* - if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) { - keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) - return - } else { - dismiss() - } - */ - dismiss() - } - - companion object { - - private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" - - fun newInstance(startingDirectory: String): FolderCreationDialogFragment { - val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) - val fragment = FolderCreationDialogFragment() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt deleted file mode 100644 index 0304b16c..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import app.passwordstore.R -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_KEY -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD -import app.passwordstore.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -class ItemCreationBottomSheet : BottomSheetDialogFragment() { - - private var behavior: BottomSheetBehavior<FrameLayout>? = null - private val bottomSheetCallback = - object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (savedInstanceState != null) dismiss() - return inflater.inflate(R.layout.item_create_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener( - object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) - dismiss() - } - dialog.findViewById<View>(R.id.create_password)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD)) - dismiss() - } - } - } - ) - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt deleted file mode 100644 index 1a75175b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.net.Uri -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.databinding.FragmentManualOtpEntryBinding -import app.passwordstore.ui.crypto.PasswordCreationActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class OtpImportDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) - builder.setView(binding.root) - builder.setPositiveButton(android.R.string.ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)), - ) - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { - val secret = binding.secret.text.toString() - val account = binding.account.text.toString() - if (secret.isBlank()) return "" - val builder = Uri.Builder() - builder.scheme("otpauth") - builder.authority("totp") - builder.appendQueryParameter("secret", secret) - if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) - return builder.build().toString() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt deleted file mode 100644 index d4a2372b..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.dialogs - -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.widget.CheckBox -import android.widget.EditText -import android.widget.Toast -import androidx.annotation.IdRes -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.FragmentPwgenBinding -import app.passwordstore.passgen.random.MaxIterationsExceededException -import app.passwordstore.passgen.random.NoCharactersIncludedException -import app.passwordstore.passgen.random.PasswordGenerator -import app.passwordstore.passgen.random.PasswordLengthTooShortException -import app.passwordstore.passgen.random.PasswordOption -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.launch -import reactivecircus.flowbinding.android.widget.afterTextChanges -import reactivecircus.flowbinding.android.widget.checkedChanges - -class PasswordGeneratorDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - val builder = MaterialAlertDialogBuilder(requireContext()) - - val binding = FragmentPwgenBinding.inflate(layoutInflater) - builder.setView(binding.root) - - binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) - binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) - binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) - binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) - binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) - binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) - binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) - binding.passwordText.typeface = Typeface.MONOSPACE - - lifecycleScope.launch { - merge( - binding.numerals.checkedChanges().skipInitialValue(), - binding.symbols.checkedChanges().skipInitialValue(), - binding.uppercase.checkedChanges().skipInitialValue(), - binding.lowercase.checkedChanges().skipInitialValue(), - binding.ambiguous.checkedChanges().skipInitialValue(), - binding.pronounceable.checkedChanges().skipInitialValue(), - binding.lengthNumber.afterTextChanges().skipInitialValue(), - ) - .collect { generate(binding.passwordText) } - } - - return builder - .run { - setTitle(R.string.pwgen_title) - setPositiveButton(R.string.dialog_ok) { _, _ -> - setFragmentResult( - PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, - bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"), - ) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - } - .apply { - setOnShowListener { - generate(binding.passwordText) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - generate(binding.passwordText) - } - } - } - } - - private fun generate(passwordField: AppCompatTextView) { - val passwordOptions = getSelectedOptions() - val passwordLength = getLength() - setPrefs(requireContext(), passwordOptions, passwordLength) - passwordField.text = - runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) } - .getOrElse { exception -> - val errorText = - when (exception) { - is MaxIterationsExceededException -> - requireContext().getString(R.string.pwgen_max_iterations_exceeded) - is NoCharactersIncludedException -> - requireContext().getString(R.string.pwgen_no_chars_error) - is PasswordLengthTooShortException -> - requireContext().getString(R.string.pwgen_length_too_short_error) - else -> requireContext().getString(R.string.pwgen_some_error_occurred) - } - Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show() - "" - } - } - - private fun isChecked(@IdRes id: Int): Boolean { - return requireDialog().findViewById<CheckBox>(id).isChecked - } - - private fun getSelectedOptions(): List<PasswordOption> { - return listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }, - ) - } - - private fun getLength(): Int { - val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString() - return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH - } - - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated - * passwords. - */ - private fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.entries) { - putBoolean(possibleOption.key, possibleOption in options) - } - putInt("length", targetLength) - } - return true - } -} diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt deleted file mode 100644 index 58c39968..00000000 --- a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.dialogs - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.databinding.DialogTextInputBinding -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** - * General purpose [DialogFragment] that can be used to accept a single text input. It provides a - * configurable title and text input hint through the instantiation helper at - * [TextInputDialog.newInstance]. Typical usage would look something like this: - * ```kotlin - * val dialog = TextInputDialog.newInstance(getString(R.string.dialog_title), getString(R.string.dialog_hint)) - * dialog.show(supportFragmentManager, "text_input_dialog") - * dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle -> - * doSomething(bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT)) - * } - * ``` - */ -class TextInputDialog : DialogFragment() { - private val binding by unsafeLazy { DialogTextInputBinding.inflate(layoutInflater) } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(arguments?.getString(BUNDLE_KEY_TITLE)) - arguments?.getString(BUNDLE_KEY_HINT)?.let { hint -> - binding.textInputLayout.isHintEnabled = true - binding.textInputLayout.isHintAnimationEnabled = true - binding.textInputLayout.hint = hint - } - builder.setPositiveButton(android.R.string.ok) { dialogInterface, _ -> - setFragmentResult(REQUEST_KEY, bundleOf(BUNDLE_KEY_TEXT to binding.editText.text.toString())) - dialogInterface.dismiss() - } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - return dialog - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - companion object { - const val REQUEST_KEY = "text_input_dialog" - const val BUNDLE_KEY_TEXT = "text" - private const val BUNDLE_KEY_TITLE = "title" - private const val BUNDLE_KEY_HINT = "hint" - - fun newInstance(title: String, hint: String? = null): TextInputDialog { - val args = bundleOf(BUNDLE_KEY_TITLE to title, BUNDLE_KEY_HINT to hint) - val dialog = TextInputDialog() - dialog.arguments = args - return dialog - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt new file mode 100644 index 00000000..6d9f9c9f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import android.app.Activity +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.GetContent +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.fragment.app.FragmentActivity +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.copyPasswordToClipboard +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.list.FolderPicker +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.snackbar.Snackbar +import com.google.zxing.integration.android.IntentIntegrator +import java.io.File +import kotlinx.coroutines.launch + +/** + * The editor: wires [EditorScreen] to the [viewModel], the generator sheet, the folder picker, OTP + * import and the save path (checks, recipients from `.gpg-id`, encryption, write, git commit). + * [initial] fills the form the first time; null means the entry to edit is no longer in memory + * (process death) and the editor closes. [original] is the file being edited, null for a new entry. + * [generate] opens the generator at once (autofill's "generate password"). [onClose] leaves without + * saving; [onSaved] runs after the commit. + */ +@Composable +fun EditorDestination( + activity: FragmentActivity, + initial: EditorForm?, + original: File?, + onClose: () -> Unit, + onSaved: (SaveResult.Saved) -> Unit, + modifier: Modifier = Modifier, + generate: Boolean = false, + viewModel: EditorViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + // ponytail: filled once per ViewModel, so a recreated activity keeps the edits; an edit restored + // after process death has nothing to fill from (the entry view's plaintext is gone) and closes, + // back to the entry view's passphrase prompt. + val first = remember { initial != null && viewModel.start(initial) } + val lost = !viewModel.started + LaunchedEffect(lost) { if (lost) onClose() } + if (lost) return + + val keyboard = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + var sheet by rememberSaveable { mutableStateOf(first && generate) } + var picker by rememberSaveable { mutableStateOf(false) } + var otp by rememberSaveable { mutableStateOf(false) } + var failure by remember { mutableStateOf<String?>(null) } + val form = viewModel.form + val hasOtp = remember(form.extra) { viewModel.hasOtp(form.extra) } + val hasCamera = remember { + activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + } + val showPassword = remember { + activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + } + val classicFirst = remember { + activity.sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "classic" + } + + fun say(message: Int) { + activity.snackbar(message = activity.getString(message)) + } + + fun addLine(line: String) { + viewModel.form = viewModel.form.copy(extra = withLine(viewModel.form.extra, line)) + } + + // The one way out without saving: only while no save runs, keyboard and focus dropped first. + fun close() { + if (viewModel.saving) return + keyboard?.hide() + focusManager.clearFocus() + onClose() + } + + fun save(copy: Boolean) { + if (viewModel.saving) return + val current = viewModel.form + val target = entryFile(root, current) + val problem = + formError(current) + ?: R.string.message_error_destination_outside_repo.takeUnless { + target.isInsideRepository() + } + if (problem != null) return say(problem) + // Recipients from the nearest .gpg-id, as pass does; null: an invalid ID, already reported. + val ids = + activity.getPGPIdentifiers(target.parentFile?.relativeTo(root)?.path.orEmpty()) ?: return + activity.lifecycleScope.launch { + val result = viewModel.save(ids, target, original, activity, copy) + if (result is SaveResult.Failed) failure = activity.getString(result.message, result.arg) + } + } + + // Finishes a save: run again by an editor recreated in the middle (a theme or locale change + // cancels this effect), so the commit and onSaved always happen; the copy and notice only once. + val saved = viewModel.saved + LaunchedEffect(saved) { + if (saved == null) return@LaunchedEffect + if (viewModel.announce()) { + if (viewModel.copyOnSave) activity.copyPasswordToClipboard(saved.form.password) + saved.staleOriginal?.let { + activity.snackbar( + message = + activity.getString( + R.string.password_creation_file_delete_fail_message, + it.nameWithoutExtension, + ), + length = Snackbar.LENGTH_LONG, + ) + } + } + // ponytail: commit failures are not shown, as for the list's move and delete. A commit cut + // short by recreation runs again; one that already went through finds nothing to commit. + activity.commitChange( + activity.getString( + if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text, + saved.file.relativeTo(root).path.removeSuffix(".gpg"), + ) + ) + keyboard?.hide() + focusManager.clearFocus() + viewModel.left() + onSaved(saved) + } + + BackHandler(onBack = ::close) + + val scan = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + val text = IntentIntegrator.parseActivityResult(result.resultCode, result.data)?.contents + if (result.resultCode == Activity.RESULT_OK && text != null) { + addLine("$text\n") + say(R.string.otp_import_success) + } else { + say(R.string.otp_import_failure_generic) + } + } + val image = + rememberLauncherForActivityResult(GetContent()) { uri -> + if (uri == null) { + say(R.string.otp_import_failure_no_selection) + } else { + scope.launch { + val text = viewModel.readQr(uri, activity) + if (text != null) { + addLine(text) + say(R.string.otp_import_success) + } else { + say(R.string.otp_import_failure_generic) + } + } + } + } + + Box(modifier) { + EditorScreen( + form = form, + editing = original != null, + saving = viewModel.saving, + showOtpActions = !hasOtp, + initiallyRevealed = showPassword, + focus = + when { + !first -> EditorFocus.None + form.name.isEmpty() -> EditorFocus.Name + original == null -> EditorFocus.Username + else -> EditorFocus.None + }, + onFormChange = { viewModel.form = it }, + onClose = ::close, + onSave = ::save, + onChangeFolder = { picker = true }, + onGenerate = { sheet = true }, + onScanQr = + if (hasCamera) { + { + scan.launch( + IntentIntegrator(activity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + .createScanIntent() + ) + } + } else null, + onQrImage = { image.launch("image/*") }, + onManualOtp = { otp = true }, + ) + if (sheet) + GeneratorSheet( + classicFirst = classicFirst, + random = viewModel.randomSpec(activity), + diceware = viewModel.dicewareSpec(), + onRandom = { spec -> viewModel.generateRandom(spec, activity) }, + onDiceware = viewModel::generateDiceware, + onUse = { password -> + viewModel.form = viewModel.form.copy(password = password) + sheet = false + }, + onDismiss = { sheet = false }, + ) + if (picker) + FolderPicker( + root = root, + start = form.dir, + onPick = { dir -> + viewModel.form = viewModel.form.copy(dir = dir) + picker = false + }, + onDismiss = { picker = false }, + ) + if (otp) + OtpDialog( + onAdd = { secret, account -> + totpUri(secret, account)?.let(::addLine) + otp = false + }, + onDismiss = { otp = false }, + ) + failure?.let { message -> + AlertDialog( + onDismissRequest = { failure = null }, + title = { Text(stringResource(R.string.password_creation_file_fail_title)) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = { failure = null }) { Text(stringResource(R.string.dialog_ok)) } + }, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt new file mode 100644 index 00000000..d7437df3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.passgen.random.MaxIterationsExceededException +import app.passwordstore.passgen.random.NoCharactersIncludedException +import app.passwordstore.passgen.random.PasswordGenerator +import app.passwordstore.passgen.random.PasswordLengthTooShortException +import app.passwordstore.passgen.random.PasswordOption +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.runCatching +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name] + * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route. + */ +@Immutable +data class EditorForm( + val name: String = "", + val dir: String = "", + val password: String = "", + val username: String = "", + val extra: String = "", +) + +/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */ +fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm = + EditorForm( + name = name, + dir = dir, + password = entry.password.orEmpty(), + username = entry.username.orEmpty(), + extra = entry.extraContentWithoutUsername, + ) + +/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */ +fun passContent(form: EditorForm): String { + val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}" + return "${form.password}$username\n${form.extra}" +} + +/** Why [form] cannot be saved, as a string resource, or null when it can. */ +@StringRes +fun formError(form: EditorForm): Int? = + when { + form.name.isBlank() -> R.string.file_toast_text + '/' in form.name -> R.string.invalid_filename_text + form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text + else -> null + } + +/** Where [form] is saved in the store at [root]. */ +fun entryFile(root: File, form: EditorForm): File = + File(File(root, form.dir), "${form.name.trim()}.gpg").toPath().normalize().toFile() + +/** True when saving to [target] would replace a file other than the [original] being edited. */ +fun overwrites(target: File, original: File?): Boolean = + target.exists() && target.canonicalPath != original?.canonicalPath + +/** + * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a + * failed write never truncates an existing entry. + */ +fun writeEntry(target: File, bytes: ByteArray) { + val dir = target.parentFile ?: throw IOException("No folder for $target") + dir.mkdirs() + val temp = File(dir, ".${target.name}.tmp") + try { + FileOutputStream(temp).use { + it.write(bytes) + it.fd.sync() + } + Files.move( + temp.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } +} + +/** [extra] with [line] added on a line of its own. */ +fun withLine(extra: String, line: String): String = + if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line" + +/** + * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces + * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret. + */ +fun totpUri(secret: String, issuer: String): String? { + val key = secret.filterNot(Char::isWhitespace) + if (key.isEmpty()) return null + fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20") + val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}" + return "otpauth://totp?secret=${encode(key)}$tail" +} + +/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */ +@Immutable +data class RandomSpec( + val numerals: Boolean = true, + val symbols: Boolean = false, + val uppercase: Boolean = true, + val lowercase: Boolean = true, + val ambiguous: Boolean = true, + val pronounceable: Boolean = false, + val length: Int = 20, +) + +/** The generator's options for this spec, negated where the generator asks what to leave out. */ +fun RandomSpec.options(): List<PasswordOption> = + listOfNotNull( + PasswordOption.NoDigits.takeUnless { numerals }, + PasswordOption.AtLeastOneSymbol.takeIf { symbols }, + PasswordOption.NoUppercaseLetters.takeUnless { uppercase }, + PasswordOption.NoLowercaseLetters.takeUnless { lowercase }, + PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous }, + PasswordOption.FullyRandom.takeUnless { pronounceable }, + ) + +/** A random password for [spec], or the string resource saying why there is none. */ +fun randomPassword(spec: RandomSpec): Result<String, Int> = + runCatching { PasswordGenerator.generate(spec.options(), spec.length) } + .mapError { e -> + when (e) { + is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded + is NoCharactersIncludedException -> R.string.pwgen_no_chars_error + is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error + else -> R.string.pwgen_some_error_occurred + } + } + +/** The diceware generator's options; defaults are the old dialog's. */ +@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-') diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt new file mode 100644 index 00000000..bb8af01a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.R as composeR +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** Which field takes focus when the editor first opens. */ +enum class EditorFocus { + None, + Name, + Username, +} + +/** + * The editor: name, folder (breadcrumbs; they and "Change" open the picker), password with reveal + * and generator, username, free-text extra content (the pass format as text) and the OTP actions, + * shown while the extras hold no TOTP secret ([showOtpActions]). [onScanQr] is null on a device + * without a camera. Only the reveal toggle and the menus keep state here. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditorScreen( + form: EditorForm, + editing: Boolean, + saving: Boolean, + showOtpActions: Boolean, + initiallyRevealed: Boolean, + focus: EditorFocus, + onFormChange: (EditorForm) -> Unit, + onClose: () -> Unit, + onSave: (copy: Boolean) -> Unit, + onChangeFolder: () -> Unit, + onGenerate: () -> Unit, + onScanQr: (() -> Unit)?, + onQrImage: () -> Unit, + onManualOtp: () -> Unit, + modifier: Modifier = Modifier, +) { + var revealed by remember { mutableStateOf(initiallyRevealed) } + var menu by remember { mutableStateOf(false) } + var qrMenu by remember { mutableStateOf(false) } + val nameFocus = remember { FocusRequester() } + val usernameFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + when (focus) { + EditorFocus.Name -> nameFocus.requestFocus() + EditorFocus.Username -> usernameFocus.requestFocus() + EditorFocus.None -> Unit + } + } + val field = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + stringResource(if (editing) R.string.editor_edit_title else R.string.editor_new_title) + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Filled.Close, stringResource(R.string.editor_close)) + } + }, + actions = { + TextButton(onClick = { onSave(false) }, enabled = !saving) { + Text(stringResource(R.string.crypto_save)) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.crypto_save_and_copy)) }, + onClick = { + menu = false + onSave(true) + }, + enabled = !saving, + ) + } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + OutlinedTextField( + value = form.name, + onValueChange = { onFormChange(form.copy(name = it)) }, + modifier = field.focusRequester(nameFocus), + label = { Text(stringResource(R.string.crypto_name_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + Text( + stringResource(R.string.editor_folder), + field, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + BreadcrumbBar( + crumbsFor(form.dir), + stringResource(R.string.list_store_root), + onCrumbClick = { onChangeFolder() }, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onChangeFolder, modifier = Modifier.padding(end = SpacingMedium)) { + Text(stringResource(R.string.editor_change_folder)) + } + } + OutlinedTextField( + value = form.password, + onValueChange = { onFormChange(form.copy(password = it)) }, + modifier = field, + label = { Text(stringResource(R.string.crypto_pass_label)) }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace), + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + trailingIcon = { + Row { + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) composeR.drawable.baseline_visibility_off_24 + else composeR.drawable.baseline_visibility_24 + ), + contentDescription = + stringResource( + if (revealed) R.string.entry_hide_password else R.string.entry_show_password + ), + ) + } + IconButton(onClick = onGenerate) { + Icon( + painterResource(R.drawable.ic_password_24px), + contentDescription = stringResource(R.string.pwgen_title), + ) + } + } + }, + ) + OutlinedTextField( + value = form.username, + onValueChange = { onFormChange(form.copy(username = it)) }, + modifier = field.focusRequester(usernameFocus), + label = { Text(stringResource(R.string.crypto_username_hint)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = form.extra, + onValueChange = { onFormChange(form.copy(extra = it)) }, + modifier = field, + label = { Text(stringResource(R.string.crypto_extra_label)) }, + minLines = 3, + // A password keyboard does not learn what is typed, as the old textVisiblePassword. + keyboardOptions = + KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password), + ) + if (showOtpActions) + Row(field, horizontalArrangement = Arrangement.spacedBy(SpacingMedium)) { + Box { + OutlinedButton(onClick = { qrMenu = true }) { + Icon(painterResource(R.drawable.ic_qr_code_scanner), contentDescription = null) + Text(stringResource(R.string.editor_otp_qr), Modifier.padding(start = SpacingMedium)) + } + DropdownMenu(expanded = qrMenu, onDismissRequest = { qrMenu = false }) { + if (onScanQr != null) + DropdownMenuItem( + text = { Text(stringResource(R.string.otp_import_qr_code)) }, + onClick = { + qrMenu = false + onScanQr() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.otp_import_from_file)) }, + onClick = { + qrMenu = false + onQrImage() + }, + ) + } + } + OutlinedButton(onClick = onManualOtp) { Text(stringResource(R.string.editor_otp_manual)) } + } + } + } +} + +/** + * Asks for a TOTP secret and an optional account (the URI's issuer), as the old manual OTP dialog + * did. Both live in [remember] only, and the window is FLAG_SECURE. + */ +@Composable +fun OtpDialog( + onAdd: (secret: String, account: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var secret by remember { mutableStateOf("") } + var account by remember { mutableStateOf("") } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.add_otp)) }, + text = { + Column { + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.otp_import_manual_hint_secret)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = account, + onValueChange = { account = it }, + label = { Text(stringResource(R.string.otp_import_manual_hint_account)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false), + ) + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton(onClick = { onAdd(secret, account) }, enabled = secret.isNotBlank()) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt new file mode 100644 index 00000000..6d2fbaec --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.injection.prefs.PasswordGeneratorPreferences +import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator +import app.passwordstore.passgen.random.PasswordOption +import app.passwordstore.ui.list.moveHistory +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import com.google.zxing.BinaryBitmap +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +/** What a save did. */ +sealed interface SaveResult { + + /** + * Written to [file] from [form]. [staleOriginal] is the old file of a rename or move, left behind + * because it could not be deleted. + */ + data class Saved(val file: File, val form: EditorForm, val staleOriginal: File?) : SaveResult + + /** Nothing was written; [message] (a string resource, formatted with [arg]) says why. */ + data class Failed(@StringRes val message: Int, val arg: String = "") : SaveResult +} + +/** + * The editor's state and work: the [form] (plaintext, in memory only: never in saved state, reset + * when this ViewModel is cleared), the generators and their stored options, QR decoding, and the + * save (encrypt, write, replace the original). Holds no Context; calls take one and use the + * application context. + */ +@HiltViewModel +class EditorViewModel +@Inject +constructor( + private val repository: CryptoRepository, + private val entryFactory: PasswordEntry.Factory, + private val diceware: dagger.Lazy<DicewarePassphraseGenerator>, + @PasswordGeneratorPreferences private val pwgenPrefs: SharedPreferences, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + /** The fields being edited. */ + var form by mutableStateOf(EditorForm()) + + /** + * Set while a save runs, and after one succeeded (the editor is leaving): saves and closes wait. + */ + var saving by mutableStateOf(false) + private set + + /** + * The successful save, kept until the editor has committed it and left ([left]), so an editor + * recreated in the middle finishes the job. Memory only: it holds the plaintext form. + */ + var saved by mutableStateOf<SaveResult.Saved?>(null) + private set + + /** Whether [saved] is to be copied to the clipboard ("Save and copy"). */ + var copyOnSave = false + private set + + private var announced = false + + /** Whether [start] filled the form. */ + var started = false + private set + + /** Fills the form the first time only and returns true then; a recreated activity keeps edits. */ + fun start(initial: EditorForm): Boolean { + if (started) return false + started = true + form = initial + return true + } + + /** + * True when [extra] already holds a TOTP secret (the OTP actions hide), as the old editor did. + */ + fun hasOtp(extra: String): Boolean = + entryFactory.create("PLACEHOLDER\n$extra".encodeToByteArray()).hasTotp() + + /** The random generator's options as the old dialog stored them. */ + fun randomSpec(context: Context): RandomSpec { + val prefs = context.randomPrefs() + return RandomSpec( + numerals = !prefs.getBoolean(PasswordOption.NoDigits.key, false), + symbols = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false), + uppercase = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false), + lowercase = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false), + ambiguous = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false), + pronounceable = !prefs.getBoolean(PasswordOption.FullyRandom.key, true), + length = prefs.getInt(PreferenceKeys.LENGTH, 20), + ) + } + + /** + * Stores [spec], as the old dialog did on every generation, then generates off the main thread. + */ + suspend fun generateRandom(spec: RandomSpec, context: Context): Result<String, Int> { + val options = spec.options() + context.randomPrefs().edit { + PasswordOption.entries.forEach { putBoolean(it.key, it in options) } + putInt(PreferenceKeys.LENGTH, spec.length) + } + return withContext(dispatcherProvider.default()) { randomPassword(spec) } + } + + /** The diceware generator's options as the old dialog stored them. */ + fun dicewareSpec(): DicewareSpec = + DicewareSpec( + words = pwgenPrefs.getInt(PreferenceKeys.DICEWARE_LENGTH, 5), + separator = pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR)?.firstOrNull() ?: '-', + ) + + /** Stores [spec] and generates a passphrase; the word list is read on first use, off main. */ + suspend fun generateDiceware(spec: DicewareSpec): String { + pwgenPrefs.edit { + putInt(PreferenceKeys.DICEWARE_LENGTH, spec.words) + putString(PreferenceKeys.DICEWARE_SEPARATOR, spec.separator.toString()) + } + return withContext(dispatcherProvider.io()) { + diceware.get().generatePassphrase(spec.words, spec.separator) + } + } + + /** The text of the QR code in the image at [uri], or null when it has none. Off main. */ + suspend fun readQr(uri: Uri, context: Context): String? { + val resolver = context.applicationContext.contentResolver + return withContext(dispatcherProvider.io()) { + runCatching { + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) + .copy(Bitmap.Config.ARGB_8888, false) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(resolver, uri) + } + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels) + QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text + } + .get() + } + } + + /** + * Encrypts the form to [ids] and writes it to [target], replacing [original] when the entry was + * renamed or moved. Nothing is written (not even the folder) when [target] would replace another + * entry or when encryption fails; a recipient whose key is missing fails it (see + * CryptoRepository.encrypt). The caller checked [formError] and that [target] is inside the + * store. A success lands in [saved], with [copy] in [copyOnSave]. Returns null when a save + * already ran or is running. + */ + suspend fun save( + ids: List<PGPIdentifier>, + target: File, + original: File?, + context: Context, + copy: Boolean, + ): SaveResult? { + if (saving) return null + saving = true + copyOnSave = copy + try { + return write(ids, target, original, context.applicationContext) + } finally { + // Stays set after a success (the editor is leaving); cleared on failure or cancellation. + if (saved == null) saving = false + } + } + + /** True the first time only: the copy and notices of [saved] run once, across recreation. */ + fun announce(): Boolean = !announced.also { announced = true } + + /** The editor committed [saved] and left: nothing is left to finish. */ + fun left() { + saved = null + } + + override fun onCleared() { + form = EditorForm() + saved = null + } + + private suspend fun write( + ids: List<PGPIdentifier>, + target: File, + original: File?, + app: Context, + ): SaveResult { + val form = form + if (withContext(dispatcherProvider.io()) { overwrites(target, original) }) + return SaveResult.Failed(R.string.password_creation_duplicate_error) + val out = ByteArrayOutputStream() + val encrypted = repository.encrypt(ids, passContent(form).byteInputStream(), out) + if (!encrypted.isOk) { + logcat(ERROR) { encrypted.error.asLog() } + return SaveResult.Failed( + R.string.password_creation_encrypt_fail_message, + encrypted.error.message.orEmpty(), + ) + } + // NonCancellable: a recreated activity must not stop between writing the target and deleting + // the original, which would leave a rename that cannot be retried ("duplicate"). + return withContext(NonCancellable + dispatcherProvider.io()) { + try { + writeEntry(target, out.toByteArray()) + } catch (e: IOException) { + logcat(ERROR) { e.asLog() } + return@withContext SaveResult.Failed(R.string.password_creation_file_write_fail_message) + } + var stale: File? = null + if (original != null && original.canonicalPath != target.canonicalPath) { + if (original.delete() || !original.exists()) { + AutofillMatcher.updateMatches(app, moveFromTo = mapOf(original to target)) + app.moveHistory(original, target) + } else { + stale = original + } + } + // Set here, inside NonCancellable: a written entry is never forgotten by a cancelled caller. + SaveResult.Saved(target, form, stale).also { saved = it } + } + } + + private fun Context.randomPrefs(): SharedPreferences = + getSharedPreferences(RANDOM_PREFS, Context.MODE_PRIVATE) + + private companion object { + /** The old random generator dialog's preferences file. */ + const val RANDOM_PREFS = "PasswordGenerator" + } +} diff --git a/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt new file mode 100644 index 00000000..67a4963a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.window.SecureFlagPolicy +import app.passwordstore.R +import app.passwordstore.passgen.random.PasswordGenerator +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.compose.theme.SpacingSmall +import com.github.michaelbull.result.Result + +private const val RANDOM_TAB = 0 +private const val DICEWARE_TAB = 1 + +/** + * The password generator as a bottom sheet: a random tab and a diceware tab with the old dialogs' + * options, starting from [random] and [diceware]. [classicFirst] opens the random tab (the + * "Password generator" setting). Every change generates again; [onUse] takes the password shown. + * The password lives in [remember] only, and the sheet's window is FLAG_SECURE. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeneratorSheet( + classicFirst: Boolean, + random: RandomSpec, + diceware: DicewareSpec, + onRandom: suspend (RandomSpec) -> Result<String, Int>, + onDiceware: suspend (DicewareSpec) -> String, + onUse: (String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var tab by remember { mutableIntStateOf(if (classicFirst) RANDOM_TAB else DICEWARE_TAB) } + var randomSpec by remember { mutableStateOf(random) } + var dicewareSpec by remember { mutableStateOf(diceware) } + var length by remember { mutableStateOf(random.length.toString()) } + var words by remember { mutableStateOf(diceware.words.toString()) } + var separator by remember { mutableStateOf(diceware.separator.toString()) } + var rolls by remember { mutableIntStateOf(0) } + var password by remember { mutableStateOf("") } + var problem by remember { mutableStateOf<Int?>(null) } + LaunchedEffect(tab, randomSpec, dicewareSpec, rolls) { + if (tab == RANDOM_TAB) { + val result = onRandom(randomSpec) + password = if (result.isOk) result.value else "" + problem = if (result.isOk) null else result.error + } else { + password = onDiceware(dicewareSpec) + problem = null + } + } + ModalBottomSheet( + onDismissRequest = onDismiss, + modifier = modifier, + properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn), + ) { + PrimaryTabRow(selectedTabIndex = tab) { + Tab( + selected = tab == RANDOM_TAB, + onClick = { tab = RANDOM_TAB }, + text = { Text(stringResource(R.string.generator_random)) }, + ) + Tab( + selected = tab == DICEWARE_TAB, + onClick = { tab = DICEWARE_TAB }, + text = { Text(stringResource(R.string.generator_diceware)) }, + ) + } + Column( + Modifier.verticalScroll(rememberScrollState()).padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingSmall), + ) { + val shown = problem + Text( + if (shown == null) password else stringResource(shown), + style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace), + color = + if (shown == null) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.error, + ) + if (tab == RANDOM_TAB) { + CheckRow(R.string.pwgen_numerals, randomSpec.numerals) { + randomSpec = randomSpec.copy(numerals = it) + } + CheckRow(R.string.pwgen_symbols, randomSpec.symbols) { + randomSpec = randomSpec.copy(symbols = it) + } + CheckRow(R.string.pwgen_uppercase, randomSpec.uppercase) { + randomSpec = randomSpec.copy(uppercase = it) + } + CheckRow(R.string.pwgen_lowercase, randomSpec.lowercase) { + randomSpec = randomSpec.copy(lowercase = it) + } + CheckRow(R.string.pwgen_pronounceable, randomSpec.pronounceable) { + randomSpec = randomSpec.copy(pronounceable = it) + } + CheckRow(R.string.pwgen_ambiguous, randomSpec.ambiguous) { + randomSpec = randomSpec.copy(ambiguous = it) + } + OutlinedTextField( + value = length, + onValueChange = { text -> + // ponytail: at most 999 characters; the generator has no upper bound of its own. + val digits = text.filter(Char::isDigit).take(3) + length = digits + randomSpec = + randomSpec.copy(length = digits.toIntOrNull() ?: PasswordGenerator.DEFAULT_LENGTH) + }, + label = { Text(stringResource(R.string.pwgen_length)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } else { + OutlinedTextField( + value = words, + onValueChange = { text -> + // ponytail: at most 99 words. + val digits = text.filter(Char::isDigit).take(2) + words = digits + dicewareSpec = dicewareSpec.copy(words = digits.toIntOrNull()?.takeIf { it > 0 } ?: 5) + }, + label = { Text(stringResource(R.string.pwgen_length)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + OutlinedTextField( + value = separator, + onValueChange = { text -> + separator = text.take(1) + dicewareSpec = dicewareSpec.copy(separator = separator.firstOrNull() ?: '-') + }, + label = { Text(stringResource(R.string.pwgen_separator)) }, + singleLine = true, + ) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(SpacingMedium, Alignment.End), + ) { + TextButton(onClick = { rolls++ }) { Text(stringResource(R.string.pwgen_generate)) } + Button(onClick = { onUse(password) }, enabled = password.isNotEmpty() && problem == null) { + Text(stringResource(R.string.generator_use)) + } + } + } + } +} + +@Composable +private fun CheckRow(@StringRes label: Int, checked: Boolean, onChange: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth() + .toggleable(value = checked, role = Role.Checkbox, onValueChange = onChange), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(stringResource(label), Modifier.padding(start = SpacingMedium)) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt new file mode 100644 index 00000000..02b112f4 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import android.app.Activity +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.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.input.PasswordVisualTransformation +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.hilt.navigation.compose.hiltViewModel +import app.passwordstore.R +import app.passwordstore.data.passfile.Totp +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.copyPasswordToClipboard +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath +import app.passwordstore.ui.main.LaunchActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File + +/** + * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts and the + * clipboard. [onClose] leaves the destination; [onEdit] opens the editor on the decrypted entry. + * The host keeps FLAG_SECURE on while this is composed. + */ +@Composable +fun EntryDestination( + activity: MainActivity, + file: File, + onClose: () -> Unit, + onEdit: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EntryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + + // Routes come from the app's own listing or from LaunchActivity's check; check once more. + val inRepo = remember(file) { LaunchActivity.isFileInRepo(file.path, root) } + LaunchedEffect(inRepo) { if (!inRepo) onClose() } + if (!inRepo) return + + fun unlock() { + val ids = + activity.getPGPIdentifiers(getParentPath(file.absolutePath, root.absolutePath)) ?: return + if (viewModel.cacheEnabled && BiometricAuthenticator.canAuthenticate(activity)) { + BiometricAuthenticator.authenticate( + activity, + R.string.biometric_prompt_title_gpg_passphrase_cache, + ) { result -> + viewModel.unlock(file, ids, result, activity) + } + } else { + viewModel.unlock(file, ids, BiometricResult.CanceledByUser, activity) + } + } + + // ponytail: after process death during key import, the unlock flow can start + // twice or a prompt can be left over the list; rare, no secret exposed. Fix by tracking + // whether the flow already started if it shows up. + val keyImport = + rememberLauncherForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) unlock() else onClose() + } + + // ponytail: a theme or locale change while the biometric prompt is up loses that prompt's + // callback (the flow already counts as started); back out and reopen the entry. + LaunchedEffect(Unit) { if (viewModel.start() && viewModel.checkKeys()) unlock() } + LaunchedEffect(viewModel.closed) { if (viewModel.closed) onClose() } + + val entry = viewModel.entry + LaunchedEffect(entry) { + val password = entry?.password + if (viewModel.takeCopyOnDecrypt() && !password.isNullOrBlank()) { + activity.copyPasswordToClipboard(password) + } + } + val fields = remember(entry) { entry?.let(::entryFields) } + val totp by + produceState<Totp?>(null, entry) { + if (entry != null && entry.hasTotp()) entry.totp.collect { value = it } + } + val folder = + remember(file) { file.canonicalFile.parentFile?.relativeTo(root.canonicalFile)?.path.orEmpty() } + val showPassword = remember { + activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + } + + Box(modifier) { + EntryScreen( + name = file.nameWithoutExtension, + folder = folder, + fields = fields, + otp = totp?.value, + otpSecondsLeft = totp?.remainingTime?.inWholeSeconds ?: 0L, + otpFractionLeft = totp?.fractionLeft() ?: 0f, + showPassword = showPassword, + onBack = onClose, + onEdit = { if (entry != null) onEdit() }, + onShare = { fields?.password?.let { activity.startActivity(shareIntent(activity, it)) } }, + onCopyName = { activity.copyTextToClipboard(file.nameWithoutExtension) }, + onCopySecret = { activity.copyPasswordToClipboard(it) }, + onCopy = { activity.copyTextToClipboard(it) }, + ) + + when (viewModel.prompt) { + EntryPrompt.None -> Unit + EntryPrompt.NoKeys -> + AlertDialog( + onDismissRequest = viewModel::cancel, + title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) }, + text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.dismissPrompt() + keyImport.launch(MainActivity.setupIntent(activity, SetupStart.PgpImport)) + } + ) { + Text(stringResource(R.string.button_label_import)) + } + }, + ) + EntryPrompt.Passphrase, + EntryPrompt.WrongPassphrase -> + key(viewModel.retries) { + PassphraseDialog( + wrong = viewModel.prompt == EntryPrompt.WrongPassphrase, + cacheEnabled = viewModel.cacheEnabled, + clearCache = viewModel.clearCache, + onSubmit = { passphrase, clear -> viewModel.submit(passphrase, clear, activity) }, + onCancel = viewModel::cancel, + ) + } + } + } +} + +/** + * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember` + * and the dialog's window is FLAG_SECURE. + */ +@Composable +internal fun PassphraseDialog( + wrong: Boolean, + cacheEnabled: Boolean, + clearCache: Boolean, + onSubmit: (passphrase: String, clearCache: Boolean) -> Unit, + onCancel: () -> Unit, +) { + // remember, never rememberSaveable: the passphrase must not reach saved state. + var passphrase by remember { mutableStateOf("") } + var clear by remember { mutableStateOf(clearCache) } + var error by remember { mutableStateOf(wrong) } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onCancel, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.password)) }, + text = { + Column { + OutlinedTextField( + value = passphrase, + onValueChange = { + passphrase = it + error = false + }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.password)) }, + supportingText = + if (error) { + { Text(stringResource(R.string.git_operation_wrong_password)) } + } else null, + isError = error, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onSubmit(passphrase, clear) }), + ) + if (cacheEnabled) + Row( + Modifier.toggleable( + value = clear, + role = Role.Checkbox, + onValueChange = { clear = it }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = clear, onCheckedChange = null) + Text(stringResource(R.string.clear_cached_password_on_screen_off)) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton(onClick = { onSubmit(passphrase, clear) }) { + Text(stringResource(R.string.dialog_ok)) + } + }, + ) +} + +/** Always a chooser, so the user can still cancel. */ +private fun shareIntent(activity: Activity, password: String): Intent = + Intent.createChooser( + Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, password).setType("text/plain"), + activity.getText(R.string.send_plaintext_password_to), + ) diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt new file mode 100644 index 00000000..8dbc9eb9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import androidx.compose.runtime.Immutable +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.Totp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * What the entry view shows. [password] and [username] open the first group (the one-time code + * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file + * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values + * are null, so their rows are not shown. + */ +@Immutable +data class EntryFields( + val password: String?, + val username: String?, + val extras: ImmutableList<Pair<String, String>>, +) + +fun entryFields(entry: PasswordEntry): EntryFields = + EntryFields( + password = entry.password?.takeIf { it.isNotBlank() }, + username = entry.username?.takeIf { it.isNotBlank() }, + extras = entry.extraContent.toList().toImmutableList(), + ) + +/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */ +fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat() diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt new file mode 100644 index 00000000..d4860819 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import app.passwordstore.R +import app.passwordstore.ui.compose.FieldRow +import app.passwordstore.ui.compose.FieldRowLabels +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.LetterAvatar +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium + +/** + * One entry: header (avatar, name, [folder] relative to the store root, "" at the root), then the + * password, username and one-time code, then the extra fields. [fields] is null until decrypted; + * [otp] is null when the entry has no TOTP. [onCopySecret] (password, one-time code) is separate + * from [onCopy] because only those copies start the clipboard clear timer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EntryScreen( + name: String, + folder: String, + fields: EntryFields?, + otp: String?, + otpSecondsLeft: Long, + otpFractionLeft: Float, + showPassword: Boolean, + onBack: () -> Unit, + onEdit: () -> Unit, + onShare: () -> Unit, + onCopyName: () -> Unit, + onCopySecret: (String) -> Unit, + onCopy: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var menu by remember { mutableStateOf(false) } + val labels = + FieldRowLabels( + copy = stringResource(R.string.copy_label), + show = stringResource(R.string.entry_show_password), + hide = stringResource(R.string.entry_hide_password), + ) + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + actions = { + if (fields != null) + IconButton(onClick = onEdit) { + Icon( + painterResource(R.drawable.ic_edit_24dp), + contentDescription = stringResource(R.string.edit_password), + ) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.entry_copy_name)) }, + onClick = { + menu = false + onCopyName() + }, + ) + if (fields?.password != null) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_plaintext)) }, + onClick = { + menu = false + onShare() + }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_share_24dp), contentDescription = null) + }, + ) + } + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + Row( + Modifier.fillMaxWidth().padding(horizontal = SpacingLarge, vertical = SpacingMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + LetterAvatar(name) + Column(Modifier.weight(1f).padding(start = SpacingLarge)) { + Text(name, style = MaterialTheme.typography.titleLarge) + Text( + if (folder.isEmpty()) stringResource(R.string.list_store_root) else folder, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (fields != null) { + if (fields.password != null || fields.username != null || otp != null) + GroupedSurface(Modifier.fillMaxWidth()) { + if (fields.password != null) + FieldRow( + label = stringResource(R.string.password), + value = fields.password, + labels = labels.copy(copy = stringResource(R.string.copy_password)), + masked = true, + initiallyRevealed = showPassword, + onCopy = { onCopySecret(fields.password) }, + ) + if (fields.username != null) + FieldRow( + label = stringResource(R.string.username), + value = fields.username, + labels = labels, + onCopy = { onCopy(fields.username) }, + ) + if (otp != null) { + FieldRow( + label = stringResource(R.string.otp_label_format, otpSecondsLeft), + value = otp, + labels = labels, + onCopy = { onCopySecret(otp) }, + ) + LinearProgressIndicator( + progress = { otpFractionLeft }, + modifier = + Modifier.fillMaxWidth() + .padding(start = SpacingLarge, end = SpacingLarge, bottom = SpacingMedium), + ) + } + } + if (fields.extras.isNotEmpty()) + GroupedSurface(Modifier.fillMaxWidth()) { + fields.extras.forEach { (label, value) -> + key(label) { FieldRow(label, value, labels, onCopy = { onCopy(value) }) } + } + } + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt new file mode 100644 index 00000000..6296a9b6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.Application.Companion.screenWasOff +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository +import app.passwordstore.data.crypto.PGPPassphraseCache +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.autofill.cachesPassphrase +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache +import app.passwordstore.util.features.Features +import app.passwordstore.util.settings.Constants +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +/** The dialog the entry destination shows, if any. */ +enum class EntryPrompt { + None, + NoKeys, + Passphrase, + WrongPassphrase, +} + +/** + * Decrypts one entry as the old decrypt screen did: passphrase cache, passphrase prompt with + * retries, then the auto-close timer. The decrypted [entry] lives only in memory: never in saved + * state or a route argument. The timer drops it, and so does popping the destination (its back + * stack entry clears this ViewModel). Holds no Context; calls take one and use the application + * context. + */ +@HiltViewModel +class EntryViewModel +@Inject +constructor( + private val repository: CryptoRepository, + private val entryFactory: PasswordEntry.Factory, + private val passphraseCache: PGPPassphraseCache, + private val features: Features, + @SettingsPreferences private val settings: SharedPreferences, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var entry by mutableStateOf<PasswordEntry?>(null) + private set + + var prompt by mutableStateOf(EntryPrompt.None) + private set + + /** Set when the destination must close: timer, cancelled prompt, too many wrong passphrases. */ + var closed by mutableStateOf(false) + private set + + /** Initial state of the passphrase dialog's "clear on screen off" box. */ + var clearCache by mutableStateOf(true) + private set + + val cacheEnabled: Boolean + get() = features.isEnabled(EnablePGPPassphraseCache) + + private var started = false + private var copyOnDecrypt = false + /** Failed-or-asked passphrase prompts so far; keys the dialog so each attempt starts fresh. */ + var retries = 0 + private set + + private var auth: BiometricResult = BiometricResult.CanceledByUser + private var ids: List<PGPIdentifier> = emptyList() + private var file: File? = null + + /** True the first time only: a recreated activity must not run the unlock flow again. */ + fun start(): Boolean { + if (started) return false + started = true + return true + } + + /** False, with the "no keys" prompt up, while no PGP key is imported. */ + suspend fun checkKeys(): Boolean { + val hasKeys = repository.hasKeys() + if (!hasKeys) prompt = EntryPrompt.NoKeys + return hasKeys + } + + /** Continues after the biometric prompt (or without one) with its [auth] result. */ + fun unlock(file: File, ids: List<PGPIdentifier>, auth: BiometricResult, context: Context) { + this.file = file + this.ids = ids + this.auth = auth + val app = context.applicationContext + viewModelScope.launch { next(isError = false, app) } + } + + fun submit(passphrase: String, clearCache: Boolean, context: Context) { + if (prompt == EntryPrompt.None) return + prompt = EntryPrompt.None + this.clearCache = clearCache + val app = context.applicationContext + viewModelScope.launch { + if (decrypt(passphrase, app) && cachesPassphrase(cacheEnabled, auth)) { + runCatching { + passphraseCache.cachePassphrase(app, ids.first(), passphrase) + settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) } + } + .onFailure { e -> logcat { e.asLog() } } + } + } + } + + fun dismissPrompt() { + prompt = EntryPrompt.None + } + + fun cancel() { + prompt = EntryPrompt.None + closed = true + } + + /** True once after a decryption when "Automatically copy password" is on. */ + fun takeCopyOnDecrypt(): Boolean = copyOnDecrypt.also { copyOnDecrypt = false } + + override fun onCleared() { + entry = null + } + + private suspend fun next(isError: Boolean, context: Context) { + when (auth) { + // Handled inside the biometric prompt. + BiometricResult.Retry -> Unit + is BiometricResult.Success -> { + // Clear the cache on first use after app start or after the screen was off; this also + // purges a stale cache after caching was disabled in the PGP settings. + clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true) + if (screenWasOff && clearCache) { + passphraseCache.clearAllCachedPassphrases(context) + screenWasOff = false + } + // A cached passphrase that just failed is not tried again (the old screen looped on it). + val cached = + if (isError) null else passphraseCache.retrieveCachedPassphrase(context, ids.first()) + if (cached != null) decrypt(cached, context) else ask(isError, context) + } + else -> ask(isError, context) + } + } + + private suspend fun ask(isError: Boolean, context: Context) { + if (retries >= MAX_RETRIES) { + closed = true + return + } + retries++ + if (withContext(dispatcherProvider.io()) { repository.isPasswordProtected(ids) }) { + prompt = if (isError) EntryPrompt.WrongPassphrase else EntryPrompt.Passphrase + } else { + decrypt(passphrase = "", context) + } + } + + /** Decrypts off the main thread; a failure goes back to [next] with an error. */ + private suspend fun decrypt(passphrase: String, context: Context): Boolean { + val source = file ?: return false + val bytes = withContext(dispatcherProvider.io()) { source.readBytes() } + val out = ByteArrayOutputStream() + val result = repository.decrypt(passphrase, ids, bytes.inputStream(), out) + if (!result.isOk) { + logcat(ERROR) { result.error.stackTraceToString() } + next(isError = true, context) + return false + } + copyOnDecrypt = settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false) + entry = withContext(dispatcherProvider.io()) { entryFactory.create(out.toByteArray()) } + startTimer() + return true + } + + /** Drops the entry and closes after the "Password copy timeout" setting (0 = never). */ + private fun startTimer() { + val timeout = + settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() + ?: Constants.DEFAULT_DECRYPTION_TIMEOUT + if (timeout == 0) return + viewModelScope.launch { + delay(timeout.seconds) + entry = null + closed = true + } + } + + private companion object { + const val MAX_RETRIES = 3 + } +} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt deleted file mode 100644 index cfeb6206..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG -import app.passwordstore.ui.passwords.PasswordStore -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { - - private lateinit var passwordList: SelectFolderFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - passwordList = SelectFolderFragment() - val args = Bundle() - args.putString( - PasswordStore.REQUEST_ARG_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ) - - passwordList.arguments = args - - supportActionBar?.show() - - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - - supportFragmentManager.commit { - replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - onBackPressedDispatcher.onBackPressed() - } - R.id.crypto_select -> selectFolder() - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) - setResult(RESULT_OK, intent) - finish() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt deleted file mode 100644 index 3cf12fdd..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.folderselect - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -import app.passwordstore.ui.passwords.PasswordStore -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.viewmodel.ListMode -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - - private val model: SearchableRepositoryViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.fab.hide() - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider).onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - binding.passRecycler.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = null - adapter = recyclerAdapter - } - - FastScrollerBuilder(binding.passRecycler).build() - registerForContextMenu(binding.passRecycler) - - val path = - requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) { - "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided" - } - model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) - lifecycleScope.launch { - model.searchResult.flowWithLifecycle(lifecycle).collect { result -> - recyclerAdapter.submitList(result.passwordItems) - } - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) - (requireActivity() as AppCompatActivity) - .supportActionBar - ?.setDisplayHomeAsUpEnabled(true) - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - val currentDir: File - get() = model.currentDir.value - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt deleted file mode 100644 index 695029d4..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.git.config - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.core.os.postDelayed -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.ActivityGitConfigBinding -import app.passwordstore.ui.dialogs.TextInputDialog -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.git.log.GitLogActivity -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.viewBinding -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.lib.RepositoryState - -class GitConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitConfigBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus() - else binding.gitUserName.setText(gitSettings.authorName) - binding.gitUserEmail.setText(gitSettings.authorEmail) - setupTools() - binding.saveButton.setOnClickListener { - val email = binding.gitUserEmail.text.toString().trim() - val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(getString(R.string.dialog_ok), null) - .show() - } else { - gitSettings.authorEmail = email - gitSettings.authorName = name - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT, - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - /** Sets up the UI components of the tools section. */ - private fun setupTools() { - val repo = PasswordRepository.repository - if (repo != null) { - binding.gitHeadStatus.text = headStatusMsg(repo) - // enable the abort button only if we're rebasing or merging - val needsAbort = - repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING - binding.gitAbortRebase.isEnabled = needsAbort - binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f - } - binding.gitLog.setOnClickListener { - runCatching { launchActivity(GitLogActivity::class.java) } - .onFailure { ex -> logcat(ERROR) { ex.asLog("Failed to start GitLogActivity") } } - } - binding.gitAbortRebase.setOnClickListener { abortRebase() } - binding.gitResetToRemote.setOnClickListener { resetToRemote() } - binding.gitGc.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.GC) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - } - - private fun resetToRemote() { - val dialog = - TextInputDialog.newInstance(getString(R.string.git_utils_reset_remote_branch_title)) - dialog.show(supportFragmentManager, "BRANCH_INPUT_DIALOG") - dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle -> - val result = bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT) - if (!result.isNullOrEmpty()) { - remoteBranch = result - lifecycleScope.launch { - launchGitOperation(GitOp.RESET) - .fold( - success = ::finishOnSuccessHandler, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - } - } - - private fun abortRebase() { - lifecycleScope.launch { - launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED) - .fold( - success = { - val branch = PasswordRepository.getCurrentBranch() - MaterialAlertDialogBuilder(this@GitConfigActivity).run { - setTitle(resources.getString(R.string.git_abort_and_push_title)) - setMessage( - resources.getString( - R.string.git_break_out_of_detached_success, - branch, - "conflicting-$branch-...", - ) - ) - setOnDismissListener { finish() } - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - show() - } - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - - /** - * Returns a user-friendly message about the current state of HEAD. - * - * The state is recognized to be either pointing to a branch or detached. - */ - private fun headStatusMsg(repo: Repository): String { - return runCatching { - val headRef = repo.findRef(Constants.HEAD) - if (headRef.isSymbolic) { - val branchName = headRef.target.name - val shortBranchName = Repository.shortenRefName(branchName) - getString(R.string.git_head_on_branch, shortBranchName) - } else { - val commitHash = headRef.objectId.abbreviate(8).name() - getString(R.string.git_head_detached, commitHash) - } - } - .getOrElse { ex -> - logcat(ERROR) { "Error getting HEAD reference\n${ex}" } - getString(R.string.git_head_missing) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt deleted file mode 100644 index 2a1d3ebd..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.git.config - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.ActivityGitCloneBinding -import app.passwordstore.ui.dialogs.BasicBottomSheet -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.Protocol -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = gitSettings.authMode - - binding.authModeGroup.apply { - when (newAuthMode) { - AuthMode.SshKey -> check(binding.authModeSshKey.id) - AuthMode.Password -> check(binding.authModePassword.id) - AuthMode.None -> clearChecked() - } - addOnButtonCheckedListener { _, _, _ -> - if (checkedButtonIds.isEmpty()) { - newAuthMode = AuthMode.None - } else { - when (checkedButtonId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - } - - binding.serverUrl.setText( - gitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - } - ) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey() - binding.clearHostKeyButton.setOnClickListener { - gitSettings.clearSavedHostKey() - Snackbar.make( - binding.root, - getString(R.string.clear_saved_host_key_success), - Snackbar.LENGTH_LONG, - ) - .show() - it.isVisible = false - } - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - if (newUrl.startsWith("git://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.git_scheme_disallowed_title) - .setMessageRes(R.string.git_scheme_disallowed_message) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - when ( - val updateResult = - gitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - ) - ) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_error), - Snackbar.LENGTH_LONG, - ) - .show() - } - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener {} - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize() - if (!isClone) { - Snackbar.make( - binding.root, - getString(R.string.git_server_config_save_success), - Snackbar.LENGTH_SHORT, - ) - .show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = - getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = - with(binding) { - if (isHttps) { - authModeSshKey.isVisible = false - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked() - } else { - authModeSshKey.isVisible = true - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) - } - } - - /** Clones the repository, the directory exists, deletes it */ - private fun cloneRepository() { - val localDir = - requireNotNull(PasswordRepository.getRepositoryDirectory()) { - "Repository directory must be set before cloning" - } - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if ( - localDir.exists() && - localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git") - ) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = - snackbar( - message = getString(R.string.delete_directory_progress_text), - length = Snackbar.LENGTH_INDEFINITE, - ) - withContext(dispatcherProvider.io()) { - localDir.deleteRecursively() - localDir.mkdirs() - } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> promptOnErrorHandler(err) { finish() } }, - ) - } - } - .onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE) - .fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt deleted file mode 100644 index a72881d1..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.git.log - -import android.os.Bundle -import android.view.MenuItem -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.databinding.ActivityGitLogBinding -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.util.extensions.viewBinding - -/** - * Displays the repository's git commits in git-log fashion. - * - * It provides basic information about each commit by way of a non-interactive RecyclerView. - */ -class GitLogActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitLogBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - createRecyclerView() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun createRecyclerView() { - binding.gitLogRecyclerView.apply { - setHasFixedSize(true) - addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) - adapter = GitLogAdapter() - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt deleted file mode 100644 index 95186188..00000000 --- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.git.log - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import app.passwordstore.databinding.GitLogRowLayoutBinding -import app.passwordstore.util.git.GitCommit -import app.passwordstore.util.git.GitLogModel -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import java.util.Locale -import logcat.LogPriority.ERROR -import logcat.logcat - -private fun shortHash(hash: String): String { - return hash.substring(0 until 8) -} - -private fun stringFrom(date: Instant): String { - return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - .withLocale(Locale.getDefault()) - .withZone(ZoneId.systemDefault()) - .format(date) -} - -/** @see GitLogActivity */ -class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() { - - private val model = GitLogModel() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val commit = model.get(position) - if (commit == null) { - logcat(ERROR) { "There is no git commit for view holder at position $position." } - return - } - viewHolder.bind(commit) - } - - override fun getItemCount() = model.size - - class ViewHolder(private val binding: GitLogRowLayoutBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(commit: GitCommit) = - with(binding) { - gitLogRowMessage.text = commit.shortMessage - gitLogRowHash.text = shortHash(commit.hash) - gitLogRowTime.text = stringFrom(commit.time) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt new file mode 100644 index 00000000..bd5b66f3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.widget.Toast +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +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.mutableStateOf +import androidx.compose.runtime.produceState +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.FolderRow +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Picks a folder of the store at [root], starting at [start] (relative to the root, "" is the + * root): breadcrumbs jump up, rows open subfolders, "New folder" creates one inside the folder + * shown (the list's validation) and opens it, Select reports the folder shown, relative to the + * root. Used by the list's move action and by the editor. Shows no secrets; its window inherits + * FLAG_SECURE from the screen below. + */ +@Composable +fun FolderPicker( + root: File, + start: String, + onPick: (relativePath: String) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var path by rememberSaveable { mutableStateOf(start) } + var naming by rememberSaveable { mutableStateOf(false) } + val showHidden = remember { + context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + } + val rows by + produceState<ImmutableList<ListRow>>(persistentListOf(), path, naming) { + val dir = File(root, path) + // ponytail: a composable has no injected DispatcherProvider; plain IO for a directory read. + @Suppress("RawDispatchersUse") + value = withContext(Dispatchers.IO) { listRows(subfolders(dir, showHidden), dir).folders } + } + if (naming) { + // The list's own dialog and checks: blank, already a file or folder, outside the store + // (component-wise isInsideRepository). + val dir = File(root, path) + FolderNameDialog( + title = stringResource(R.string.title_create_folder), + message = null, + confirm = stringResource(R.string.button_create), + validate = { folderNameError(it, File(dir, it)) }, + onConfirm = { name -> + val folder = File(dir, name).canonicalFile + naming = false + if (folder.mkdirs() || folder.isDirectory) { + path = folder.relativeTo(root.canonicalFile).path + } else { + Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show() + } + }, + onDismiss = { naming = false }, + ) + } + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.folder_picker_title)) }, + text = { + Column { + BreadcrumbBar( + crumbsFor(path), + stringResource(R.string.list_store_root), + onCrumbClick = { path = it }, + modifier = Modifier.fillMaxWidth().padding(bottom = SpacingMedium), + ) + // ponytail: a plain scrolling column, fine for the folders of one level; a LazyColumn + // if a store ever has thousands of subfolders in one folder. + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + rows.forEach { row -> + key(row.path) { + FolderRow( + name = row.label, + count = row.count, + onClick = { path = File(row.path).relativeTo(root).path }, + ) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onPick(path) }) { Text(stringResource(R.string.crypto_select)) } + }, + dismissButton = { + Row { + TextButton(onClick = { naming = true }) { Text(stringResource(R.string.folder_picker_new)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt new file mode 100644 index 00000000..a9228484 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -0,0 +1,368 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import java.io.File +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.launch + +private enum class ListDialog { + None, + Create, + NewFolder, + Rename, + Delete, + Move, + Clone, + NotInitialized, +} + +/** + * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the + * folder picker and the store operations. [onOpenFolder] takes a path relative to the root, + * [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, [onClone] the git + * server screen for a clone, [onSettings] the settings; [onNoStore] runs when the store is gone. + */ +@Composable +fun ListDestination( + activity: MainActivity, + path: String, + onOpenFolder: (relativePath: String) -> Unit, + onOpenEntry: (File) -> Unit, + onNewEntry: () -> Unit, + onClone: () -> Unit, + onSettings: () -> Unit, + onNoStore: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchableRepositoryViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + val dir = File(root, path) + val prefs = activity.sharedPrefs + var query by rememberSaveable { mutableStateOf("") } + var selected by rememberSaveable { mutableStateOf(listOf<String>()) } + var dialog by rememberSaveable { mutableStateOf(ListDialog.None) } + var deleteCount by rememberSaveable { mutableIntStateOf(0) } + var refreshing by remember { mutableStateOf(false) } + var resumes by remember { mutableIntStateOf(0) } + + // Declared before the collection below on purpose: effects start in order, so the ViewModel + // already points at this folder when the first rows are built (no flash of the store root). + LaunchedEffect(path, query) { + viewModel.navigateTo(dir) + if (query.isNotBlank()) viewModel.search(query.trim()) + } + val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows()) + val gitOps = remember(resumes) { activity.gitOps() } + val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) } + + fun refresh() { + if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh() + } + + // Every return to this folder (back from a child, or from another activity) re-reads it. + LifecycleResumeEffect(Unit) { + if (activity.checkStore()) refresh() else onNoStore() + resumes++ + onPauseOrDispose {} + } + BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } + BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } + + fun ifInitialized(action: () -> Unit) { + if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized + } + + fun runGit(op: GitOp) { + refreshing = true + activity.lifecycleScope.launch { + activity.runGit(op) + refreshing = false + refresh() + } + } + + Box(modifier) { + ListScreen( + path = path, + query = query, + rows = rows, + selected = selected.toImmutableSet(), + refreshing = refreshing, + canSync = canSync, + gitOps = gitOps, + onQueryChange = { query = it }, + onCrumb = onOpenFolder, + onOpenFolder = { folder -> + activity.recordUse(File(folder)) + query = "" + onOpenFolder(File(folder).relativeTo(root).path) + }, + onOpenEntry = { entry -> + activity.recordUse(File(entry)) + onOpenEntry(File(entry)) + }, + onToggle = { p -> selected = if (p in selected) selected - p else selected + p }, + onClearSelection = { selected = emptyList() }, + onSync = { + when { + root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh() + !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone + activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL) + else -> runGit(GitOp.SYNC) + } + }, + onGitOp = { op -> ifInitialized { runGit(op) } }, + onSettings = onSettings, + onNew = { dialog = ListDialog.Create }, + onMove = { dialog = ListDialog.Move }, + onDelete = { + val files = selected.map(::File) + val count = countFiles(files) + if (count == 0) { + files.forEach { it.deleteWithoutFollowingLinks() } + selected = emptyList() + refresh() + } else { + deleteCount = count + dialog = ListDialog.Delete + } + }, + onRename = { dialog = ListDialog.Rename }, + onPin = { + activity.pinEntry(File(selected.single())) + selected = emptyList() + }, + focusSearch = path.isEmpty() && prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false), + ) + + when (dialog) { + ListDialog.None -> Unit + ListDialog.Create -> + CreateSheet( + onDismiss = { dialog = ListDialog.None }, + onNewEntry = { + dialog = ListDialog.None + ifInitialized(onNewEntry) + }, + onNewFolder = { + dialog = ListDialog.None + ifInitialized { dialog = ListDialog.NewFolder } + }, + ) + ListDialog.NewFolder -> + FolderNameDialog( + title = stringResource(R.string.title_create_folder), + message = null, + confirm = stringResource(R.string.button_create), + validate = { folderNameError(it, File(dir, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + val folder = File(dir, name).canonicalFile + folder.mkdirs() + onOpenFolder(folder.relativeTo(root.canonicalFile).path) + }, + onDismiss = { dialog = ListDialog.None }, + ) + ListDialog.Rename -> { + val folder = File(selected.single()) + FolderNameDialog( + title = stringResource(R.string.title_rename_folder), + message = stringResource(R.string.message_rename_folder, folder.name), + confirm = stringResource(R.string.dialog_ok), + validate = { folderNameError(it, File(folder.parentFile, it)) }, + onConfirm = { name -> + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.renameFolder(folder, File(folder.parentFile, name)) + refresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + ) + } + ListDialog.Delete -> + Prompt( + text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount), + confirm = stringResource(R.string.dialog_yes), + onConfirm = { + val files = selected.map(::File) + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.deleteItems(files) + refresh() + } + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_no), + ) + ListDialog.Move -> + FolderPicker( + root = root, + start = path, + onPick = { target -> + // One move per pick: a double tap on Select must not move twice. + val destination = File(root, target) + val files = selected.map(::File) + val canonical = destination.canonicalFile + if ( + !destination.isInsideRepository() || + files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) } + ) { + Toast.makeText( + activity, + R.string.message_error_destination_outside_repo, + Toast.LENGTH_LONG, + ) + .show() + } else if (dialog == ListDialog.Move) { + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, destination) + refresh() + } + } + }, + onDismiss = { dialog = ListDialog.None }, + ) + ListDialog.Clone -> + Prompt( + text = stringResource(R.string.clone_git_repo), + confirm = stringResource(R.string.clone_button), + onConfirm = { + dialog = ListDialog.None + onClone() + }, + onDismiss = { dialog = ListDialog.None }, + dismiss = stringResource(R.string.dialog_cancel), + ) + ListDialog.NotInitialized -> + Prompt( + text = stringResource(R.string.creation_dialog_text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = ListDialog.None }, + onDismiss = { dialog = ListDialog.None }, + ) + } + } +} + +/** + * Asks for a folder name; [validate] returns an error string resource or null. Shared with the + * folder picker's "New folder". + */ +@Composable +internal fun FolderNameDialog( + title: String, + message: String?, + confirm: String, + validate: (String) -> Int?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf("") } + var error by remember { mutableStateOf<Int?>(null) } + val focus = remember { FocusRequester() } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + val err = error + Column { + if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium)) + OutlinedTextField( + value = name, + onValueChange = { + name = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(stringResource(R.string.crypto_name_hint)) }, + supportingText = + if (err != null) { + { Text(stringResource(err)) } + } else null, + isError = err != null, + singleLine = true, + ) + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { + TextButton( + onClick = { + val e = validate(name) + error = e + if (e == null) onConfirm(name) + } + ) { + Text(confirm) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** A one-message dialog; [dismiss] adds a second button that only closes it. */ +@Composable +private fun Prompt( + text: String, + confirm: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + dismiss: String? = null, +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(text) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } }, + dismissButton = + if (dismiss != null) { + { TextButton(onClick = onDismiss) { Text(dismiss) } } + } else null, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt new file mode 100644 index 00000000..96b7ab4b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import app.passwordstore.ui.compose.crumbsFor +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +/** + * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of + * children, 0 for entries. + */ +data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int) + +/** The rows of one folder, or of a search inside it: folders above entries. */ +data class ListRows( + val folders: ImmutableList<ListRow> = persistentListOf(), + val entries: ImmutableList<ListRow> = persistentListOf(), +) + +/** + * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their + * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results + * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted + * file, and the list never decrypts. + */ +fun listRows(files: List<File>, dir: File): ListRows { + val (folders, entries) = files.partition { it.isDirectory } + return ListRows( + folders + .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) } + .toImmutableList(), + entries + .map { + val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty() + ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0) + } + .toImmutableList(), + ) +} + +/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */ +private fun childCount(folder: File): Int = + folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0 + +/** + * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden + * folders only with "Show hidden contents" ([showHidden]), as the list filters them. + */ +fun subfolders(dir: File, showHidden: Boolean): List<File> = + dir + .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) } + .orEmpty() + .sortedBy { it.name.lowercase() } + +/** + * How to get from the list at [from] to the list at [to] (both relative to the store root) while + * the back stack holds one entry per folder level: how many entries to pop, then which paths to + * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push. + */ +fun folderMoves(from: String, to: String): Pair<Int, List<String>> { + val current = crumbsFor(from) + val target = crumbsFor(to) + val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size + return (current.size - shared) to target.drop(shared).map { it.path } +} diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt new file mode 100644 index 00000000..720cfc0a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import app.passwordstore.R +import app.passwordstore.ui.compose.BreadcrumbBar +import app.passwordstore.ui.compose.EntryRow +import app.passwordstore.ui.compose.FolderRow +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.crumbsFor +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +/** + * The password list: search bar, breadcrumbs, folders above entries, pull to sync, long-press + * selection and the "New entry" button. Row callbacks take absolute paths; [onCrumb] takes a path + * relative to the store root, like [path]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListScreen( + path: String, + query: String, + rows: ListRows, + selected: ImmutableSet<String>, + refreshing: Boolean, + canSync: Boolean, + gitOps: ImmutableList<GitOp>, + onQueryChange: (String) -> Unit, + onCrumb: (relativePath: String) -> Unit, + onOpenFolder: (path: String) -> Unit, + onOpenEntry: (path: String) -> Unit, + onToggle: (path: String) -> Unit, + onClearSelection: () -> Unit, + onSync: () -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + onNew: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, + modifier: Modifier = Modifier, + focusSearch: Boolean = false, +) { + val selecting = selected.isNotEmpty() + val listState = rememberLazyListState() + val pullState = rememberPullToRefreshState() + val searchFocus = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + // Saveable, so it survives this destination leaving composition while a folder is open. + var launched by rememberSaveable { mutableStateOf(false) } + var returned by remember { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + returned = true + onPauseOrDispose {} + } + // Out of touch mode (keys, D-pad) the view system hands its default focus to the first + // focusable, the search field, which keeps it in touch mode too and brings the keyboard back + // when the list returns from a screen that had it up. So every return drops focus, except the + // first, where search on start asks for it. Done once the window has focus: before that the + // view still holds the touch mode it last saw, and out of it clearing focus refocuses the field. + val windowFocused = LocalWindowInfo.current.isWindowFocused + LaunchedEffect(returned, windowFocused) { + if (returned && windowFocused) { + returned = false + if (focusSearch && !launched) searchFocus.requestFocus() else focusManager.clearFocus() + launched = true + } + } + // Filtered results always start at the top, where the best match is. + LaunchedEffect(query) { if (query.isNotEmpty()) listState.scrollToItem(0) } + Scaffold( + modifier = modifier, + topBar = { + if (selecting) { + SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin) + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus) + BreadcrumbBar( + crumbsFor(path), + stringResource(R.string.list_store_root), + onCrumb, + Modifier.fillMaxWidth().padding(vertical = SpacingMedium), + ) + } + } + }, + floatingActionButton = { + if (!selecting) + // M3 clears the label's semantics; the FAB takes its description from the icon. + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.list_new_entry)) }, + icon = { Icon(Icons.Filled.Add, stringResource(R.string.list_new_entry)) }, + onClick = onNew, + ) + }, + ) { padding -> + Box( + Modifier.padding(padding) + .fillMaxSize() + .pullToRefresh(refreshing, pullState, enabled = canSync && !selecting, onRefresh = onSync) + ) { + // ponytail: each group is one lazy item, fine for hundreds of rows; split rows into their + // own items (with per-row corner shapes) if a store ever has thousands in one folder. + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + if (rows.folders.isNotEmpty()) + item(key = "folders") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.folders.forEach { row -> + key(row.path) { + FolderRow( + name = row.label, + count = row.count, + onClick = { if (selecting) onToggle(row.path) else onOpenFolder(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + if (rows.entries.isNotEmpty()) + item(key = "entries") { + GroupedSurface(Modifier.fillMaxWidth()) { + rows.entries.forEach { row -> + key(row.path) { + EntryRow( + name = row.label, + subtitle = row.subtitle, + onClick = { if (selecting) onToggle(row.path) else onOpenEntry(row.path) }, + selected = row.path in selected, + onLongClick = { onToggle(row.path) }, + ) + } + } + } + } + } + PullToRefreshDefaults.Indicator(pullState, refreshing, Modifier.align(Alignment.TopCenter)) + } + } +} + +/** Bottom sheet behind the "New entry" button: a password entry or a folder. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateSheet( + onDismiss: () -> Unit, + onNewEntry: () -> Unit, + onNewFolder: () -> Unit, + modifier: Modifier = Modifier, +) { + ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) { + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_password)) }, + modifier = Modifier.clickable(onClick = onNewEntry), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_password), contentDescription = null) + }, + ) + ListItem( + headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_folder)) }, + modifier = Modifier.clickable(onClick = onNewFolder), + leadingContent = { + Icon(painterResource(R.drawable.ic_action_new_folder), contentDescription = null) + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchField( + query: String, + gitOps: ImmutableList<GitOp>, + onQueryChange: (String) -> Unit, + onGitOp: (GitOp) -> Unit, + onSettings: () -> Unit, + focus: FocusRequester, +) { + val focusManager = LocalFocusManager.current + var menu by remember { mutableStateOf(false) } + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier.focusRequester(focus), + placeholder = { Text(stringResource(R.string.action_search)) }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null) + }, + trailingIcon = { + Row { + if (query.isNotEmpty()) + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search)) + } + Box { + IconButton(onClick = { menu = true }) { + Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options)) + } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + gitOps.forEach { op -> + DropdownMenuItem( + text = { Text(stringResource(op.label)) }, + onClick = { + menu = false + onGitOp(op) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.action_settings)) }, + onClick = { + menu = false + onSettings() + }, + ) + } + } + } + }, + ) + }, + expanded = false, + onExpandedChange = {}, + ) {} +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectionBar( + rows: ListRows, + selected: ImmutableSet<String>, + onClear: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, + onRename: () -> Unit, + onPin: () -> Unit, +) { + val single = selected.singleOrNull() + TopAppBar( + title = { Text(pluralStringResource(R.plurals.delete_title, selected.size, selected.size)) }, + navigationIcon = { + IconButton(onClick = onClear) { + Icon(Icons.Filled.Close, stringResource(R.string.dialog_cancel)) + } + }, + actions = { + ActionIcon(R.drawable.ic_move_folder_24dp, R.string.move, onMove) + ActionIcon(R.drawable.ic_delete_24dp, R.string.delete, onDelete) + // ponytail: rename one folder at a time; the old list queued one dialog per folder. + if (rows.folders.any { it.path == single }) + ActionIcon(R.drawable.ic_edit_24dp, R.string.title_rename_folder, onRename) + if (rows.entries.any { it.path == single }) + ActionIcon(R.drawable.ic_push_pin_24dp, R.string.place_shortcut_on_home_screen, onPin) + }, + ) +} + +@Composable +private fun ActionIcon(@DrawableRes icon: Int, @StringRes label: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(painterResource(icon), contentDescription = stringResource(label)) + } +} + +private val GitOp.label: Int + get() = + when (this) { + GitOp.SYNC -> R.string.git_sync + GitOp.PUSH -> R.string.git_push + else -> R.string.git_pull + } diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt new file mode 100644 index 00000000..3bdede73 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import android.content.Context +import androidx.core.content.edit +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.crypto.getLongName +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.extensions.base64 +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.isInsideRepository +import app.passwordstore.util.extensions.listFilesRecursively +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.io.File +import java.nio.file.Files +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat + +/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */ +private const val HISTORY = "recent_password_history" + +/** Password files in [files], counting a folder by everything below it. */ +fun countFiles(files: List<File>): Int = + files.sumOf { if (it.isFile) 1 else it.listFilesRecursively().size } + +/** Why [name] cannot become the folder [target], as a string resource, or null when it can. */ +fun folderNameError(name: String, target: File): Int? = + when { + name.isBlank() -> R.string.message_category_error_empty_field + target.isFile -> R.string.folder_creation_err_file_exists + target.isDirectory -> R.string.folder_creation_err_folder_exists + !target.isInsideRepository() -> R.string.message_error_destination_outside_repo + else -> null + } + +/** Records that [file] was opened, for the "recently used" sort order. */ +fun Context.recordUse(file: File) { + if (sharedPrefs.getString(PreferenceKeys.SORT_ORDER) != PasswordSortOrder.RECENTLY_USED.name) + return + getSharedPreferences(HISTORY, Context.MODE_PRIVATE).edit { + putString(file.absolutePath.base64(), System.currentTimeMillis().toString()) + } +} + +/** Deletes this file or folder, never following symlinks; true when everything is gone. */ +internal fun File.deleteWithoutFollowingLinks(): Boolean { + if (isDirectory && !Files.isSymbolicLink(toPath())) { + // Evaluate every child, no short-circuit, so one failure leaves the rest deleted. + val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() } + if (!children.all { it }) return false + } + return delete() +} + +/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ +suspend fun BaseGitActivity.deleteItems(files: List<File>) { + val root = PasswordRepository.getRepositoryDirectory() + withContext(dispatcherProvider.io()) { + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteWithoutFollowingLinks() } + AutofillMatcher.updateMatches(applicationContext, delete = deleted) + } + commitChange( + getString( + R.string.git_commit_remove_text, + files.joinToString(", ") { it.toRelativeString(root) }, + ) + ) +} + +/** Moves [files] into the folder [target], asking before overwriting, then commits. */ +suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) { + val repo = PasswordRepository.getRepositoryDirectory().absolutePath + for (source in files) { + if (!source.exists()) { + logcat(ERROR) { "Tried moving something that appears non-existent." } + continue + } + val destination = File(target, source.name) + val name = source.nameWithoutExtension + if ( + destination.exists() && + !confirm( + getString(R.string.password_exists_title), + getString( + R.string.password_exists_message, + getLongName(target.absolutePath, repo, name), + getLongName(source.parent.orEmpty(), repo, name), + ), + ) + ) + continue + moveFile(source, destination) + } + val message = + if (files.size == 1) { + val name = files[0].nameWithoutExtension + getString( + R.string.git_commit_move_text, + getLongName(files[0].parent.orEmpty(), repo, name), + getLongName(target.absolutePath, repo, name), + ) + } else { + val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/") + getString(R.string.git_commit_move_multiple_text, relative) + } + commitChange(message) +} + +/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ +suspend fun BaseGitActivity.renameFolder(from: File, to: File) { + if (!moveFile(from, to)) return + moveHistory(from, to) + commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) +} + +/** Carries the "recently used" time of [from] over to [to]. */ +fun Context.moveHistory(from: File, to: File) { + val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) + val time = history.getString(from.absolutePath.base64()) ?: return + history.edit { + remove(from.absolutePath.base64()) + putString(to.absolutePath.base64(), time) + } +} + +private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean { + val moved = + withContext(dispatcherProvider.io()) { + val map = + if (source.isDirectory) { + destination.mkdirs() + source.listFilesRecursively().associateWith { destination.resolve(it.relativeTo(source)) } + } else { + mapOf(source to destination) + } + source.renameTo(destination).also { ok -> + if (ok) AutofillMatcher.updateMatches(this@moveFile, map) + } + } + if (!moved) { + logcat(ERROR) { "Something went wrong while moving $source to $destination." } + // ponytail: View dialog like BaseGitActivity's git prompts; both move to Compose together. + MaterialAlertDialogBuilder(this) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destination)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + return moved +} + +/** Shows [message] under [title] and suspends until answered; true when confirmed. */ +// ponytail: View dialog, see moveFile. +private suspend fun Context.confirm(title: String, message: String): Boolean = + suspendCancellableCoroutine { cont -> + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + cont.invokeOnCancellation { dialog.dismiss() } + } diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 1a5e8a89..c0af5742 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -12,9 +12,6 @@ import android.os.Looper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.passwords.PasswordStore import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs @@ -24,10 +21,21 @@ import java.io.File @SuppressLint("CustomSplashScreen") class LaunchActivity : AppCompatActivity() { + private var prompted = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!sharedPrefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) startTargetActivity(true) + } + + // The prompt waits for window focus: Android 14's SystemUI dismisses a prompt shown while its + // activity is not yet on top (shown from onCreate), with ERROR_CANCELED, closing the app at once. + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus || prompted || isFinishing) return val prefs = sharedPrefs if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) { + prompted = true BiometricAuthenticator.authenticate(this) { result -> when (result) { is Result.Success -> { @@ -45,27 +53,20 @@ class LaunchActivity : AppCompatActivity() { is Result.Retry -> {} } } - } else { - startTargetActivity(true) } } - private fun getDecryptIntent(): Intent { - return Intent(this, DecryptActivity::class.java) - } - private fun startTargetActivity(noAuth: Boolean) { - // This activity is exported, so the file path is untrusted input: only forward it to - // DecryptActivity when it points at a file inside the password store. + // This activity is exported, so the file path is untrusted input: only forward it when it + // points at a file inside the password store. MainActivity is not exported and trusts it. val repoDir = PasswordRepository.getRepositoryDirectory() - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - val intentToStart = - if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) - getDecryptIntent().apply { - putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) - putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath) - } - else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW) + val filePath = intent.getStringExtra(EXTRA_FILE_PATH) + val intentToStart = Intent(this, MainActivity::class.java) + if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) + intentToStart.putExtra(EXTRA_FILE_PATH, filePath) + // A plain start replaces the task, so an instance already open (one whose store vanished, say) + // is not left below the new one. An entry opens on top, back returns to where the user was. + else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intentToStart) Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) @@ -75,6 +76,9 @@ class LaunchActivity : AppCompatActivity() { const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + /** The entry to open; home screen shortcuts carry this exact key, never change it. */ + const val EXTRA_FILE_PATH = "FILE_PATH" + /** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */ fun isFileInRepo(path: String?, repoDir: File): Boolean { if (path.isNullOrEmpty()) return false diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt new file mode 100644 index 00000000..ce5fb1df --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.main + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import app.passwordstore.data.password.PasswordItem +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.APSTheme +import app.passwordstore.ui.editor.EditorDestination +import app.passwordstore.ui.editor.EditorForm +import app.passwordstore.ui.editor.editorForm +import app.passwordstore.ui.entry.EntryDestination +import app.passwordstore.ui.entry.EntryViewModel +import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.list.ListDestination +import app.passwordstore.ui.list.folderMoves +import app.passwordstore.ui.settings.SettingsRoute +import app.passwordstore.ui.settings.settingsGraph +import app.passwordstore.ui.setup.GitServerRoute +import app.passwordstore.ui.setup.SetupStart +import app.passwordstore.ui.setup.WelcomeRoute +import app.passwordstore.ui.setup.setupGraph +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.shortcuts.ShortcutHandler +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import dagger.hilt.android.AndroidEntryPoint +import java.io.File +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +/** + * The password list of the folder at [path], relative to the store root ("" is the root). The back + * stack holds one entry per folder level. + */ +@Serializable data class ListRoute(val path: String = "") + +/** + * One entry, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo]; + * the entry destination checks again before decrypting. + */ +@Serializable data class EntryRoute(val filePath: String) + +/** + * The editor: a new entry in [dir] (relative to the store root, "" is the root), or the entry + * [name] (file name without `.gpg`) there. An edit takes its plaintext from the entry view below it + * on the back stack, never from the route. + */ +@Serializable data class EditRoute(val dir: String, val name: String? = null) + +/** + * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity], + * after its biometric gate and path check, and by the app itself through [setupIntent]. Extends + * [BaseGitActivity] (an AppCompatActivity, so also the FragmentActivity BiometricPrompt needs) + * because the git operations require one; it hosts no fragments. + */ +@AndroidEntryPoint +class MainActivity : BaseGitActivity() { + + @Inject lateinit var shortcutHandler: ShortcutHandler + + /** Composed destinations that show secrets; FLAG_SECURE is on while this is above zero. */ + private var secureHolds = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val filePath = intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH) + val setup = + intent.getStringExtra(EXTRA_SETUP)?.let { name -> + SetupStart.entries.firstOrNull { it.name == name } + } + // A restored back stack wins over this choice, so a recreation stays where it was. + val start: Any = + when { + filePath != null -> EntryRoute(filePath) + setup != null -> setup.route + checkStore() -> ListRoute() + else -> WelcomeRoute + } + setContent { + APSTheme { + val nav = rememberNavController() + NavHost(nav, startDestination = start) { + composable<ListRoute> { entry -> + val path = entry.toRoute<ListRoute>().path + ListDestination( + activity = this@MainActivity, + path = path, + onOpenFolder = { target -> + // Only the folder on top may move the stack: a double tap must not push twice. + if (nav.currentBackStackEntry?.id == entry.id) { + val (pops, pushes) = folderMoves(path, target) + repeat(pops) { nav.popBackStack() } + pushes.forEach { nav.navigate(ListRoute(it)) } + } + }, + onOpenEntry = { file -> + if (nav.currentBackStackEntry?.id == entry.id) { + addShortcut(file) + nav.navigate(EntryRoute(file.absolutePath)) + } + }, + onNewEntry = { + if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path)) + }, + onClone = { + if (nav.currentBackStackEntry?.id == entry.id) + nav.navigate(GitServerRoute(clone = true)) + }, + onSettings = { + if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute) + }, + onNoStore = { + // The store is gone (deleted from Settings): setup starts over. + if (nav.currentBackStackEntry?.id == entry.id) + nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } } + }, + ) + } + composable<EntryRoute> { entry -> + val file = File(entry.toRoute<EntryRoute>().filePath) + SecureWindow(this@MainActivity) + EntryDestination( + activity = this@MainActivity, + file = file, + onClose = { + // Only the entry on top closes: the auto-close timer can race a back press. + if (nav.currentBackStackEntry?.id == entry.id) { + if (nav.previousBackStackEntry == null) finish() else nav.popBackStack() + } + }, + onEdit = { + if (nav.currentBackStackEntry?.id == entry.id) { + // Canonical folder, link's own name: a symlinked entry keeps its name. + val root = PasswordRepository.getRepositoryDirectory().canonicalFile + val dir = file.parentFile?.canonicalFile?.relativeTo(root)?.path.orEmpty() + nav.navigate(EditRoute(dir, file.nameWithoutExtension)) + } + }, + ) + } + composable<EditRoute> { entry -> + val route = entry.toRoute<EditRoute>() + val name = route.name + // An edit reads the decrypted entry from the entry view below it. After process + // death that ViewModel is new and empty, and the editor closes. + val decrypted = + if (name == null) null + else + remember(entry) { + // No entry view below (none on the stack): getBackStackEntry throws. + try { + nav.getBackStackEntry<EntryRoute>() + } catch (_: IllegalArgumentException) { + null + } + } + ?.let { hiltViewModel<EntryViewModel>(it).entry } + SecureWindow(this@MainActivity) + EditorDestination( + activity = this@MainActivity, + initial = + if (name == null) EditorForm(dir = route.dir) + else decrypted?.let { editorForm(it, route.dir, name) }, + original = + name?.let { + File(File(PasswordRepository.getRepositoryDirectory(), route.dir), "$it.gpg") + }, + onClose = { if (nav.currentBackStackEntry?.id == entry.id) nav.popBackStack() }, + onSaved = { + // Back to the list, which re-reads the store on resume. An entry opened from a + // shortcut has no list below it: the list root replaces the stack. + if (nav.currentBackStackEntry?.id == entry.id) { + if (!nav.popBackStack<ListRoute>(inclusive = false)) + nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } } + } + }, + ) + } + setupGraph(this@MainActivity, nav) + settingsGraph(this@MainActivity, nav) + } + } + } + } + + /** Counts a destination that shows secrets; the first one turns FLAG_SECURE on. */ + fun holdSecure() { + if (secureHolds++ == 0) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + /** Ends a [holdSecure]; FLAG_SECURE goes off when no secret-showing destination is left. */ + fun releaseSecure() { + if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + + /** Re-reads the store; false when there is none yet and setup has to run. */ + fun checkStore(): Boolean { + PasswordRepository.initialize() + return sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) + } + + /** Git actions offered in the list's menu, chosen as the old list chose its menu. */ + fun gitOps(): ImmutableList<GitOp> = + when { + gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL) + PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH) + else -> persistentListOf() + } + + /** Runs [op]; failures show the usual git error dialog. */ + suspend fun runGit(op: GitOp) { + launchGitOperation(op).onFailure { promptOnErrorHandler(it) } + } + + /** Runs [op] (a hard reset against [branch]) and returns its outcome; the caller reports it. */ + suspend fun gitOperation(op: GitOp, branch: String = ""): Result<Unit, Throwable> { + remoteBranch = branch + return launchGitOperation(op) + } + + /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */ + fun addShortcut(file: File) { + val item = entryItem(file) + shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this)) + } + + fun pinEntry(file: File) { + val item = entryItem(file) + shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this)) + } + + private fun entryItem(file: File) = + PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) + + companion object { + + private const val EXTRA_SETUP = "SETUP" + + /** + * Opens one setup screen in an instance of its own, for callers outside the graph (autofill, + * the git operations' "No SSH key" dialog) and for those waiting for a result (the entry view's + * missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK + * once a key was imported. + */ + // ponytail: a second instance instead of in-graph navigation. Autofill's activities stay + // outside the graph for good (the autofill service starts them); the git operations' "No SSH + // key" dialog could navigate in-graph once it becomes a Compose dialog. + fun setupIntent(context: Context, start: SetupStart): Intent = + Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name) + } +} + +/** + * FLAG_SECURE while composed. Counted in [MainActivity], because in a transition (entry view to + * editor and back) the entering destination is composed before the leaving one is disposed. + */ +@Composable +private fun SecureWindow(activity: MainActivity) { + DisposableEffect(activity) { + activity.holdSecure() + onDispose { activity.releaseSecure() } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt deleted file mode 100644 index 709c2302..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.activity - -import android.os.Bundle -import androidx.activity.addCallback -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.hide() - val callback = onBackPressedDispatcher.addCallback(enabled = false) { finishAffinity() } - supportFragmentManager.addOnBackStackChangedListener { - if (supportFragmentManager.backStackEntryCount == 0) { - callback.isEnabled = true - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt deleted file mode 100644 index a9d933b6..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentCloneBinding -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class CloneFragment : Fragment(R.layout.fragment_clone) { - - private val binding by viewBinding(FragmentCloneBinding::bind) - - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } - binding.createLocal.setOnClickListener { createRepository() } - } - - /** Clones a remote Git repository to the app's private directory */ - private fun cloneToHiddenDir() { - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) - } - .onFailure { e -> - logcat(ERROR) { e.asLog() } - if (!localDir.delete()) { - logcat { "Failed to delete local repository: $localDir" } - } - finish() - } - } - - companion object { - - fun newInstance(): CloneFragment = CloneFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt deleted file mode 100644 index 64c2e124..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.FragmentKeySelectionBinding -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@AndroidEntryPoint -class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { - - @Inject @SettingsPreferences lateinit var settings: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - private val binding by viewBinding(FragmentKeySelectionBinding::bind) - private val gpgKeySelectAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - val data = result.data ?: return@registerForActivityResult - val selectedKey = - data.getStringExtra(PGPKeyListActivity.EXTRA_SELECTED_KEY) - ?: return@registerForActivityResult - lifecycleScope.launch { - withContext(dispatcherProvider.io()) { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - gpgIdentifierFile.writeText(selectedKey) - } - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - requireActivity() - .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name))) - finish() - } - } else { - requireActivity() - .snackbar( - message = getString(R.string.gpg_key_select_mandatory), - length = Snackbar.LENGTH_LONG, - ) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.selectKey.setOnClickListener { - gpgKeySelectAction.launch(PGPKeyListActivity.newSelectionActivity(requireContext())) - } - } - - companion object { - - fun newInstance() = KeySelectionFragment() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt deleted file mode 100644 index 44881c64..00000000 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.onboarding.fragments - -import android.os.Bundle -import android.view.View -import androidx.annotation.Keep -import androidx.fragment.app.Fragment -import app.passwordstore.R -import app.passwordstore.databinding.FragmentWelcomeBinding -import app.passwordstore.ui.settings.SettingsActivity -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.viewBinding - -@Keep -class WelcomeFragment : Fragment(R.layout.fragment_welcome) { - - private val binding by viewBinding(FragmentWelcomeBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.letsGo.setOnClickListener { - parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) - } - binding.settingsButton.setOnClickListener { - requireActivity().launchActivity(SettingsActivity::class.java) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt deleted file mode 100644 index 917bb91c..00000000 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.passwords - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.os.Parcelable -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.view.ActionMode -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -import app.passwordstore.ui.dialogs.BasicBottomSheet -import app.passwordstore.ui.dialogs.ItemCreationBottomSheet -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.ui.util.OnOffItemAnimator -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PasswordSortOrder -import app.passwordstore.util.settings.PreferenceKeys -import app.passwordstore.util.shortcuts.ShortcutHandler -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -@AndroidEntryPoint -class PasswordFragment : Fragment(R.layout.password_recycler_view) { - - @Inject lateinit var gitSettings: GitSettings - @Inject lateinit var shortcutHandler: ShortcutHandler - @Inject lateinit var dispatcherProvider: DispatcherProvider - @Inject @SettingsPreferences lateinit var prefs: SharedPreferences - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - private lateinit var settings: SharedPreferences - - private var recyclerViewStateToRestore: Parcelable? = null - private var actionMode: ActionMode? = null - private var scrollTarget: File? = null - - private val model: SearchableRepositoryViewModel by activityViewModels() - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private val swipeResult = - registerForActivityResult(StartActivityForResult()) { - binding.swipeRefresher.isRefreshing = false - requireStore().refreshPasswordList() - } - - val currentDir: File - get() = model.currentDir.value - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings = requireContext().sharedPrefs - initializePasswordList() - binding.fab.setOnClickListener { - ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") - } - childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { - _, - bundle -> - when (bundle.getString(ACTION_KEY)) { - ACTION_FOLDER -> requireStore().createFolder() - ACTION_PASSWORD -> requireStore().createPassword() - } - } - } - - private fun initializePasswordList() { - val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") - val hasGitDir = - gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) - binding.swipeRefresher.setOnRefreshListener { - if (!hasGitDir) { - requireStore().refreshPasswordList() - binding.swipeRefresher.isRefreshing = false - } else if (!PasswordRepository.isGitRepo()) { - BasicBottomSheet.Builder(requireContext()) - .setMessageRes(R.string.clone_git_repo) - .setPositiveButtonClickListener(getString(R.string.clone_button)) { - swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - .build() - .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") - binding.swipeRefresher.isRefreshing = false - } else { - // When authentication is set to AuthMode.None then the only git operation we can - // run is a pull, so automatically fallback to that. - val operationId = - when (gitSettings.authMode) { - AuthMode.None -> BaseGitActivity.GitOp.PULL - else -> BaseGitActivity.GitOp.SYNC - } - requireStore().apply { - lifecycleScope.launch { - launchGitOperation(operationId) - .fold( - success = { - binding.swipeRefresher.isRefreshing = false - refreshPasswordList() - }, - failure = { err -> - promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } - }, - ) - } - } - } - } - - recyclerAdapter = - PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider) - .onItemClicked { _, item -> listener.onFragmentInteraction(item) } - .onSelectionChanged { selection -> - // In order to not interfere with drag selection, we disable the - // SwipeRefreshLayout - // once an item is selected. - binding.swipeRefresher.isEnabled = selection.isEmpty - - if (actionMode == null) - actionMode = - requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged - - if (!selection.isEmpty) { - actionMode!!.title = - resources.getQuantityString( - R.plurals.delete_title, - selection.size(), - selection.size(), - ) - actionMode!!.invalidate() - } else { - actionMode!!.finish() - } - } - val recyclerView = binding.passRecycler - recyclerView.apply { - addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = OnOffItemAnimator() - adapter = recyclerAdapter - } - - FastScrollerBuilder(recyclerView).build() - recyclerAdapter.makeSelectable(recyclerView) - registerForContextMenu(recyclerView) - - val path = - requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) { - "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided" - } - model.navigateTo(File(path), pushPreviousLocation = false) - lifecycleScope.launch { - model.searchResult.flowWithLifecycle(lifecycle).collect { result -> - // Only run animations when the new list is filtered, i.e., the user submitted a search, - // and not on folder navigation since the latter leads to too many removal animations. - (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered - recyclerAdapter.submitList(result.passwordItems) { - when { - result.isFiltered -> { - // When the result is filtered, we always scroll to the top since that is - // where the best fuzzy match appears. - recyclerView.scrollToPosition(0) - } - scrollTarget != null -> { - scrollTarget?.let { - recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) - } - scrollTarget = null - } - else -> { - // When the result is not filtered and there is a saved scroll position for - // it, we try to restore it. - recyclerViewStateToRestore?.let { - recyclerView.layoutManager!!.onRestoreInstanceState(it) - } - recyclerViewStateToRestore = null - } - } - } - } - } - } - - private val actionModeCallback = - object : ActionMode.Callback { - // Called when the action mode is created; startActionMode() was called - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - // Inflate a menu resource providing context menu items - mode.menuInflater.inflate(R.menu.context_pass, menu) - // hide the fab - animateFab(false) - return true - } - - // Called each time the action mode is shown. Always called after onCreateActionMode, - // but may be called multiple times if the mode is invalidated. - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val selectedItems = recyclerAdapter.getSelectedItems() - menu.findItem(R.id.menu_edit_password).isVisible = - selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY } - menu.findItem(R.id.menu_pin_password).isVisible = - selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD - return true - } - - // Called when the user selects a contextual menu item - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_delete_password -> { - requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) - // Action picked, so close the CAB - mode.finish() - true - } - R.id.menu_move_password -> { - requireStore().movePasswords(recyclerAdapter.getSelectedItems()) - false - } - R.id.menu_edit_password -> { - requireStore().renameCategory(recyclerAdapter.getSelectedItems()) - mode.finish() - false - } - R.id.menu_pin_password -> { - val passwordItem = recyclerAdapter.getSelectedItems()[0] - shortcutHandler.addPinnedShortcut( - passwordItem, - passwordItem.createAuthEnabledIntent(requireContext()), - ) - false - } - else -> false - } - } - - // Called when the user exits the action mode - override fun onDestroyActionMode(mode: ActionMode) { - recyclerAdapter.requireSelectionTracker().clearSelection() - actionMode = null - // show the fab - animateFab(true) - } - - private fun animateFab(show: Boolean) = - with(binding.fab) { - val animation = - AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down) - animation.setAnimationListener( - object : Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) {} - - override fun onAnimationEnd(animation: Animation?) { - if (!show) visibility = View.GONE - } - - override fun onAnimationStart(animation: Animation?) { - if (show) visibility = View.VISIBLE - } - } - ) - animate() - .rotationBy(if (show) -90f else 90f) - .setStartDelay(if (show) 100 else 0) - .setDuration(100) - .start() - startAnimation(animation) - } - } - - override fun onResume() { - super.onResume() - binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = - object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if ( - settings.getString(PreferenceKeys.SORT_ORDER) == - PasswordSortOrder.RECENTLY_USED.name - ) { - // save the time when password was used - val preferences = - context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - preferences.edit { - putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) - } - } - - if (item.type == PasswordItem.TYPE_CATEGORY) { - navigateTo(item.file) - } else { - if (requireArguments().getBoolean("matchWith", false)) { - requireStore().matchPasswordWithApp(item) - } else { - requireStore().decryptPassword(item) - } - } - } - } - } - .onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - private fun requireStore() = requireActivity() as PasswordStore - - /** Returns true if the back press was handled by the [Fragment]. */ - fun onBackPressedInActivity(): Boolean { - if (!model.canNavigateBack) return false - // The RecyclerView state is restored when the asynchronous update operation on the - // adapter is completed. - recyclerViewStateToRestore = model.navigateBack() - if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) - return true - } - - fun dismissActionMode() { - actionMode?.finish() - } - - companion object { - - const val ITEM_CREATION_REQUEST_KEY = "creation_key" - const val ACTION_KEY = "action" - const val ACTION_FOLDER = "folder" - const val ACTION_PASSWORD = "password" - - fun newInstance(args: Bundle): PasswordFragment { - val fragment = PasswordFragment() - fragment.arguments = args - return fragment - } - } - - fun navigateTo(file: File) { - requireStore().clearSearch() - model.navigateTo( - file, - recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState(), - ) - requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - fun scrollToOnNextRefresh(file: File) { - scrollTarget = file - } - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt deleted file mode 100644 index 059b398f..00000000 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ /dev/null @@ -1,621 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.passwords - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener -import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.core.content.edit -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.password.PasswordItem -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName -import app.passwordstore.ui.crypto.DecryptActivity -import app.passwordstore.ui.crypto.PasswordCreationActivity -import app.passwordstore.ui.dialogs.FolderCreationDialogFragment -import app.passwordstore.ui.folderselect.SelectFolderActivity -import app.passwordstore.ui.git.base.BaseGitActivity -import app.passwordstore.ui.onboarding.activity.OnboardingActivity -import app.passwordstore.ui.settings.SettingsActivity -import app.passwordstore.util.autofill.AutofillMatcher -import app.passwordstore.util.extensions.base64 -import app.passwordstore.util.extensions.commitChange -import app.passwordstore.util.extensions.contains -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.isInsideRepository -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.listFilesRecursively -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.settings.AuthMode -import app.passwordstore.util.settings.PreferenceKeys -import app.passwordstore.util.shortcuts.ShortcutHandler -import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import java.lang.Character.UnicodeBlock -import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.LogPriority.INFO -import logcat.logcat - -const val PASSWORD_FRAGMENT_TAG = "PasswordsList" - -@AndroidEntryPoint -class PasswordStore : BaseGitActivity() { - - @Inject lateinit var shortcutHandler: ShortcutHandler - private lateinit var searchItem: MenuItem - private val settings by lazy { sharedPrefs } - - private val model: SearchableRepositoryViewModel by viewModels() - - private val listRefreshAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - refreshPasswordList() - } - } - - private val passwordMoveAction = - registerForActivityResult(StartActivityForResult()) { result -> - val intentData = result.data ?: return@registerForActivityResult - val filesToMove = - requireNotNull(intentData.getStringArrayExtra("Files")) { - "'Files' intent extra must be set" - } - val target = - File( - requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")) { - "'SELECTED_FOLDER_PATH' intent extra must be set" - } - ) - val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath - if (!target.isDirectory) { - logcat(ERROR) { "Tried moving passwords to a non-existing folder." } - return@registerForActivityResult - } - - logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } - logcat { filesToMove.joinToString(", ") } - - lifecycleScope.launch(dispatcherProvider.io()) { - for (file in filesToMove) { - val source = File(file) - if (!source.exists()) { - logcat(ERROR) { "Tried moving something that appears non-existent." } - continue - } - val destinationFile = File(target.absolutePath + "/" + source.name) - val basename = source.nameWithoutExtension - val sourceLongName = - getLongName( - requireNotNull(source.parent) { "$file has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - if (destinationFile.exists()) { - logcat(ERROR) { "Trying to move a file that already exists." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(resources.getString(R.string.password_exists_title)) - .setMessage( - resources.getString( - R.string.password_exists_message, - destinationLongName, - sourceLongName, - ) - ) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - } else { - launch(dispatcherProvider.io()) { moveFile(source, destinationFile) } - } - } - when (filesToMove.size) { - 1 -> { - val source = File(filesToMove[0]) - val basename = source.nameWithoutExtension - val sourceLongName = - getLongName( - requireNotNull(source.parent) { "$basename has no parent" }, - repositoryPath, - basename, - ) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - sourceLongName, - destinationLongName, - ) - ) - } - } - else -> { - val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath - val relativePath = getRelativePath("${target.absolutePath}/", repoDir) - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString(R.string.git_commit_move_multiple_text, relativePath) - ) - } - } - } - } - refreshPasswordList() - getPasswordFragment()?.dismissActionMode() - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // open search view on search key, or Ctr+F - if ( - (keyCode == KeyEvent.KEYCODE_SEARCH || - keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded - ) { - searchItem.expandActionView() - return true - } - - // open search view on any printable character and query for it - val c = event.unicodeChar.toChar() - val printable = isPrintable(c) - if (printable && !searchItem.isActionViewExpanded) { - searchItem.expandActionView() - (searchItem.actionView as SearchView).setQuery(c.toString(), true) - return true - } - return super.onKeyDown(keyCode, event) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_pwdstore) - - lifecycleScope.launch { - model.currentDir.flowWithLifecycle(lifecycle).collect { dir -> - val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile - supportActionBar?.apply { - if (dir != basePath) title = dir.name else setTitle(R.string.app_name) - } - } - } - } - - override fun onStart() { - super.onStart() - refreshPasswordList() - } - - override fun onResume() { - super.onResume() - checkLocalRepository() - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { - if (!searchItem.isActionViewExpanded) { - searchItem.expandActionView() - } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val menuRes = - when { - gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth - PasswordRepository.isGitRepo() -> R.menu.main_menu_git - else -> R.menu.main_menu_non_git - } - menuInflater.inflate(menuRes, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so - // we can get by without any noticeable difference in performance. - invalidateOptionsMenu() - searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener( - object : OnQueryTextListener { - override fun onQueryTextSubmit(s: String): Boolean { - searchView.clearFocus() - return true - } - - override fun onQueryTextChange(s: String): Boolean { - val filter = s.trim() - // List the contents of the current directory if the user enters a blank - // search term. - if (filter.isEmpty()) - model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false) - else model.search(filter) - return true - } - } - ) - - // When using the support library, the setOnActionExpandListener() method is - // static and accepts the MenuItem object as an argument - searchItem.setOnActionExpandListener( - object : OnActionExpandListener { - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - refreshPasswordList() - return true - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } - } - ) - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { - searchItem.expandActionView() - } - return super.onPrepareOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - val initBefore = - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - when (id) { - R.id.user_pref -> { - runCatching { launchActivity(SettingsActivity::class.java) } - .onFailure { e -> e.printStackTrace() } - } - R.id.git_push -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PUSH) - } - } - R.id.git_pull -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.PULL) - } - } - R.id.git_sync -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - } else { - runGitOperation(GitOp.SYNC) - } - } - R.id.refresh -> refreshPasswordList() - android.R.id.home -> { - @Suppress("DEPRECATION") onBackPressed() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - @Deprecated("Deprecated in Java") - @Suppress("DEPRECATION") - override fun onBackPressed() { - if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed() - } - - private fun getPasswordFragment(): PasswordFragment? { - return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment - } - - fun clearSearch() { - if (searchItem.isActionViewExpanded) searchItem.collapseActionView() - } - - private fun runGitOperation(operation: GitOp) = - lifecycleScope.launch { - launchGitOperation(operation) - .fold(success = { refreshPasswordList() }, failure = { promptOnErrorHandler(it) }) - } - - private fun checkLocalRepository() { - PasswordRepository.initialize() - checkLocalRepository(PasswordRepository.getRepositoryDirectory()) - } - - private fun checkLocalRepository(localDir: File?) { - if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { - // do not push the fragment if we already have it - if ( - getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false) - ) { - settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } - val args = Bundle() - args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - - // if the activity was started from the autofill settings, the - // intent is to match a clicked pwd with app. pass this to fragment - if (intent.getBooleanExtra("matchWith", false)) { - args.putBoolean("matchWith", true) - } - supportActionBar?.apply { - show() - setDisplayHomeAsUpEnabled(false) - } - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { - replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) - } - } - } else { - launchActivity(OnboardingActivity::class.java) - } - } - - private fun getRelativePath(fullPath: String, repositoryPath: String): String { - return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - } - - fun decryptPassword(item: PasswordItem) { - val authDecryptIntent = item.createAuthEnabledIntent(this) - val decryptIntent = - Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java)) - - startActivity(decryptIntent) - - // Adds shortcut - shortcutHandler.addDynamicShortcut(item, authDecryptIntent) - } - - private fun validateState(): Boolean { - if (!PasswordRepository.isInitialized) { - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - .show() - return false - } - return true - } - - fun createPassword() { - if (!validateState()) return - val currentDir = currentDir - logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" } - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePath) - intent.putExtra( - BasePGPActivity.EXTRA_REPO_PATH, - PasswordRepository.getRepositoryDirectory().absolutePath, - ) - listRefreshAction.launch(intent) - } - - fun createFolder() { - if (!validateState()) return - FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) - } - - fun deletePasswords(selectedItems: List<PasswordItem>) { - var size = 0 - selectedItems.forEach { - if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size - } - if (size == 0) { - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - return - } - MaterialAlertDialogBuilder(this) - .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) - .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> - val filesToDelete = arrayListOf<File>() - selectedItems.forEach { item -> - if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively()) - else filesToDelete.add(item.file) - } - selectedItems.map { item -> item.file.deleteRecursively() } - refreshPasswordList() - AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) - val fmt = - selectedItems.joinToString(separator = ", ") { item -> - item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) - } - lifecycleScope.launch { - commitChange(resources.getString(R.string.git_commit_remove_text, fmt)) - } - } - .setNegativeButton(resources.getString(R.string.dialog_no), null) - .show() - } - - fun movePasswords(values: List<PasswordItem>) { - val intent = Intent(this, SelectFolderActivity::class.java) - val fileLocations = values.map { it.file.absolutePath }.toTypedArray() - intent.putExtra("Files", fileLocations) - passwordMoveAction.launch(intent) - } - - enum class CategoryRenameError(val resource: Int) { - None(0), - EmptyField(R.string.message_category_error_empty_field), - CategoryExists(R.string.message_category_error_category_exists), - DestinationOutsideRepo(R.string.message_error_destination_outside_repo), - } - - /** - * Prompt the user with a new category name to assign, if the new category forms/leads a path - * (i.e. contains "/"), intermediate directories will be created and new category will be placed - * inside. - * - * @param oldCategory The category to change its name - * @param error Determines whether to show an error to the user in the alert dialog, this error - * may be due to the new category the user entered already exists or the field was empty or the - * destination path is outside the repository - * @see [CategoryRenameError] - * @see [isInsideRepository] - */ - private fun renameCategory( - oldCategory: PasswordItem, - error: CategoryRenameError = CategoryRenameError.None, - ) { - val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null) - val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text) - - if (error != CategoryRenameError.None) { - newCategoryEditText.error = getString(error.resource) - } - - val dialog = - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_rename_folder) - .setView(view) - .setMessage(getString(R.string.message_rename_folder, oldCategory.name)) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}") - when { - newCategoryEditText.text.isNullOrBlank() -> - renameCategory(oldCategory, CategoryRenameError.EmptyField) - newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) - !newCategory.isInsideRepository() -> - renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) - else -> - lifecycleScope.launch(dispatcherProvider.io()) { - moveFile(oldCategory.file, newCategory) - - // associate the new category with the last category's timestamp in - // history - val preference = - getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) - if (timestamp != null) { - preference.edit { - remove(oldCategory.file.absolutePath.base64()) - putString(newCategory.absolutePath.base64(), timestamp) - } - } - - withContext(dispatcherProvider.main()) { - commitChange( - resources.getString( - R.string.git_commit_move_text, - oldCategory.name, - newCategory.name, - ) - ) - } - } - } - } - .setNegativeButton(R.string.dialog_skip, null) - .create() - - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.show() - } - - fun renameCategory(categories: List<PasswordItem>) { - for (oldCategory in categories) { - renameCategory(oldCategory) - } - } - - /** - * Refreshes the password list by re-executing the last navigation or search action, preserving - * the navigation stack and scroll position. If the current directory no longer exists, navigation - * is reset to the repository root. If the optional [target] argument is provided, it will be - * entered if it is a directory or scrolled into view if it is a file (both inside the current - * directory). - */ - fun refreshPasswordList(target: File? = null) { - val plist = getPasswordFragment() - if (target?.isDirectory == true && model.currentDir.value.contains(target)) { - plist?.navigateTo(target) - } else if (target?.isFile == true && model.currentDir.value.contains(target)) { - // Creating new passwords is handled by an activity, so we will refresh in onStart. - plist?.scrollToOnNextRefresh(target) - } else if (model.currentDir.value.isDirectory) { - model.forceRefresh() - } else { - model.reset() - supportActionBar?.setDisplayHomeAsUpEnabled(false) - } - } - - private val currentDir: File - get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() - - private suspend fun moveFile(source: File, destinationFile: File) { - val sourceDestinationMap = - if (source.isDirectory) { - destinationFile.mkdirs() - // Recursively list all files (not directories) below `source`, then - // obtain the corresponding target file by resolving the relative path - // starting at the destination folder. - source.listFilesRecursively().associateWith { - destinationFile.resolve(it.relativeTo(source)) - } - } else { - mapOf(source to destinationFile) - } - if (!source.renameTo(destinationFile)) { - logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(R.string.password_move_error_title) - .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } else { - AutofillMatcher.updateMatches(this, sourceDestinationMap) - } - } - - fun matchPasswordWithApp(item: PasswordItem) { - val path = - item.file.absolutePath - .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") - .replace(".gpg", "") - val data = Intent() - data.putExtra("path", path) - setResult(RESULT_OK, data) - finish() - } - - companion object { - - const val REQUEST_ARG_PATH = "PATH" - - private fun isPrintable(c: Char): Boolean { - val block = UnicodeBlock.of(c) - return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt deleted file mode 100644 index d276c552..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("BlockingMethodInNonBlockingContext") - -package app.passwordstore.ui.pgp - -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.crypto.KeyUtils.tryGetId -import app.passwordstore.crypto.PGPKey -import app.passwordstore.crypto.PGPKeyManager -import app.passwordstore.crypto.errors.KeyAlreadyExistsException -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlinx.coroutines.runBlocking - -@AndroidEntryPoint -class PGPKeyImportActivity : AppCompatActivity() { - - /** - * A [ByteArray] containing the contents of the previously selected file. This is necessary for - * the replacement case where we do not want users to have to pick the file again. - */ - private var lastBytes: ByteArray? = null - @Inject lateinit var keyManager: PGPKeyManager - - private val pgpKeyImportAction = - registerForActivityResult(OpenDocument()) { uri -> - runCatching { - if (uri == null) { - return@runCatching null - } - val keyInputStream = - contentResolver.openInputStream(uri) - ?: throw IllegalStateException("Failed to open selected file") - val bytes = keyInputStream.use { `is` -> `is`.readBytes() } - importKey(bytes, false) - } - .run(::handleImportResult) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pgpKeyImportAction.launch(arrayOf("*/*")) - } - - override fun onDestroy() { - lastBytes = null - super.onDestroy() - } - - private fun importKey(bytes: ByteArray, replace: Boolean): PGPKey? { - lastBytes = bytes - val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes), replace = replace) } - if (replace) { - lastBytes = null - } - if (error != null) throw error - return key - } - - private fun handleImportResult(result: Result<PGPKey?, Throwable>) { - if (result.isOk) { - val key = result.value - if (key == null) { - setResult(RESULT_CANCELED) - finish() - // This return convinces Kotlin that the control flow for `key == null` definitely - // terminates here and allows for a smart cast below. - return - } - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_succeeded)) - .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key))) - .setPositiveButton(android.R.string.ok) { _, _ -> - setResult(RESULT_OK) - finish() - } - .setCancelable(false) - .show() - } else { - if (result.error is KeyAlreadyExistsException && lastBytes != null) { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(getString(R.string.pgp_key_import_failed_replace_message)) - .setPositiveButton(R.string.dialog_yes) { _, _ -> - handleImportResult(runCatching { importKey(lastBytes!!, replace = true) }) - } - .setNegativeButton(R.string.dialog_no) { _, _ -> finish() } - .setCancelable(false) - .show() - } else { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.pgp_key_import_failed)) - .setMessage(result.error.message) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .setCancelable(false) - .show() - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt deleted file mode 100644 index 312f5fb0..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import androidx.compose.foundation.Image -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.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingLarge -import app.passwordstore.util.extensions.conditional -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -@Composable -fun KeyList( - identifiers: ImmutableList<PGPIdentifier>, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - if (identifiers.isEmpty()) { - Column( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - painter = painterResource(id = R.drawable.ic_launcher_foreground), - contentDescription = "Password Store logo", - ) - Text(stringResource(R.string.pgp_key_manager_no_keys_guidance)) - } - } else { - LazyColumn(modifier = modifier) { - items(identifiers) { identifier -> - KeyItem(identifier = identifier, onItemClick = onItemClick, onKeySelected = onKeySelected) - } - } - } -} - -@Composable -private fun KeyItem( - identifier: PGPIdentifier, - onItemClick: (identifier: PGPIdentifier) -> Unit, - modifier: Modifier = Modifier, - onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null, -) { - var isDeleting by remember { mutableStateOf(false) } - DeleteConfirmationDialog( - isDeleting = isDeleting, - onDismiss = { isDeleting = false }, - onConfirm = { - onItemClick(identifier) - isDeleting = false - }, - ) - val label = - when (identifier) { - is PGPIdentifier.KeyId -> identifier.id.toString() - is PGPIdentifier.UserId -> identifier.email - } - Row( - modifier = - modifier.padding(SpacingLarge).fillMaxWidth().conditional(onKeySelected != null) { - clickable { onKeySelected?.invoke(identifier) } - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - modifier = Modifier.weight(1f), - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - if (onKeySelected == null) { - IconButton(onClick = { isDeleting = true }) { - Icon( - painter = painterResource(R.drawable.ic_delete_24dp), - stringResource(id = R.string.delete), - ) - } - } - } -} - -@Suppress("NOTHING_TO_INLINE") -@Composable -private inline fun DeleteConfirmationDialog( - isDeleting: Boolean, - noinline onDismiss: () -> Unit, - noinline onConfirm: () -> Unit, -) { - if (isDeleting) { - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text(text = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title)) - }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.dialog_yes)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.dialog_no)) } - }, - ) - } -} - -@Preview -@Composable -private fun KeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList( - identifiers = - listOfNotNull( - PGPIdentifier.fromString("ultramicroscopicsilicovolcanoconiosis@example.com"), - PGPIdentifier.fromString("0xB950AE2813841585"), - ) - .toPersistentList(), - onItemClick = {}, - ) - } - } -} - -@Preview -@Composable -private fun EmptyKeyListPreview() { - APSTheme { - Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { - KeyList(identifiers = persistentListOf(), onItemClick = {}) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt deleted file mode 100644 index 77b9aaf1..00000000 --- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.pgp - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import app.passwordstore.R -import app.passwordstore.ui.APSAppBar -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.util.viewmodel.PGPKeyListViewModel -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class PGPKeyListActivity : ComponentActivity() { - - private val viewModel: PGPKeyListViewModel by viewModels() - private val keyImportAction = - registerForActivityResult(StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - viewModel.updateKeySet() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isSelecting = intent.extras?.getBoolean(EXTRA_KEY_SELECTION) ?: false - setContent { - APSTheme { - Scaffold( - topBar = { - APSAppBar( - title = stringResource(R.string.activity_label_pgp_key_manager), - navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp), - onNavigationIconClick = { finish() }, - backgroundColor = MaterialTheme.colorScheme.surface, - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { keyImportAction.launch(Intent(this, PGPKeyImportActivity::class.java)) } - ) { - Icon( - painter = painterResource(R.drawable.ic_add_48dp), - stringResource(R.string.pref_import_pgp_key_title), - ) - } - }, - ) { paddingValues -> - KeyList( - identifiers = viewModel.keys, - onItemClick = viewModel::deleteKey, - modifier = Modifier.padding(paddingValues), - onKeySelected = - if (isSelecting) { - { identifier -> - val result = Intent() - result.putExtra(EXTRA_SELECTED_KEY, identifier.toString()) - setResult(RESULT_OK, result) - finish() - } - } else null, - ) - } - } - } - } - - companion object { - const val EXTRA_SELECTED_KEY = "SELECTED_KEY" - private const val EXTRA_KEY_SELECTION = "KEY_SELECTION_MODE" - - fun newSelectionActivity(context: Context): Intent { - val intent = Intent(context, PGPKeyListActivity::class.java) - intent.putExtra(EXTRA_KEY_SELECTION, true) - return intent - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt deleted file mode 100644 index cb5fbf40..00000000 --- a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.proxy - -import android.content.SharedPreferences -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.postDelayed -import androidx.core.widget.doOnTextChanged -import app.passwordstore.R -import app.passwordstore.databinding.ActivityProxySelectorBinding -import app.passwordstore.injection.prefs.ProxyPreferences -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.proxy.ProxyUtils -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PreferenceKeys -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class ProxySelectorActivity : AppCompatActivity() { - - @Inject lateinit var gitSettings: GitSettings - @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences - @Inject lateinit var proxyUtils: ProxyUtils - private val binding by viewBinding(ActivityProxySelectorBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) - proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) - proxyPrefs - .getInt(PreferenceKeys.PROXY_PORT, -1) - .takeIf { it != -1 } - ?.let { proxyPort.setText("$it") } - proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) - save.setOnClickListener { saveSettings() } - proxyHost.doOnTextChanged { text, _, _, _ -> - if (text != null) { - proxyHost.error = - if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) { - null - } else { - getString(R.string.invalid_proxy_url) - } - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun isNumericAddress(text: CharSequence): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - InetAddresses.isNumericAddress(text as String) - } else { - @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() - } - } - - private fun saveSettings() { - proxyPrefs.edit { - binding.proxyHost.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyHost = it } - binding.proxyUser.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyUsername = it } - binding.proxyPort.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - ?.let { gitSettings.proxyPort = it.toInt() } - binding.proxyPassword.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyPassword = it } - } - proxyUtils.setDefaultProxy() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - - private companion object { - private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt deleted file mode 100644 index afb2a691..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.annotation.SuppressLint -import android.content.Intent -import android.provider.Settings -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import app.passwordstore.BuildConfig -import app.passwordstore.R -import app.passwordstore.util.extensions.autofillManager -import app.passwordstore.util.settings.PreferenceKeys -import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel -import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.SwitchPreference - -class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { - - private val isAutofillServiceEnabled: Boolean - get() { - return activity.autofillManager?.hasEnabledAutofillServices() == true - } - - private fun showAutofillDialog(pref: SwitchPreference) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - pref.checked = isAutofillServiceEnabled - } - else -> {} - } - } - MaterialAlertDialogBuilder(activity).run { - setTitle(R.string.pref_autofill_enable_title) - @SuppressLint("InflateParams") - val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) - val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers) - supportedBrowsersTextView.text = - getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") { - val appLabel = it.first - val supportDescription = - when (it.second) { - BrowserAutofillSupportLevel.None -> - activity.getString(R.string.oreo_autofill_no_support) - BrowserAutofillSupportLevel.FlakyFill -> - activity.getString(R.string.oreo_autofill_flaky_fill_support) - BrowserAutofillSupportLevel.PasswordFill -> - activity.getString(R.string.oreo_autofill_password_fill_support) - BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> - activity.getString( - R.string.oreo_autofill_password_fill_and_conditional_save_support - ) - BrowserAutofillSupportLevel.GeneralFill -> - activity.getString(R.string.oreo_autofill_general_fill_support) - BrowserAutofillSupportLevel.GeneralFillAndSave -> - activity.getString(R.string.oreo_autofill_general_fill_and_save_support) - } - "$appLabel: $supportDescription" - } - setView(layout) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val intent = - Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { - data = "package:${BuildConfig.APPLICATION_ID}".toUri() - } - activity.startActivity(intent) - } - setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { pref.checked = isAutofillServiceEnabled } - activity.lifecycle.addObserver(observer) - show() - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - switch(PreferenceKeys.AUTOFILL_ENABLE) { - titleRes = R.string.pref_autofill_enable_title - defaultValue = isAutofillServiceEnabled - onClick { - if (isAutofillServiceEnabled) { - activity.autofillManager?.disableAutofillServices() - } else { - showAutofillDialog(this) - } - false - } - } - editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_default_username_title - summaryProvider = { activity.getString(R.string.preference_default_username_summary) } - } - editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) { - dependency = PreferenceKeys.AUTOFILL_ENABLE - titleRes = R.string.preference_custom_public_suffixes_title - summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) } - textInputHintRes = R.string.preference_custom_public_suffixes_hint - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt deleted file mode 100644 index e3594269..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.pm.ShortcutManager -import android.os.Build -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.settings.DirectoryStructure -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem - -class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val themeValues = activity.resources.getStringArray(R.array.app_theme_values) - val themeOptions = activity.resources.getStringArray(R.array.app_theme_options) - val themeItems = - themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.APP_THEME, themeItems) { - initialSelection = activity.resources.getString(R.string.app_theme_def) - titleRes = R.string.pref_app_theme_title - } - - val values = activity.resources.getStringArray(R.array.directory_structure_values) - val titles = activity.resources.getStringArray(R.array.directory_structure_entries) - val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.DIRECTORY_STRUCTURE, items) { - initialSelection = DirectoryStructure.DEFAULT.value - titleRes = R.string.pref_directory_structure - } - - val sortValues = activity.resources.getStringArray(R.array.sort_order_values) - val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries) - val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.SORT_ORDER, sortItems) { - initialSelection = sortValues[0] - titleRes = R.string.pref_sort_order_title - } - - switch(PreferenceKeys.DISABLE_SYNC_ACTION) { - titleRes = R.string.pref_disable_sync_on_pull_title - summaryRes = R.string.pref_disable_sync_on_pull_summary - defaultValue = false - } - - switch(PreferenceKeys.FILTER_RECURSIVELY) { - titleRes = R.string.pref_recursive_filter_title - summaryRes = R.string.pref_recursive_filter_summary - defaultValue = true - } - - switch(PreferenceKeys.SEARCH_ON_START) { - titleRes = R.string.pref_search_on_start_title - summaryRes = R.string.pref_search_on_start_summary - defaultValue = false - } - - switch(PreferenceKeys.SHOW_HIDDEN_CONTENTS) { - titleRes = R.string.pref_show_hidden_title - summaryRes = R.string.pref_show_hidden_summary - defaultValue = false - } - - // See https://github.com/android-password-store/Android-Password-Store/issues/2802 - val disableAuth = Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE - val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) - switch(PreferenceKeys.BIOMETRIC_AUTH_2) { - titleRes = R.string.pref_biometric_auth_title - defaultValue = false - enabled = !disableAuth - summaryRes = - if (disableAuth) R.string.pref_biometric_auth_summary_disabled_platform - else if (canAuthenticate) R.string.pref_biometric_auth_summary - else R.string.pref_biometric_auth_summary_error - onClick { - enabled = false - val isChecked = checked - activity.sharedPrefs.edit { - BiometricAuthenticator.authenticate(activity) { result -> - when (result) { - is Result.Success -> { - // Apply the changes - putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, checked) - enabled = true - } - is Result.Retry -> {} - else -> { - // If any error occurs, revert back to the previous - // state. This - // catch-all clause includes the cancellation case. - putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, !checked) - checked = !isChecked - enabled = true - } - } - } - } - activity.getSystemService<ShortcutManager>()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - false - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt new file mode 100644 index 00000000..d008eea3 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupDialog +import app.passwordstore.util.extensions.snackbar +import com.github.michaelbull.result.fold +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Author name and email with Save, then the repository utilities: HEAD ([head], already a + * sentence), commit log, abort rebase ([canAbort]), hard reset, gc. [busy] disables everything. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitConfigScreen( + name: String, + email: String, + head: String, + canAbort: Boolean, + busy: Boolean, + onNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onSave: () -> Unit, + onLog: () -> Unit, + onAbort: () -> Unit, + onReset: () -> Unit, + onGc: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.pref_edit_git_config)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + val field = Modifier.fillMaxWidth() + OutlinedTextField( + value = name, + onValueChange = onNameChange, + modifier = field, + enabled = !busy, + label = { Text(stringResource(R.string.git_user_name_hint)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + modifier = field, + enabled = !busy, + label = { Text(stringResource(R.string.git_user_email)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + ) + Button(onClick = onSave, modifier = Modifier.align(Alignment.End), enabled = !busy) { + Text(stringResource(R.string.crypto_save)) + } + Text(stringResource(R.string.git_tools), style = MaterialTheme.typography.titleLarge) + Text(head) + if (busy) LinearProgressIndicator(field) + OutlinedButton(onClick = onLog, modifier = field, enabled = !busy) { + Text(stringResource(R.string.git_log)) + } + OutlinedButton(onClick = onAbort, modifier = field, enabled = canAbort && !busy) { + Text(stringResource(R.string.abort_rebase)) + } + OutlinedButton(onClick = onReset, modifier = field, enabled = !busy) { + Text(stringResource(R.string.reset_to_remote)) + } + OutlinedButton(onClick = onGc, modifier = field, enabled = !busy) { + Text(stringResource(R.string.git_run_gc_job)) + } + } + } +} + +/** Asks before a hard reset, for the remote [branch] to reset to. */ +@Composable +fun ResetDialog( + branch: String, + onBranchChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.reset_to_remote)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(SpacingLarge)) { + Text(stringResource(R.string.git_reset_confirm_message)) + OutlinedTextField( + value = branch, + onValueChange = onBranchChange, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.git_utils_reset_remote_branch_title)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false), + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm, enabled = branch.isNotBlank()) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private enum class GitConfigDialog { + InvalidEmail, + Reset, +} + +/** + * The git config screen: wires [GitConfigScreen] to the [viewModel] and runs the utilities as git + * operations of [activity]. A successful reset or gc stays here with a short snackbar; an abort + * shows its report, which closes the screen; a failure shows the git error dialog and the screen + * stays. + */ +@Composable +fun GitConfigDestination( + activity: MainActivity, + onClose: () -> Unit, + onLog: () -> Unit, + modifier: Modifier = Modifier, + viewModel: GitConfigViewModel = hiltViewModel(), +) { + var dialog by rememberSaveable { mutableStateOf<GitConfigDialog?>(null) } + var branch by rememberSaveable { mutableStateOf("") } + + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose {} + } + // A running operation holds the screen: leaving would cancel it half way. + BackHandler(enabled = viewModel.busy) {} + // A finished reset or gc is announced once, also when the screen was recreated in between: + // the result stays in the ViewModel until the snackbar was shown. + val done = viewModel.done + LaunchedEffect(done) { + val label = + when (done) { + GitOp.RESET -> R.string.reset_to_remote + GitOp.GC -> R.string.git_run_gc_job + else -> return@LaunchedEffect + } + activity.snackbar( + message = activity.getString(R.string.git_tool_done, activity.getString(label)), + length = Snackbar.LENGTH_LONG, + ) + viewModel.done = null + } + + // ponytail: the activity's scope, like every git operation of the app: a theme or locale change + // cancels it, and the finally block frees the screen. + fun run(op: GitOp, remote: String = "") { + if (viewModel.busy) return + viewModel.busy = true + activity.lifecycleScope.launch { + try { + activity + .gitOperation(op, remote) + .fold( + success = { + if (op == GitOp.BREAK_OUT_OF_DETACHED) + viewModel.abortedBranch = PasswordRepository.getCurrentBranch() + viewModel.done = op + }, + failure = { activity.promptOnErrorHandler(it) }, + ) + } finally { + viewModel.busy = false + viewModel.refresh() + } + } + } + + val head = + when (val status = viewModel.head) { + is HeadStatus.Branch -> stringResource(R.string.git_head_on_branch, status.name) + is HeadStatus.Detached -> stringResource(R.string.git_head_detached, status.hash) + HeadStatus.Missing -> stringResource(R.string.git_head_missing) + null -> "" + } + Box(modifier) { + GitConfigScreen( + name = viewModel.name, + email = viewModel.email, + head = head, + canAbort = viewModel.canAbort, + busy = viewModel.busy, + onNameChange = { viewModel.name = it }, + onEmailChange = { viewModel.email = it }, + onSave = { + if (viewModel.save()) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT) + .show() + onClose() + } else dialog = GitConfigDialog.InvalidEmail + }, + onLog = onLog, + onAbort = { + // Checked again: BreakOutOfDetached reports success without doing anything otherwise. + if (PasswordRepository.repository?.let(::needsAbort) == true) + run(GitOp.BREAK_OUT_OF_DETACHED) + else viewModel.refresh() + }, + onReset = { + branch = PasswordRepository.getCurrentBranch().orEmpty() + dialog = GitConfigDialog.Reset + }, + onGc = { run(GitOp.GC) }, + onBack = { if (!viewModel.busy) onClose() }, + ) + when (dialog) { + null -> Unit + GitConfigDialog.InvalidEmail -> + SetupDialog( + text = stringResource(R.string.invalid_email_dialog_text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = null }, + onDismiss = { dialog = null }, + ) + GitConfigDialog.Reset -> + ResetDialog( + branch = branch, + onBranchChange = { branch = it }, + onConfirm = { + dialog = null + run(GitOp.RESET, branch.trim()) + }, + onDismiss = { dialog = null }, + ) + } + if (done == GitOp.BREAK_OUT_OF_DETACHED) { + val aborted = viewModel.abortedBranch.orEmpty() + // BreakOutOfDetached names the pushed branch conflicting-<branch>-<millis>: the newest one. + val pushed = + PasswordRepository.repository + ?.refDatabase + ?.getRefsByPrefix("refs/heads/conflicting-$aborted-") + ?.maxOfOrNull { it.name.removePrefix("refs/heads/") } ?: "conflicting-$aborted-..." + SetupDialog( + title = stringResource(R.string.git_abort_and_push_title), + text = stringResource(R.string.git_break_out_of_detached_success, aborted, pushed), + confirm = stringResource(R.string.dialog_ok), + onConfirm = onClose, + onDismiss = onClose, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt new file mode 100644 index 00000000..8882ed87 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.util.Patterns +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.settings.GitSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The git config screen's state: author name and email being edited, HEAD, and the git operation + * that runs or succeeded. The operation itself runs in the destination (it needs the activity); + * this ViewModel keeps what must survive a recreation. + */ +@HiltViewModel +class GitConfigViewModel +@Inject +constructor( + private val gitSettings: GitSettings, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var name by mutableStateOf(gitSettings.authorName) + var email by mutableStateOf(gitSettings.authorEmail) + + var head by mutableStateOf<HeadStatus?>(null) + private set + + var canAbort by mutableStateOf(false) + private set + + /** A git operation runs; everything waits. */ + var busy by mutableStateOf(false) + + /** + * The operation that succeeded and is not reported yet: a reset or gc gets a snackbar (then this + * goes back to null), an abort its dialog. + */ + var done by mutableStateOf<GitOp?>(null) + + /** The branch an abort pushed from, for its report. */ + var abortedBranch by mutableStateOf<String?>(null) + + init { + refresh() + } + + /** Re-reads HEAD and the rebase state, off the main thread. */ + fun refresh() { + viewModelScope.launch { + val repo = PasswordRepository.repository + val (status, abort) = + withContext(dispatcherProvider.io()) { + if (repo == null) HeadStatus.Missing to false else headStatus(repo) to needsAbort(repo) + } + head = status + canAbort = abort + } + } + + /** Saves name and email; false, saving nothing, when the email is not an address. */ + fun save(): Boolean { + val address = email.trim() + if (!Patterns.EMAIL_ADDRESS.matcher(address).matches()) return false + gitSettings.authorEmail = address + gitSettings.authorName = name.trim() + return true + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt new file mode 100644 index 00000000..276c9c11 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.git.GitCommit +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The store's commits, read once off the main thread. */ +@HiltViewModel +class GitLogViewModel @Inject constructor(dispatcherProvider: DispatcherProvider) : ViewModel() { + + /** Null while loading. */ + var commits by mutableStateOf<ImmutableList<GitCommit>?>(null) + private set + + init { + viewModelScope.launch { + commits = + withContext(dispatcherProvider.io()) { + gitLog(PasswordRepository.repository).toImmutableList() + } + } + } +} + +/** + * The commit log, newest first: message, short hash, date. A progress bar while [commits] is null. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitLogScreen( + commits: ImmutableList<GitCommit>?, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val formatter = remember { + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_activity_git_log)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + if (commits == null) LinearProgressIndicator(Modifier.padding(padding).fillMaxWidth()) + else + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(commits, key = GitCommit::hash) { commit -> + ListItem( + headlineContent = { Text(commit.shortMessage) }, + overlineContent = { Text(commit.hash.take(8), fontFamily = FontFamily.Monospace) }, + supportingContent = { Text(formatter.format(commit.time)) }, + ) + HorizontalDivider() + } + } + } +} + +@Composable +fun GitLogDestination( + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: GitLogViewModel = hiltViewModel(), +) { + GitLogScreen(commits = viewModel.commits, onBack = onClose, modifier = modifier) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt deleted file mode 100644 index 9a5d1e3b..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.FragmentActivity -import app.passwordstore.BuildConfig -import app.passwordstore.R -import app.passwordstore.util.services.PasswordExportService -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch - -class MiscSettings(activity: FragmentActivity) : SettingsProvider { - - private val storeExportAction = - activity.registerForActivityResult( - object : ActivityResultContracts.OpenDocumentTree() { - override fun createIntent(context: Context, input: Uri?): Intent { - return super.createIntent(context, input).apply { - flags = - Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION - } - } - } - ) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) - - if (targetDirectory != null) { - val service = - Intent(activity.applicationContext, PasswordExportService::class.java).apply { - action = PasswordExportService.ACTION_EXPORT_PASSWORD - putExtra("uri", uri) - } - - activity.startForegroundService(service) - } - } - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref(PreferenceKeys.EXPORT_PASSWORDS) { - titleRes = R.string.prefs_export_passwords_title - summaryRes = R.string.prefs_export_passwords_summary - onClick { - storeExportAction.launch(null) - true - } - } - switch(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) { - defaultValue = false - titleRes = R.string.pref_clear_clipboard_title - summaryRes = R.string.pref_clear_clipboard_summary - } - switch(PreferenceKeys.ENABLE_DEBUG_LOGGING) { - defaultValue = false - titleRes = R.string.pref_debug_logging_title - summaryRes = R.string.pref_debug_logging_summary - visible = !BuildConfig.DEBUG - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt deleted file mode 100644 index d2e63779..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import androidx.core.content.edit -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.ui.pgp.PGPKeyListActivity -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.features.Feature -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch -import kotlinx.coroutines.launch - -class PGPSettings( - private val activity: FragmentActivity, - private val passphraseCache: PGPPassphraseCache, -) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - pref("_") { - titleRes = R.string.pref_pgp_key_manager_title - persistent = false - onClick { - activity.launchActivity(PGPKeyListActivity::class.java) - false - } - } - switch(PreferenceKeys.ASCII_ARMOR) { - titleRes = R.string.pref_pgp_ascii_armor_title - persistent = true - } - switch(Feature.EnablePGPPassphraseCache.configKey) { - enabled = BiometricAuthenticator.canAuthenticate(activity) - titleRes = R.string.pref_passphrase_cache_title - summaryRes = R.string.pref_passphrase_cache_summary - defaultValue = false - onClick { - if (BiometricAuthenticator.canAuthenticate(activity)) { - val promptTitle = - if (checked) R.string.pref_passphrase_cache_authenticate_enable - else R.string.pref_passphrase_cache_authenticate_disable - - BiometricAuthenticator.authenticate(activity, promptTitle) { result -> - when (result) { - is BiometricAuthenticator.Result.Success -> { - /* Any successful change of this setting clears the passphrase - * cache for safety */ - activity.lifecycleScope.launch { - passphraseCache.clearAllCachedPassphrases(activity) - } - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked) - if (!checked) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) - } - } - is BiometricAuthenticator.Result.Retry -> {} - else -> { - /* revert back to previous state in case of error or cancellation */ - checked = !checked - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked) - } - } - } - } - } else { - /* we may get here if device lock has been disabled while PGP settings - * screen was left open */ - checked = false - enabled = false - activity.sharedPrefs.edit { - putBoolean(Feature.EnablePGPPassphraseCache.configKey, false) - } - } - false - } - } - switch(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) { - dependency = Feature.EnablePGPPassphraseCache.configKey - titleRes = R.string.pref_passphrase_cache_auto_clear_title - summaryRes = R.string.pref_passphrase_cache_auto_clear_summary - defaultValue = true - /* Clear the cache once when unchecking; this is to prevent a malicious user (someone - * knowing the screen-lock pin, but not knowing the PGP passphrase) from bypassing cache - * clearing via the settings. Disabling it requires authentication, and cache clearing - * stays enabled in case of any authentication failure. */ - onClick { - if (!checked) { - if ( - BiometricAuthenticator.canAuthenticate(activity) && - activity.sharedPrefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false) - ) { - BiometricAuthenticator.authenticate( - activity, - R.string.pref_passphrase_cache_auto_clear_authenticate_disable, - ) { result -> - when (result) { - is BiometricAuthenticator.Result.Success -> { - activity.sharedPrefs.edit { - putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false) - } - activity.lifecycleScope.launch { - passphraseCache.clearAllCachedPassphrases(activity) - } - } - is BiometricAuthenticator.Result.Retry -> {} - else -> { - activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) } - checked = true - } - } - } - } else { - activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) } - checked = true - enabled = false - } - } - false - } - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt deleted file mode 100644 index 9fe4cd42..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.text.InputType -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.util.settings.PreferenceKeys -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.singleChoice -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem - -class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { - - override fun provideSettings(builder: PreferenceScreen.Builder) { - builder.apply { - val values = activity.resources.getStringArray(R.array.pwgen_provider_values) - val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels) - val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) } - singleChoice(PreferenceKeys.PREF_KEY_PWGEN_TYPE, items) { - initialSelection = "diceware" - titleRes = R.string.pref_password_generator_type_title - } - editText(PreferenceKeys.GENERAL_SHOW_TIME) { - titleRes = R.string.pref_clipboard_timeout_title - summaryProvider = { timeout -> - activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45") - } - textInputType = InputType.TYPE_CLASS_NUMBER - } - switch(PreferenceKeys.SHOW_PASSWORD) { - titleRes = R.string.show_password_pref_title - summaryRes = R.string.show_password_pref_summary - defaultValue = true - } - switch(PreferenceKeys.COPY_ON_DECRYPT) { - titleRes = R.string.pref_copy_title - summaryRes = R.string.pref_copy_summary - defaultValue = false - } - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt new file mode 100644 index 00000000..cfaa741d --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import kotlinx.collections.immutable.ImmutableList + +private const val DISABLED_ALPHA = 0.38f + +/** List item colors for rows inside a grouped surface: the surface shows through. */ +@Composable +internal fun rowColors(): ListItemColors = + ListItemDefaults.colors(containerColor = Color.Transparent) + +private fun Modifier.dimmed(enabled: Boolean) = if (enabled) this else alpha(DISABLED_ALPHA) + +/** A row that runs [onClick]: opens a screen or a dialog. */ +@Composable +fun PreferenceAction( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true, +) { + ListItem( + headlineContent = { Text(title) }, + modifier = + modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick).dimmed(enabled), + supportingContent = + if (summary != null) { + { Text(summary) } + } else null, + colors = rowColors(), + ) +} + +/** + * A switch row showing [checked]. [onCheckedChange] gets the requested value; the caller decides + * whether it is stored (a screen lock prompt may come first), and the row shows what was stored. + */ +@Composable +fun PreferenceSwitch( + title: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + enabled: Boolean = true, +) { + ListItem( + headlineContent = { Text(title) }, + modifier = + modifier + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .dimmed(enabled), + supportingContent = + if (summary != null) { + { Text(summary) } + } else null, + trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) }, + colors = rowColors(), + ) +} + +/** + * A single choice among [values], shown by their [labels] (same order); the summary is the selected + * label. Tapping opens a dialog of radio buttons; choosing one closes it. + */ +@Composable +fun PreferenceChoice( + title: String, + values: ImmutableList<String>, + labels: ImmutableList<String>, + selected: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var open by rememberSaveable { mutableStateOf(false) } + PreferenceAction( + title = title, + onClick = { open = true }, + modifier = modifier, + summary = labels.getOrNull(values.indexOf(selected)), + ) + if (open) + AlertDialog( + onDismissRequest = { open = false }, + title = { Text(title) }, + text = { + Column(Modifier.selectableGroup()) { + values.forEachIndexed { index, value -> + Row( + Modifier.fillMaxWidth() + .selectable( + selected = value == selected, + role = Role.RadioButton, + onClick = { + open = false + onSelect(value) + }, + ) + .padding(vertical = SpacingMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = value == selected, onClick = null) + Text(labels[index], Modifier.padding(start = SpacingLarge)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { open = false }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** + * A text value; tapping opens a dialog to edit it. The draft is kept across recreation in saved + * state: never use this row for a secret. [KeyboardType.Number] keeps digits only. + */ +@Composable +fun PreferenceText( + title: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + summary: String? = null, + hint: String? = null, + singleLine: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Text, + enabled: Boolean = true, +) { + var draft by rememberSaveable { mutableStateOf<String?>(null) } + PreferenceAction( + title = title, + onClick = { draft = value }, + modifier = modifier, + summary = summary, + enabled = enabled, + ) + draft?.let { text -> + AlertDialog( + onDismissRequest = { draft = null }, + title = { Text(title) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { new -> + draft = if (keyboardType == KeyboardType.Number) new.filter(Char::isDigit) else new + }, + modifier = Modifier.fillMaxWidth(), + placeholder = + if (hint != null) { + { Text(hint) } + } else null, + singleLine = singleLine, + minLines = if (singleLine) 1 else 3, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + ) + }, + confirmButton = { + TextButton( + onClick = { + draft = null + onValueChange(text) + } + ) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = { draft = null }) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt new file mode 100644 index 00000000..b7116bb7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.hilt.navigation.compose.hiltViewModel +import app.passwordstore.R +import app.passwordstore.ui.compose.R as composeR +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.main.MainActivity + +/** Proxy host, port, user name and password; [hostError] and [portError] block the save. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxyScreen( + host: String, + port: String, + username: String, + password: String, + hostError: Boolean, + portError: Boolean, + onHostChange: (String) -> Unit, + onPortChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onSave: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var revealed by remember { mutableStateOf(false) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.activity_proxy_label)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + val field = Modifier.fillMaxWidth() + OutlinedTextField( + value = host, + onValueChange = onHostChange, + modifier = field, + label = { Text(stringResource(R.string.proxy_hostname)) }, + supportingText = + if (hostError) { + { Text(stringResource(R.string.invalid_proxy_url)) } + } else null, + isError = hostError, + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + ) + OutlinedTextField( + value = port, + onValueChange = { onPortChange(it.filter(Char::isDigit)) }, + modifier = field, + label = { Text(stringResource(R.string.port)) }, + supportingText = + if (portError) { + { Text(stringResource(R.string.invalid_proxy_port)) } + } else null, + isError = portError, + singleLine = true, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = username, + onValueChange = onUsernameChange, + modifier = field, + label = { Text(stringResource(R.string.username)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next), + ) + OutlinedTextField( + value = password, + onValueChange = onPasswordChange, + modifier = field, + label = { Text(stringResource(R.string.password)) }, + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) composeR.drawable.baseline_visibility_off_24 + else composeR.drawable.baseline_visibility_24 + ), + contentDescription = + stringResource( + if (revealed) R.string.entry_hide_password else R.string.entry_show_password + ), + ) + } + }, + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + ) + Button(onClick = onSave, modifier = field, enabled = !hostError && !portError) { + Text(stringResource(R.string.crypto_save)) + } + } + } +} + +/** + * The proxy screen. Holds FLAG_SECURE while shown: it shows the saved proxy password (masked, with + * a reveal toggle). + */ +@Composable +fun ProxyDestination( + activity: MainActivity, + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProxyViewModel = hiltViewModel(), +) { + DisposableEffect(activity) { + activity.holdSecure() + onDispose { activity.releaseSecure() } + } + ProxyScreen( + host = viewModel.host, + port = viewModel.port, + username = viewModel.username, + password = viewModel.password, + hostError = viewModel.hostError, + portError = viewModel.portError, + onHostChange = { viewModel.host = it }, + onPortChange = { viewModel.port = it }, + onUsernameChange = { viewModel.username = it }, + onPasswordChange = { viewModel.password = it }, + onSave = { + if (viewModel.save()) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT).show() + onClose() + } + }, + onBack = onClose, + modifier = modifier, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt new file mode 100644 index 00000000..fb2bfe93 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.net.InetAddresses +import android.os.Build +import android.util.Patterns +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import app.passwordstore.util.proxy.ProxyUtils +import app.passwordstore.util.settings.GitSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** A numeric address or a plain host name: no scheme, port or path, which the port field holds. */ +fun isProxyHost(text: String): Boolean { + val numeric = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text) + else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() + return numeric || + (!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.matcher(text).matches()) +} + +/** + * The proxy form, prefilled from the stored proxy. The password lives here in memory only, never in + * saved state; it is stored in `KeystorePreferences` through [GitSettings]. + */ +@HiltViewModel +class ProxyViewModel +@Inject +constructor(private val gitSettings: GitSettings, private val proxyUtils: ProxyUtils) : + ViewModel() { + + var host by mutableStateOf(gitSettings.proxyHost.orEmpty()) + var port by mutableStateOf(gitSettings.proxyPort.takeIf { it != -1 }?.toString().orEmpty()) + var username by mutableStateOf(gitSettings.proxyUsername.orEmpty()) + var password by mutableStateOf(gitSettings.proxyPassword.orEmpty()) + + val hostError: Boolean + get() = host.isNotBlank() && !isProxyHost(host.trim()) + + /** Invalid, or empty while a host is set: a proxy without a port is not used at all. */ + val portError: Boolean + get() = proxyPort(port).let { it == null || (it == -1 && host.isNotBlank()) } + + /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */ + fun save(): Boolean { + if (hostError || portError) return false + val portValue = proxyPort(port) ?: return false + gitSettings.proxyHost = host.trim().ifEmpty { null } + gitSettings.proxyPort = portValue + gitSettings.proxyUsername = username.trim().ifEmpty { null } + gitSettings.proxyPassword = password.ifEmpty { null } + proxyUtils.setDefaultProxy() + return true + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt deleted file mode 100644 index fac95949..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.ShortcutManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.GitPreferences -import app.passwordstore.ui.git.config.GitConfigActivity -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.ui.proxy.ProxySelectorActivity -import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.launchActivity -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy -import app.passwordstore.util.git.sshj.SshKey -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.pref -import de.Maxr1998.modernpreferences.helpers.switch - -class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { - - private val generateSshKey = - activity.registerForActivityResult(StartActivityForResult()) { - showSshKeyPref?.visible = SshKey.canShowSshPublicKey - } - - private val hiltEntryPoint by unsafeLazy { - EntryPointAccessors.fromApplication( - activity.applicationContext, - RepositorySettingsEntryPoint::class.java, - ) - } - - private var showSshKeyPref: Preference? = null - - override fun provideSettings(builder: PreferenceScreen.Builder) { - val encryptedPreferences = hiltEntryPoint.encryptedPreferences() - val gitSettings = hiltEntryPoint.gitSettings() - - builder.apply { - switch(PreferenceKeys.REBASE_ON_PULL) { - titleRes = R.string.pref_rebase_on_pull_title - summaryRes = R.string.pref_rebase_on_pull_summary - summaryOnRes = R.string.pref_rebase_on_pull_summary_on - defaultValue = true - } - pref(PreferenceKeys.GIT_SERVER_INFO) { - titleRes = R.string.pref_edit_git_server_settings - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitServerConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.PROXY_SETTINGS) { - titleRes = R.string.pref_edit_proxy_settings - visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(ProxySelectorActivity::class.java) - true - } - } - pref(PreferenceKeys.GIT_CONFIG) { - titleRes = R.string.pref_edit_git_config - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(GitConfigActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEY) { - titleRes = R.string.pref_import_ssh_key_title - visible = PasswordRepository.isGitRepo() - onClick { - activity.launchActivity(SshKeyImportActivity::class.java) - true - } - } - pref(PreferenceKeys.SSH_KEYGEN) { - titleRes = R.string.pref_ssh_keygen_title - onClick { - generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java)) - true - } - } - showSshKeyPref = - pref(PreferenceKeys.SSH_SEE_KEY) { - titleRes = R.string.pref_ssh_see_key_title - visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey - onClick { - ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") - true - } - } - pref(PreferenceKeys.CLEAR_SAVED_PASS) { - fun Preference.updatePref() { - val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) - if (sshPass == null && httpsPass == null) { - visible = false - return - } - titleRes = - when { - httpsPass != null -> R.string.clear_saved_passphrase_https - else -> R.string.clear_saved_passphrase_ssh - } - visible = true - requestRebind() - } - onClick { - updatePref() - true - } - updatePref() - } - pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) { - titleRes = R.string.pref_title_openkeystore_clear_keyid - visible = - activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() - ?: false - onClick { - activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } - visible = false - true - } - } - pref(PreferenceKeys.GIT_DELETE_REPO) { - titleRes = R.string.pref_git_delete_repo_title - summaryRes = R.string.pref_git_delete_repo_summary - onClick { - val repoDir = PasswordRepository.getRepositoryDirectory() - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.pref_dialog_delete_title) - .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> - runCatching { - PasswordRepository.closeRepository() - PasswordRepository.getRepositoryDirectory().let { dir -> - dir.deleteRecursively() - dir.mkdirs() - } - } - .onFailure { it.message?.let { message -> activity.snackbar(message = message) } } - - activity.getSystemService<ShortcutManager>()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } - dialogInterface.cancel() - activity.finish() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> - run { dialogInterface.cancel() } - } - .show() - true - } - } - } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface RepositorySettingsEntryPoint { - fun gitSettings(): GitSettings - - @GitPreferences fun encryptedPreferences(): SharedPreferences - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt deleted file mode 100644 index 02addd84..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.os.Bundle -import android.view.MenuItem -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.BundleCompat -import app.passwordstore.R -import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding -import app.passwordstore.util.extensions.viewBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import de.Maxr1998.modernpreferences.Preference -import de.Maxr1998.modernpreferences.PreferencesAdapter -import de.Maxr1998.modernpreferences.helpers.screen -import de.Maxr1998.modernpreferences.helpers.subScreen -import javax.inject.Inject - -@AndroidEntryPoint -class SettingsActivity : AppCompatActivity() { - - @Inject lateinit var passphraseCache: PGPPassphraseCache - private val miscSettings = MiscSettings(this) - private val autofillSettings = AutofillSettings(this) - private val passwordSettings = PasswordSettings(this) - private val repositorySettings = RepositorySettings(this) - private val generalSettings = GeneralSettings(this) - private lateinit var pgpSettings: PGPSettings - - private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate) - private val preferencesAdapter: PreferencesAdapter - get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } - pgpSettings = PGPSettings(this, passphraseCache) - val screen = - screen(this) { - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_general_title - iconRes = R.drawable.app_settings_alt_24px - generalSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_autofill_title - iconRes = R.drawable.ic_wysiwyg_24px - autofillSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_passwords_title - iconRes = R.drawable.ic_password_24px - passwordSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_repository_title - iconRes = R.drawable.ic_call_merge_24px - repositorySettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_misc_title - iconRes = R.drawable.ic_miscellaneous_services_24px - miscSettings.provideSettings(this) - } - subScreen { - collapseIcon = true - titleRes = R.string.pref_category_pgp_title - iconRes = R.drawable.ic_lock_open_24px - pgpSettings.provideSettings(this) - } - } - val backPressedCallback = - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - preferencesAdapter.goBack() - } - } - onBackPressedDispatcher.addCallback(backPressedCallback) - val adapter = PreferencesAdapter(screen) - adapter.onScreenChangeListener = - PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> - backPressedCallback.isEnabled = entering - supportActionBar?.title = - if (!entering) { - getString(R.string.action_settings) - } else { - getString(subScreen.titleRes) - } - } - if (savedInstanceState != null) { - BundleCompat.getParcelable( - savedInstanceState, - "adapter", - PreferencesAdapter.SavedState::class.java, - ) - ?.let(adapter::loadSavedState) - } - binding.preferenceRecyclerView.adapter = adapter - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putParcelable("adapter", preferencesAdapter.getSavedState()) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> - if (!preferencesAdapter.goBack()) { - super.onOptionsItemSelected(item) - } else { - true - } - else -> super.onOptionsItemSelected(item) - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt new file mode 100644 index 00000000..c3809b36 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt @@ -0,0 +1,304 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupDialog +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.auth.BiometricAuthenticator.Result +import app.passwordstore.util.extensions.autofillManager +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.features.Feature +import app.passwordstore.util.services.PasswordExportService +import app.passwordstore.util.settings.PreferenceKeys +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel + +private enum class SettingsDialog { + Autofill, + ClearSecret, + DeleteRepository, +} + +/** The export's folder picker, with the persistable write grant `PasswordExportService` needs. */ +private class ExportFolder : ActivityResultContracts.OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent = + super.createIntent(context, input).apply { + flags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + } +} + +private fun MainActivity.autofillEnabled() = autofillManager?.hasEnabledAutofillServices() == true + +/** + * One settings category: wires [SettingsSectionScreen] to the [viewModel] and to what needs the + * activity: screen lock prompts, the autofill service, the export picker, confirmations. [onOpen] + * gets the actions that open another screen; [onDeleted] runs once the store was deleted. + */ +@Composable +fun SettingsSectionDestination( + activity: MainActivity, + section: SettingsSection, + onBack: () -> Unit, + onOpen: (SettingsAction) -> Unit, + onDeleted: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsViewModel = hiltViewModel(), +) { + var dialog by rememberSaveable { mutableStateOf<SettingsDialog?>(null) } + var canAuthenticate by remember { + mutableStateOf(BiometricAuthenticator.canAuthenticate(activity)) + } + var autofillEnabled by remember { mutableStateOf(activity.autofillEnabled()) } + + fun refresh() { + viewModel.refresh() + canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) + autofillEnabled = activity.autofillEnabled() + } + + // Back from another screen (git server, SSH key, key generation, the system's autofill + // settings): rows that depend on it follow, e.g. "View generated public SSH key". + LifecycleResumeEffect(Unit) { + refresh() + onPauseOrDispose {} + } + // A finished deletion moves on, also when the screen was recreated in between. + val deleted = viewModel.deleted + LaunchedEffect(deleted) { + if (deleted == null) return@LaunchedEffect + if (!deleted) activity.snackbar(message = activity.getString(R.string.store_delete_failed)) + onDeleted() + } + BackHandler(enabled = viewModel.deleting) {} + + val export = + rememberLauncherForActivityResult(ExportFolder()) { uri -> + if (uri != null && DocumentFile.fromTreeUri(activity, uri) != null) + activity.startForegroundService( + Intent(activity, PasswordExportService::class.java) + .setAction(PasswordExportService.ACTION_EXPORT_PASSWORD) + .putExtra("uri", uri) + ) + } + + // One screen lock prompt at a time: a double tap must not open two. A recreation resets it + // (remember), and the old activity's prompt is gone with it. + var prompting by remember { mutableStateOf(false) } + + fun authenticate(@StringRes title: Int, onResult: (Result) -> Unit) { + if (prompting) return + prompting = true + BiometricAuthenticator.authenticate(activity, title) { result -> + // Retry: a finger was not recognized, the prompt stays open. Errors close it and are + // terminal (Failure, Canceled*, HardwareUnavailableOrDisabled). + if (result !is Result.Retry) prompting = false + onResult(result) + } + } + + // Values are written only once allowed: a failed or cancelled prompt leaves the stored value, + // which the switch shows. + fun setBoolean(key: String, value: Boolean) { + when (key) { + PreferenceKeys.AUTOFILL_ENABLE -> + if (value) dialog = SettingsDialog.Autofill + else { + activity.autofillManager?.disableAutofillServices() + // hasEnabledAutofillServices() still answers true right after this call. + autofillEnabled = false + } + PreferenceKeys.BIOMETRIC_AUTH_2 -> { + fun write() { + viewModel.putBoolean(key, value) + // Shortcuts were built for the old lock setting, as the old screen knew. + activity.getSystemService<ShortcutManager>()?.removeAllDynamicShortcuts() + } + // Without a screen lock no prompt can open: turning the app lock off needs none. + if (!value && !BiometricAuthenticator.canAuthenticate(activity)) write() + else + authenticate(R.string.biometric_prompt_title) { result -> + if (result is Result.Success) write() + } + } + Feature.EnablePGPPassphraseCache.configKey -> + if (!BiometricAuthenticator.canAuthenticate(activity)) viewModel.setPassphraseCache(false) + else + authenticate( + if (value) R.string.pref_passphrase_cache_authenticate_enable + else R.string.pref_passphrase_cache_authenticate_disable + ) { result -> + if (result is Result.Success) viewModel.setPassphraseCache(value) + } + PreferenceKeys.CLEAR_PASSPHRASE_CACHE -> + when { + value -> viewModel.putBoolean(key, true) + !BiometricAuthenticator.canAuthenticate(activity) -> viewModel.resetAutoClear() + else -> + authenticate(R.string.pref_passphrase_cache_auto_clear_authenticate_disable) { result -> + when (result) { + is Result.Success -> viewModel.disableAutoClear() + is Result.Retry -> Unit + else -> viewModel.resetAutoClear() + } + } + } + else -> viewModel.putBoolean(key, value) + } + } + + Box(modifier) { + SettingsSectionScreen( + section = section, + state = viewModel.state, + canAuthenticate = canAuthenticate, + autofillEnabled = autofillEnabled, + deleting = viewModel.deleting, + // Nothing else starts while the store is being deleted. + onBoolean = { key, value -> if (!viewModel.deleting) setBoolean(key, value) }, + onString = { key, value -> if (!viewModel.deleting) viewModel.putString(key, value) }, + onAction = { action -> + if (!viewModel.deleting) + when (action) { + SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret + SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository + SettingsAction.Export -> export.launch(null) + else -> onOpen(action) + } + }, + onBack = { if (!viewModel.deleting) onBack() }, + ) + when (dialog) { + null -> Unit + SettingsDialog.Autofill -> + AutofillDialog( + onConfirm = { + dialog = null + activity.startActivity( + Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE) + .setData("package:${BuildConfig.APPLICATION_ID}".toUri()) + ) + }, + onDismiss = { dialog = null }, + ) + SettingsDialog.ClearSecret -> + SetupDialog( + title = viewModel.state.savedSecret?.let { stringResource(it) }, + text = stringResource(R.string.clear_saved_secret_message), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { + dialog = null + viewModel.clearSavedSecret() + }, + onDismiss = { dialog = null }, + dismiss = stringResource(R.string.dialog_cancel), + ) + SettingsDialog.DeleteRepository -> + SetupDialog( + title = stringResource(R.string.pref_dialog_delete_title), + text = stringResource(R.string.store_delete_confirm), + confirm = stringResource(R.string.dialog_delete), + onConfirm = { + dialog = null + viewModel.deleteRepository() + }, + onDismiss = { dialog = null }, + dismiss = stringResource(R.string.dialog_do_not_delete), + ) + } + } +} + +/** The old autofill instructions: what it does, how to enable it, and the installed browsers. */ +@Composable +private fun AutofillDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val browsers = remember { getInstalledBrowsersWithAutofillSupportLevel(context) } + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.pref_autofill_enable_title)) }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + Text(stringResource(R.string.oreo_autofill_enable_dialog_description)) + Text(stringResource(R.string.oreo_autofill_enable_dialog_instructions)) + Column { + Text( + stringResource(R.string.oreo_autofill_enable_dialog_installed_browsers), + fontWeight = FontWeight.Bold, + ) + browsers.forEach { (label, level) -> Text("$label: ${stringResource(level.label())}") } + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@StringRes +private fun BrowserAutofillSupportLevel.label(): Int = + when (this) { + BrowserAutofillSupportLevel.None -> R.string.oreo_autofill_no_support + BrowserAutofillSupportLevel.FlakyFill -> R.string.oreo_autofill_flaky_fill_support + BrowserAutofillSupportLevel.PasswordFill -> R.string.oreo_autofill_password_fill_support + BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> + R.string.oreo_autofill_password_fill_and_conditional_save_support + BrowserAutofillSupportLevel.GeneralFill -> R.string.oreo_autofill_general_fill_support + BrowserAutofillSupportLevel.GeneralFillAndSave -> + R.string.oreo_autofill_general_fill_and_save_support + } diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt new file mode 100644 index 00000000..ae4e277b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.GitServerRoute +import app.passwordstore.ui.setup.PgpKeysRoute +import app.passwordstore.ui.setup.SshKeyGenRoute +import app.passwordstore.ui.setup.SshKeyRoute +import app.passwordstore.ui.setup.WelcomeRoute +import kotlinx.serialization.Serializable + +/** The settings categories. */ +@Serializable data object SettingsRoute + +/** One settings category. */ +@Serializable data class SettingsSectionRoute(val section: SettingsSection) + +/** Author name and email, and the repository utilities. */ +@Serializable data object GitConfigRoute + +/** The store's commit log. */ +@Serializable data object GitLogRoute + +/** HTTP(S) proxy settings. */ +@Serializable data object ProxyRoute + +/** The settings destinations, and where their rows lead. */ +fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController) { + fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id + + fun close(entry: NavBackStackEntry) { + if (onTop(entry)) nav.popBackStack() + } + + composable<SettingsRoute> { entry -> + SettingsScreen( + onSection = { if (onTop(entry)) nav.navigate(SettingsSectionRoute(it)) }, + onBack = { close(entry) }, + ) + } + composable<SettingsSectionRoute> { entry -> + SettingsSectionDestination( + activity = activity, + section = entry.toRoute<SettingsSectionRoute>().section, + onBack = { close(entry) }, + onOpen = { action -> + if (onTop(entry)) + when (action) { + SettingsAction.GitServer -> nav.navigate(GitServerRoute(clone = false)) + SettingsAction.Proxy -> nav.navigate(ProxyRoute) + SettingsAction.GitConfig -> nav.navigate(GitConfigRoute) + SettingsAction.SshKey -> nav.navigate(SshKeyRoute) + SettingsAction.SshKeyGen -> nav.navigate(SshKeyGenRoute) + SettingsAction.KeyManager -> nav.navigate(PgpKeysRoute()) + // Handled by the destination itself. + SettingsAction.ClearSecret, + SettingsAction.DeleteRepository, + SettingsAction.Export -> Unit + } + }, + onDeleted = { + // No store any more: setup starts over, as after a deletion from the list's side. + if (onTop(entry)) nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } } + }, + ) + } + composable<GitConfigRoute> { entry -> + GitConfigDestination( + activity = activity, + onClose = { close(entry) }, + onLog = { if (onTop(entry)) nav.navigate(GitLogRoute) }, + ) + } + composable<GitLogRoute> { entry -> GitLogDestination(onClose = { close(entry) }) } + composable<ProxyRoute> { entry -> + ProxyDestination(activity = activity, onClose = { close(entry) }) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt new file mode 100644 index 00000000..eca6770c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.extensions.hash +import app.passwordstore.util.extensions.time +import app.passwordstore.util.git.GitCommit +import java.io.IOException +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.api.errors.JGitInternalException +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState + +private const val TAG = "SettingsModel" + +/** + * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when + * it is not a port number from 1 to 65535. + */ +fun proxyPort(text: String): Int? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return -1 + return trimmed.toIntOrNull()?.takeIf { it in 1..65535 } +} + +/** Where HEAD points, as the git config screen reports it. */ +sealed interface HeadStatus { + data class Branch(val name: String) : HeadStatus + + data class Detached(val hash: String) : HeadStatus + + data object Missing : HeadStatus +} + +/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */ +fun headStatus(repo: Repository): HeadStatus = + try { + val head = repo.findRef(Constants.HEAD) + when { + head == null -> HeadStatus.Missing + head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name)) + else -> + head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing + } + } catch (e: IOException) { + logcat(TAG, ERROR) { e.asLog() } + HeadStatus.Missing + } + +/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */ +fun needsAbort(repo: Repository): Boolean = + repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING } + +/** + * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking: + * call it off the main thread. + */ +// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it +// if a store's history ever makes this slow. +fun gitLog(repo: Repository?): List<GitCommit> { + if (repo == null) return emptyList() + return try { + Git(repo).log().call().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } + } catch (e: GitAPIException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } catch (e: JGitInternalException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt deleted file mode 100644 index cacca80f..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import de.Maxr1998.modernpreferences.PreferenceScreen - -/** Used to generate a uniform API for all settings UI classes. */ -interface SettingsProvider { - - /** Inserts the settings items for the class into the given [builder]. */ - fun provideSettings(builder: PreferenceScreen.Builder) -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt new file mode 100644 index 00000000..7d45ad74 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -0,0 +1,349 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import androidx.annotation.DrawableRes +import androidx.annotation.Keep +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.util.features.Feature +import app.passwordstore.util.settings.PreferenceKeys +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +/** The settings categories, one screen each. A route argument: kept from minification. */ +@Keep +@Serializable +enum class SettingsSection(@StringRes val title: Int, @DrawableRes val icon: Int) { + General(R.string.pref_category_general_title, R.drawable.app_settings_alt_24px), + Autofill(R.string.pref_category_autofill_title, R.drawable.ic_wysiwyg_24px), + Passwords(R.string.pref_category_passwords_title, R.drawable.ic_password_24px), + Repository(R.string.pref_category_repository_title, R.drawable.ic_call_merge_24px), + Misc(R.string.pref_category_misc_title, R.drawable.ic_miscellaneous_services_24px), + Pgp(R.string.pref_category_pgp_title, R.drawable.ic_lock_open_24px), +} + +/** What a row asks for beyond storing a value: another screen, a dialog or a picker. */ +enum class SettingsAction { + GitServer, + Proxy, + GitConfig, + SshKey, + SshKeyGen, + KeyManager, + ClearSecret, + DeleteRepository, + Export, +} + +/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScaffold( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + GroupedSurface(content = content) + } + } +} + +/** The settings categories. */ +@Composable +fun SettingsScreen( + onSection: (SettingsSection) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(R.string.action_settings), onBack, modifier) { + SettingsSection.entries.forEach { section -> + ListItem( + headlineContent = { Text(stringResource(section.title)) }, + modifier = Modifier.clickable(role = Role.Button) { onSection(section) }, + leadingContent = { Icon(painterResource(section.icon), contentDescription = null) }, + colors = rowColors(), + ) + } + } +} + +/** + * One category's rows. Switches and values go out through [onBoolean] and [onString] with their + * preference key (the destination decides which need a prompt first); everything else through + * [onAction]. [canAuthenticate] and [autofillEnabled] come from the activity. + */ +@Composable +fun SettingsSectionScreen( + section: SettingsSection, + state: SettingsState, + canAuthenticate: Boolean, + autofillEnabled: Boolean, + deleting: Boolean, + onBoolean: (key: String, value: Boolean) -> Unit, + onString: (key: String, value: String) -> Unit, + onAction: (SettingsAction) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(section.title), onBack, modifier) { + when (section) { + SettingsSection.General -> { + PreferenceChoice( + title = stringResource(R.string.pref_app_theme_title), + values = persistentListOf(*stringArrayResource(R.array.app_theme_values)), + labels = persistentListOf(*stringArrayResource(R.array.app_theme_options)), + selected = state.theme, + onSelect = { onString(PreferenceKeys.APP_THEME, it) }, + ) + PreferenceChoice( + title = stringResource(R.string.pref_directory_structure), + values = persistentListOf(*stringArrayResource(R.array.directory_structure_values)), + labels = persistentListOf(*stringArrayResource(R.array.directory_structure_entries)), + selected = state.directoryStructure, + onSelect = { onString(PreferenceKeys.DIRECTORY_STRUCTURE, it) }, + ) + PreferenceChoice( + title = stringResource(R.string.pref_sort_order_title), + values = persistentListOf(*stringArrayResource(R.array.sort_order_values)), + labels = persistentListOf(*stringArrayResource(R.array.sort_order_entries)), + selected = state.sortOrder, + onSelect = { onString(PreferenceKeys.SORT_ORDER, it) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_disable_sync_on_pull_title), + checked = state.disableSync, + onCheckedChange = { onBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, it) }, + summary = stringResource(R.string.pref_disable_sync_on_pull_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_recursive_filter_title), + checked = state.filterRecursively, + onCheckedChange = { onBoolean(PreferenceKeys.FILTER_RECURSIVELY, it) }, + summary = stringResource(R.string.pref_recursive_filter_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_search_on_start_title), + checked = state.searchOnStart, + onCheckedChange = { onBoolean(PreferenceKeys.SEARCH_ON_START, it) }, + summary = stringResource(R.string.pref_search_on_start_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_show_hidden_title), + checked = state.showHidden, + onCheckedChange = { onBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, it) }, + summary = stringResource(R.string.pref_show_hidden_summary), + ) + // Enabled on Android 14 too (maintainer decision 4): upstream blocked it there for issue + // 2802; if that platform bug shows on the maintainer's Android 14 tablet, the block + // returns. + PreferenceSwitch( + title = stringResource(R.string.pref_biometric_auth_title), + checked = state.biometric, + onCheckedChange = { onBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, it) }, + summary = + stringResource( + if (canAuthenticate) R.string.pref_biometric_auth_summary + else R.string.pref_biometric_auth_summary_error + ), + ) + } + SettingsSection.Autofill -> { + PreferenceSwitch( + title = stringResource(R.string.pref_autofill_enable_title), + checked = autofillEnabled, + onCheckedChange = { onBoolean(PreferenceKeys.AUTOFILL_ENABLE, it) }, + ) + PreferenceText( + title = stringResource(R.string.preference_default_username_title), + value = state.defaultUsername, + onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, it) }, + summary = stringResource(R.string.preference_default_username_summary), + enabled = autofillEnabled, + ) + PreferenceText( + title = stringResource(R.string.preference_custom_public_suffixes_title), + value = state.customSuffixes, + onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, it) }, + summary = stringResource(R.string.preference_custom_public_suffixes_summary), + hint = stringResource(R.string.preference_custom_public_suffixes_hint), + singleLine = false, + enabled = autofillEnabled, + ) + } + SettingsSection.Passwords -> { + PreferenceChoice( + title = stringResource(R.string.pref_password_generator_type_title), + values = persistentListOf(*stringArrayResource(R.array.pwgen_provider_values)), + labels = persistentListOf(*stringArrayResource(R.array.pwgen_provider_labels)), + selected = state.pwgenType, + onSelect = { onString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, it) }, + ) + PreferenceText( + title = stringResource(R.string.pref_clipboard_timeout_title), + value = state.clipboardTimeout, + onValueChange = { onString(PreferenceKeys.GENERAL_SHOW_TIME, it) }, + summary = + stringResource( + R.string.pref_clipboard_timeout_summary, + state.clipboardTimeout.ifEmpty { "45" }, + ), + keyboardType = KeyboardType.Number, + ) + PreferenceSwitch( + title = stringResource(R.string.show_password_pref_title), + checked = state.showPassword, + onCheckedChange = { onBoolean(PreferenceKeys.SHOW_PASSWORD, it) }, + summary = stringResource(R.string.show_password_pref_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_copy_title), + checked = state.copyOnDecrypt, + onCheckedChange = { onBoolean(PreferenceKeys.COPY_ON_DECRYPT, it) }, + summary = stringResource(R.string.pref_copy_summary), + ) + } + SettingsSection.Repository -> { + PreferenceSwitch( + title = stringResource(R.string.pref_rebase_on_pull_title), + checked = state.rebaseOnPull, + onCheckedChange = { onBoolean(PreferenceKeys.REBASE_ON_PULL, it) }, + summary = + stringResource( + if (state.rebaseOnPull) R.string.pref_rebase_on_pull_summary_on + else R.string.pref_rebase_on_pull_summary + ), + ) + if (state.isGitRepo) { + PreferenceAction( + title = stringResource(R.string.pref_edit_git_server_settings), + onClick = { onAction(SettingsAction.GitServer) }, + ) + if (state.httpsRemote) + PreferenceAction( + title = stringResource(R.string.pref_edit_proxy_settings), + onClick = { onAction(SettingsAction.Proxy) }, + ) + PreferenceAction( + title = stringResource(R.string.pref_edit_git_config), + onClick = { onAction(SettingsAction.GitConfig) }, + ) + PreferenceAction( + title = stringResource(R.string.pref_import_ssh_key_title), + onClick = { onAction(SettingsAction.SshKey) }, + ) + } + PreferenceAction( + title = stringResource(R.string.pref_ssh_keygen_title), + onClick = { onAction(SettingsAction.SshKeyGen) }, + ) + if (state.isGitRepo && state.canShowSshKey) + PreferenceAction( + title = stringResource(R.string.pref_ssh_see_key_title), + onClick = { onAction(SettingsAction.SshKey) }, + ) + state.savedSecret?.let { title -> + PreferenceAction( + title = stringResource(title), + onClick = { onAction(SettingsAction.ClearSecret) }, + ) + } + PreferenceAction( + title = stringResource(R.string.pref_git_delete_repo_title), + onClick = { onAction(SettingsAction.DeleteRepository) }, + summary = stringResource(R.string.pref_git_delete_repo_summary), + enabled = !deleting, + ) + } + SettingsSection.Misc -> { + PreferenceAction( + title = stringResource(R.string.prefs_export_passwords_title), + onClick = { onAction(SettingsAction.Export) }, + summary = stringResource(R.string.prefs_export_passwords_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_clear_clipboard_title), + checked = state.clearClipboardHistory, + onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, it) }, + summary = stringResource(R.string.pref_clear_clipboard_summary), + ) + if (!BuildConfig.DEBUG) + PreferenceSwitch( + title = stringResource(R.string.pref_debug_logging_title), + checked = state.debugLogging, + onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) }, + summary = stringResource(R.string.pref_debug_logging_summary), + ) + } + SettingsSection.Pgp -> { + PreferenceAction( + title = stringResource(R.string.pref_pgp_key_manager_title), + onClick = { onAction(SettingsAction.KeyManager) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_pgp_ascii_armor_title), + checked = state.asciiArmor, + onCheckedChange = { onBoolean(PreferenceKeys.ASCII_ARMOR, it) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_passphrase_cache_title), + checked = state.passphraseCache, + onCheckedChange = { onBoolean(Feature.EnablePGPPassphraseCache.configKey, it) }, + summary = stringResource(R.string.pref_passphrase_cache_summary), + // Without a screen lock it can still be turned off. + enabled = canAuthenticate || state.passphraseCache, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_passphrase_cache_auto_clear_title), + checked = state.autoClearCache, + onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, it) }, + summary = stringResource(R.string.pref_passphrase_cache_auto_clear_summary), + enabled = state.passphraseCache, + ) + } + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt new file mode 100644 index 00000000..ef604f1f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.app.Application +import android.content.SharedPreferences +import android.content.pm.ShortcutManager +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.data.crypto.PGPPassphraseCache +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.list.deleteWithoutFollowingLinks +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.features.Feature +import app.passwordstore.util.git.sshj.SshKey +import app.passwordstore.util.settings.DirectoryStructure +import app.passwordstore.util.settings.GitSettings +import app.passwordstore.util.settings.PasswordSortOrder +import app.passwordstore.util.settings.PreferenceKeys +import dagger.hilt.android.lifecycle.HiltViewModel +import java.nio.file.Files +import java.nio.file.LinkOption +import javax.inject.Inject +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** What the settings sections show: stored values, and the state rows depend on. */ +data class SettingsState( + val theme: String, + val directoryStructure: String, + val sortOrder: String, + val disableSync: Boolean, + val filterRecursively: Boolean, + val searchOnStart: Boolean, + val showHidden: Boolean, + val biometric: Boolean, + val defaultUsername: String, + val customSuffixes: String, + val pwgenType: String, + val clipboardTimeout: String, + val showPassword: Boolean, + val copyOnDecrypt: Boolean, + val rebaseOnPull: Boolean, + val isGitRepo: Boolean, + val httpsRemote: Boolean, + val canShowSshKey: Boolean, + /** Title of the "clear saved credential" row, or null when none is saved. */ + @StringRes val savedSecret: Int?, + val clearClipboardHistory: Boolean, + val debugLogging: Boolean, + val asciiArmor: Boolean, + val passphraseCache: Boolean, + val autoClearCache: Boolean, +) + +/** + * One settings section's state over the existing preference keys. [state] is re-read on every + * change of the settings and on [refresh] (the destination calls it on resume, so changes made on + * other screens, such as a new SSH key, show at once). + */ +@HiltViewModel +class SettingsViewModel +@Inject +constructor( + application: Application, + @SettingsPreferences private val prefs: SharedPreferences, + @GitPreferences private val gitPrefs: SharedPreferences, + private val gitSettings: GitSettings, + private val passphraseCache: PGPPassphraseCache, + private val dispatcherProvider: DispatcherProvider, +) : AndroidViewModel(application) { + + var state by mutableStateOf(read()) + private set + + /** True while "Delete repository" runs. */ + var deleting by mutableStateOf(false) + private set + + /** Null until "Delete repository" ran; then whether the whole store folder was deleted. */ + var deleted by mutableStateOf<Boolean?>(null) + private set + + // A field: SharedPreferences holds its listeners weakly. + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> refresh() } + + init { + prefs.registerOnSharedPreferenceChangeListener(listener) + } + + override fun onCleared() { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + + fun refresh() { + state = read() + } + + fun putBoolean(key: String, value: Boolean) { + prefs.edit { putBoolean(key, value) } + } + + /** Stores [value] under [key]; an empty value removes the key, so readers use their default. */ + fun putString(key: String, value: String) { + prefs.edit { if (value.isEmpty()) remove(key) else putString(key, value) } + } + + /** Turns the passphrase cache on or off (after the screen lock prompt); it starts empty. */ + fun setPassphraseCache(enabled: Boolean) { + prefs.edit { + putBoolean(Feature.EnablePGPPassphraseCache.configKey, enabled) + if (!enabled) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) + } + clearCachedPassphrases() + } + + /** Stops clearing the cache when the screen goes off; the cache is emptied once now. */ + fun disableAutoClear() { + putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false) + clearCachedPassphrases() + } + + /** Back to the default: the cache is cleared when the screen goes off. */ + fun resetAutoClear() { + prefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) } + } + + /** Forgets the credential the "clear" row names: the HTTPS password, else the SSH passphrase. */ + fun clearSavedSecret() { + val key = + if (gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD)) PreferenceKeys.HTTPS_PASSWORD + else PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + gitPrefs.edit { remove(key) } + refresh() + } + + /** + * Empties the store folder and marks setup as not done, then sets [deleted]. In [viewModelScope] + * and [NonCancellable], so neither a recreation nor the ViewModel's end can stop it half way (a + * half-deleted store still marked as set up). Never follows a symlink: `deleteRecursively()` + * would, and a `store/link -> ..` link would take the PGP and SSH keys with it. + */ + fun deleteRepository() { + if (deleting) return + deleting = true + viewModelScope.launch { + withContext(NonCancellable) { + val root = PasswordRepository.getRepositoryDirectory() + val gone = + withContext(dispatcherProvider.io()) { + PasswordRepository.closeRepository() + val ok = + Files.notExists(root.toPath(), LinkOption.NOFOLLOW_LINKS) || + root.deleteWithoutFollowingLinks() + root.mkdirs() + ok + } + getApplication<Application>() + .getSystemService<ShortcutManager>() + ?.removeAllDynamicShortcuts() + prefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } + deleted = gone + } + } + } + + // NonCancellable: leaving the screen right after the switch must not skip the clearing. + private fun clearCachedPassphrases() { + viewModelScope.launch(NonCancellable) { + passphraseCache.clearAllCachedPassphrases(getApplication()) + } + } + + private fun read(): SettingsState { + val app = getApplication<Application>() + return SettingsState( + theme = prefs.getString(PreferenceKeys.APP_THEME) ?: app.getString(R.string.app_theme_def), + directoryStructure = + prefs.getString(PreferenceKeys.DIRECTORY_STRUCTURE) ?: DirectoryStructure.DEFAULT.value, + sortOrder = PasswordSortOrder.getSortOrder(prefs).name, + disableSync = prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false), + filterRecursively = prefs.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true), + searchOnStart = prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false), + showHidden = prefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false), + biometric = prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false), + defaultUsername = prefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME).orEmpty(), + customSuffixes = + prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES).orEmpty(), + pwgenType = prefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: "diceware", + clipboardTimeout = prefs.getString(PreferenceKeys.GENERAL_SHOW_TIME).orEmpty(), + showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true), + copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false), + rebaseOnPull = gitSettings.rebaseOnPull, + isGitRepo = PasswordRepository.isGitRepo(), + httpsRemote = gitSettings.url?.startsWith("https") == true, + canShowSshKey = SshKey.canShowSshPublicKey, + // contains, never getString: the secret is not decrypted to decide whether to show a row. + savedSecret = + when { + gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD) -> R.string.clear_saved_passphrase_https + gitPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) -> + R.string.clear_saved_passphrase_ssh + else -> null + }, + clearClipboardHistory = prefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false), + debugLogging = prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false), + asciiArmor = prefs.getBoolean(PreferenceKeys.ASCII_ARMOR, false), + passphraseCache = prefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false), + autoClearCache = prefs.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true), + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt new file mode 100644 index 00000000..fd440996 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt @@ -0,0 +1,345 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings.UpdateConnectionSettingsResult +import app.passwordstore.util.settings.Protocol +import com.github.michaelbull.result.fold +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Server URL and authentication mode; the SSH key's state ([sshKey], a string resource) with Manage + * while the mode is SSH key; "Clear saved host key" when one is saved. [progress] (a string + * resource) shows while a clone runs, and disables everything. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitServerScreen( + clone: Boolean, + url: String, + authMode: AuthMode, + @StringRes sshKey: Int, + hasHostKey: Boolean, + @StringRes progress: Int?, + onUrlChange: (String) -> Unit, + onAuthModeChange: (AuthMode) -> Unit, + onSshKey: () -> Unit, + onClearHostKey: () -> Unit, + onSubmit: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val idle = progress == null + val modes = remember(url) { authModes(url.trim()) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_activity_git_clone)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + OutlinedTextField( + value = url, + onValueChange = onUrlChange, + modifier = Modifier.fillMaxWidth(), + enabled = idle, + label = { Text(stringResource(R.string.server_url)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), + ) + Text(stringResource(R.string.connection_mode), style = MaterialTheme.typography.labelLarge) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + modes.forEachIndexed { index, mode -> + SegmentedButton( + selected = mode == authMode, + onClick = { onAuthModeChange(mode) }, + shape = SegmentedButtonDefaults.itemShape(index, modes.size), + enabled = idle, + ) { + Text(stringResource(mode.label())) + } + } + } + if (authMode == AuthMode.SshKey) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.connection_mode_ssh_key), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(stringResource(sshKey)) + } + TextButton(onClick = onSshKey, enabled = idle) { + Text(stringResource(R.string.ssh_key_manage)) + } + } + if (hasHostKey) + OutlinedButton(onClick = onClearHostKey, enabled = idle) { + Text(stringResource(R.string.clear_saved_host_key)) + } + if (progress != null) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(progress)) + } + Button(onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = idle) { + Text(stringResource(if (clone) R.string.clone_button else R.string.crypto_save)) + } + } + } +} + +@StringRes +private fun AuthMode.label(): Int = + when (this) { + AuthMode.SshKey -> R.string.connection_mode_ssh_key + AuthMode.Password -> R.string.connection_mode_basic_authentication + AuthMode.None -> R.string.connection_mode_none + } + +private sealed interface ServerDialog { + data class Url(val problem: UrlProblem) : ServerDialog + + data class Username(val protocol: Protocol) : ServerDialog + + data object DeleteStore : ServerDialog +} + +/** + * The git server screen: wires [GitServerScreen] to the [viewModel], the URL checks, the save and, + * with [clone], the clone into the store folder. [onCloned] gets whether the store still needs a + * PGP key. A failed clone stays here, so URL or SSH key can be fixed and the clone retried. + */ +@Composable +fun GitServerDestination( + activity: MainActivity, + clone: Boolean, + onClose: () -> Unit, + onSshKey: () -> Unit, + onCloned: (keyNeeded: Boolean) -> Unit, + modifier: Modifier = Modifier, + viewModel: GitServerViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + val keyboard = LocalSoftwareKeyboardController.current + var dialog by remember { mutableStateOf<ServerDialog?>(null) } + + // Back from the SSH key screen (or from an instance it opened) with another key. + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose {} + } + // A finished clone moves on from here, also when the screen was recreated in between. + val cloned = viewModel.cloned + LaunchedEffect(cloned) { if (cloned != null) onCloned(cloned) } + // A running clone holds the screen: leaving would cancel it half way. + BackHandler(enabled = viewModel.progress != null) {} + + fun say(message: String) { + activity.snackbar(message = message, length = Snackbar.LENGTH_LONG) + } + + // ponytail: a theme or locale change cancels the clone with the activity's scope, like every + // git operation of the app; the finally block frees the screen, and a new clone first offers to + // delete what was left. Once the clone succeeded, the key check runs in the ViewModel's scope + // (finishClone), so a recreation from then on loses nothing. + fun startClone() { + viewModel.progress = R.string.git_clone_progress + activity.lifecycleScope.launch { + try { + // A folder that could not be emptied is reported as such, not as a vague clone error. + viewModel.clearStore(root)?.let { failure -> + say(activity.getString(failure)) + return@launch + } + activity + .launchGitOperation(GitOp.CLONE) + .fold( + success = { viewModel.finishClone(root) }, + failure = { activity.promptOnErrorHandler(it) }, + ) + } finally { + viewModel.cloneEnded() + } + } + } + + fun submit() { + if (viewModel.progress != null) return + keyboard?.hide() + val problem = urlProblem(viewModel.url.trim()) + if (problem != null) { + dialog = ServerDialog.Url(problem) + return + } + when (val result = viewModel.save()) { + UpdateConnectionSettingsResult.FailedToParseUrl -> + say(activity.getString(R.string.git_server_config_save_error)) + is UpdateConnectionSettingsResult.MissingUsername -> + dialog = ServerDialog.Username(result.newProtocol) + is UpdateConnectionSettingsResult.AuthModeMismatch -> + say( + activity.getString( + R.string.git_server_config_save_auth_mode_mismatch, + result.newProtocol, + result.validModes.joinToString(", "), + ) + ) + UpdateConnectionSettingsResult.Valid -> + if (!clone) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT) + .show() + onClose() + } else { + if (PasswordRepository.repository == null) PasswordRepository.initialize() + if (cloneTarget(root) == CloneTarget.Occupied) dialog = ServerDialog.DeleteStore + else startClone() + } + } + } + + Box(modifier) { + GitServerScreen( + clone = clone, + url = viewModel.url, + authMode = viewModel.authMode, + sshKey = viewModel.sshKey, + hasHostKey = viewModel.hasHostKey, + progress = viewModel.progress, + onUrlChange = viewModel::changeUrl, + onAuthModeChange = { viewModel.authMode = it }, + onSshKey = onSshKey, + onClearHostKey = { + viewModel.clearHostKey() + say(activity.getString(R.string.clear_saved_host_key_success)) + }, + onSubmit = ::submit, + onBack = { if (viewModel.progress == null) onClose() }, + ) + when (val current = dialog) { + null -> Unit + is ServerDialog.Url -> { + val (title, text) = + when (current.problem) { + UrlProblem.HttpsPort -> + R.string.https_scheme_with_port_title to R.string.https_scheme_with_port_message + UrlProblem.SshScheme -> + R.string.ssh_scheme_needed_title to R.string.ssh_scheme_needed_message + UrlProblem.GitScheme -> + R.string.git_scheme_disallowed_title to R.string.git_scheme_disallowed_message + } + val fixed = fixUrl(viewModel.url.trim(), current.problem) + SetupDialog( + title = stringResource(title), + text = stringResource(text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { + dialog = null + fixed?.let(viewModel::changeUrl) + }, + onDismiss = { dialog = null }, + dismiss = if (fixed != null) stringResource(R.string.dialog_cancel) else null, + ) + } + is ServerDialog.Username -> + SetupDialog( + title = stringResource(R.string.ssh_scheme_needed_title), + text = + stringResource( + if (current.protocol == Protocol.Https) + R.string.git_server_config_save_missing_username_https + else R.string.git_server_config_save_missing_username_ssh + ), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = null }, + onDismiss = { dialog = null }, + ) + ServerDialog.DeleteStore -> + SetupDialog( + title = stringResource(R.string.dialog_delete_title), + text = stringResource(R.string.dialog_delete_msg, root.toString()), + confirm = stringResource(R.string.dialog_delete), + onConfirm = { + dialog = null + startClone() + }, + onDismiss = { dialog = null }, + dismiss = stringResource(R.string.dialog_do_not_delete), + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt new file mode 100644 index 00000000..5995bf5b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.ui.list.deleteWithoutFollowingLinks +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.settings.GitSettings +import com.github.michaelbull.result.get +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The git server screen's state: URL and mode being edited, the clone's [progress] and outcome + * ([cloned]), the host key and SSH key state. The clone itself runs in the destination (it needs + * the activity); this ViewModel keeps what must survive a recreation. + */ +@HiltViewModel +class GitServerViewModel +@Inject +constructor( + private val gitSettings: GitSettings, + private val keyManager: PGPKeyManager, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var url by mutableStateOf(gitSettings.url.orEmpty()) + private set + + var authMode by mutableStateOf(fitAuthMode(gitSettings.url.orEmpty(), gitSettings.authMode)) + + /** What a running clone does, as a string resource; null when idle. */ + var progress by mutableStateOf<Int?>(null) + + /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */ + var cloned by mutableStateOf<Boolean?>(null) + private set + + var hasHostKey by mutableStateOf(false) + private set + + var sshKey by mutableIntStateOf(R.string.ssh_key_state_none) + private set + + init { + refresh() + } + + /** Re-reads what other screens change: the saved host key and the SSH key. */ + fun refresh() { + hasHostKey = gitSettings.hasSavedHostKey() + sshKey = sshKeySummary() + } + + fun changeUrl(value: String) { + url = value + authMode = fitAuthMode(value.trim(), authMode) + } + + fun clearHostKey() { + gitSettings.clearSavedHostKey() + hasHostKey = false + } + + /** Validates and stores URL and mode (unchanged rules in [GitSettings]). */ + fun save(): GitSettings.UpdateConnectionSettingsResult = + gitSettings.updateConnectionSettingsIfValid(newAuthMode = authMode, newUrl = url.trim()) + + /** + * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a + * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP + * keys with it. Returns null, or why it failed as a string resource: then the clone must not + * start. + */ + suspend fun clearStore(root: File): Int? { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null + progress = R.string.delete_directory_progress_text + if (!withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() }) + return R.string.git_clone_delete_failed + progress = R.string.git_clone_progress + return null + } + + /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */ + private var checking = false + + /** + * After a successful clone into [root], checks in [viewModelScope] whether its key is missing and + * records the answer in [cloned]: no recipient of the root `.gpg-id` is an imported secret key + * (without recipients: no secret key is imported at all). A public key alone cannot decrypt. In + * [viewModelScope], so a recreation during the check cannot lose the answer. + */ + fun finishClone(root: File) { + checking = true + viewModelScope.launch { + try { + cloned = + withContext(dispatcherProvider.io()) { + val ids = readGpgId(root)?.let(::storeRecipients).orEmpty() + if (ids.isEmpty()) keyManager.getAllKeys().get().orEmpty().none(KeyUtils::isSecretKey) + else + ids.none { id -> keyManager.getKeyById(id).get()?.let(KeyUtils::isSecretKey) == true } + } + } finally { + checking = false + progress = null + } + } + } + + /** The clone's coroutine ended (done, failed or cancelled): free the screen, re-read state. */ + fun cloneEnded() { + if (!checking) progress = null + refresh() + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt new file mode 100644 index 00000000..11fd979c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -0,0 +1,337 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.annotation.Keep +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.snackbar +import com.github.michaelbull.result.onFailure +import com.google.android.material.snackbar.Snackbar +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +/** What the PGP keys screen is for. A route argument: kept from minification. */ +@Keep +@Serializable +enum class KeysMode { + /** Import and delete keys (Settings). */ + Manage, + /** Pick the key a new local store is encrypted for. */ + Choose, + /** A key is needed (after a clone, or by an entry): import it, or go on without. */ + Need, +} + +/** + * The imported [keys] with the import FAB. [mode] decides what a row does (select in + * [KeysMode.Choose], delete in [KeysMode.Manage]) and the guidance shown; [recipients] are the + * store's `.gpg-id` entries, listed in [KeysMode.Need]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PgpKeysScreen( + mode: KeysMode, + keys: ImmutableList<KeyRow>, + recipients: ImmutableList<String>, + busy: Boolean, + onImport: () -> Unit, + onSelect: (KeyRow) -> Unit, + onDelete: (KeyRow) -> Unit, + onLater: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var deleting by remember { mutableStateOf<KeyRow?>(null) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + stringResource( + when (mode) { + KeysMode.Manage -> R.string.activity_label_pgp_key_manager + KeysMode.Choose -> R.string.gpg_key_select + KeysMode.Need -> R.string.pref_import_pgp_key_title + } + ) + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + actions = { + if (mode == KeysMode.Need) + TextButton(onClick = onLater) { Text(stringResource(R.string.ssh_keygen_later)) } + }, + ) + }, + floatingActionButton = { + // M3 clears the semantics of the FAB's content, label and icon alike: describe the FAB. + val label = stringResource(R.string.pref_import_pgp_key_title) + ExtendedFloatingActionButton( + text = { Text(label) }, + icon = { Icon(Icons.Filled.Add, null) }, + onClick = { if (!busy) onImport() }, + modifier = Modifier.semantics { contentDescription = label }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + val message = + when (mode) { + KeysMode.Manage -> null + KeysMode.Choose -> R.string.select_gpg_key_message + KeysMode.Need -> R.string.pgp_key_needed_message + } + if (message != null) + Text( + stringResource(message), + Modifier.padding(SpacingLarge), + style = MaterialTheme.typography.bodyLarge, + ) + if (mode == KeysMode.Need && recipients.isNotEmpty()) + GroupedSurface { + Column(Modifier.padding(SpacingLarge)) { + Text( + stringResource(R.string.pgp_key_needed_recipients), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + recipients.forEach { Text(it, fontFamily = FontFamily.Monospace) } + } + } + if (keys.isEmpty()) + Text( + stringResource(R.string.pgp_key_manager_no_keys_guidance), + Modifier.padding(SpacingLarge), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else + GroupedSurface { + keys.forEach { row -> + KeyItem(row = row, mode = mode, onSelect = onSelect, onDelete = { deleting = row }) + } + } + // Room for the FAB below the last row. + Spacer(Modifier.height(88.dp)) + } + } + deleting?.let { row -> + SetupDialog( + title = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title), + text = row.userId ?: row.keyId.toString(), + confirm = stringResource(R.string.dialog_yes), + onConfirm = { + deleting = null + onDelete(row) + }, + onDismiss = { deleting = null }, + dismiss = stringResource(R.string.dialog_no), + ) + } +} + +@Composable +private fun KeyItem( + row: KeyRow, + mode: KeysMode, + onSelect: (KeyRow) -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .then(if (mode == KeysMode.Choose) Modifier.clickable { onSelect(row) } else Modifier) + .padding(horizontal = SpacingLarge, vertical = SpacingMedium), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + row.userId ?: row.keyId.toString(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (row.userId != null) + Text( + row.keyId.toString(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (mode == KeysMode.Manage) + IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, stringResource(R.string.delete)) } + } +} + +/** + * The PGP keys screen: wires [PgpKeysScreen] to the [viewModel], the document picker and the import + * dialogs. [onImported] runs after every successful import; [onDone] ends the flow: after an + * import, "Later" or back in [KeysMode.Need], after the new store is created in [KeysMode.Choose]. + * [onClose] leaves the other modes. + */ +@Composable +fun PgpKeysDestination( + activity: MainActivity, + mode: KeysMode, + onClose: () -> Unit, + onDone: () -> Unit, + onImported: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PgpKeysViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + // Read off the main thread, symlink- and size-checked (readGpgId). + LaunchedEffect(mode) { if (mode == KeysMode.Need) viewModel.loadRecipients(root) } + // A created store moves on from here, also when the screen was recreated in between. + val done = viewModel.done + LaunchedEffect(done) { if (done) onDone() } + + fun leave() { + if (viewModel.busy) return + if (mode == KeysMode.Need) onDone() else onClose() + } + BackHandler(onBack = ::leave) + + val picker = + rememberLauncherForActivityResult(OpenDocument()) { uri -> + if (uri != null) viewModel.import(uri, activity) + } + + // ponytail: a theme or locale change in the middle cancels the commit. Choosing the same key + // again finds the store already made for it (createdFor), commits the `.gpg-id` and moves on. + fun choose(row: KeyRow) { + if (viewModel.busy) return + viewModel.busy = true + activity.lifecycleScope.launch { + try { + val error = viewModel.createStore(root, row, activity.getString(R.string.store_not_empty)) + if (error != null) { + activity.snackbar( + message = activity.getString(R.string.store_create_failed, error), + length = Snackbar.LENGTH_LONG, + ) + } else { + // A failed commit is reported like any git error; the store exists either way, and + // the next commit picks up the `.gpg-id`. + activity + .commitChange( + activity.getString(R.string.git_commit_gpg_id, activity.getString(R.string.app_name)) + ) + .onFailure { activity.promptOnErrorHandler(it) } + viewModel.markDone() + } + } finally { + viewModel.busy = false + } + } + } + + Box(modifier) { + PgpKeysScreen( + mode = mode, + keys = viewModel.keys, + recipients = viewModel.recipients, + busy = viewModel.busy, + onImport = { picker.launch(arrayOf("*/*")) }, + onSelect = ::choose, + onDelete = viewModel::deleteKey, + onLater = ::leave, + onBack = ::leave, + ) + when (val result = viewModel.importResult) { + null -> Unit + is KeyImport.Imported -> { + fun accept() { + viewModel.dismissImport() + onImported() + if (mode == KeysMode.Need) onDone() + } + SetupDialog( + title = stringResource(R.string.pgp_key_import_succeeded), + text = stringResource(R.string.pgp_key_import_succeeded_message, result.keyId), + confirm = stringResource(R.string.dialog_ok), + onConfirm = ::accept, + onDismiss = ::accept, + ) + } + KeyImport.Exists -> + SetupDialog( + title = stringResource(R.string.pgp_key_import_failed), + text = stringResource(R.string.pgp_key_import_failed_replace_message), + confirm = stringResource(R.string.dialog_yes), + onConfirm = viewModel::replace, + onDismiss = viewModel::dismissImport, + dismiss = stringResource(R.string.dialog_no), + ) + is KeyImport.Failed -> + SetupDialog( + title = stringResource(R.string.pgp_key_import_failed), + text = result.message.orEmpty(), + confirm = stringResource(R.string.dialog_ok), + onConfirm = viewModel::dismissImport, + onDismiss = viewModel::dismissImport, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt new file mode 100644 index 00000000..8f1b52ec --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.crypto.KeyUtils +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.crypto.PGPKey +import app.passwordstore.crypto.PGPKeyManager +import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDowngradeException +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.list.deleteWithoutFollowingLinks +import app.passwordstore.util.coroutines.DispatcherProvider +import com.github.michaelbull.result.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.api.errors.JGitInternalException + +/** One imported key: its first user ID, when it has one, and its long key ID. */ +@Immutable data class KeyRow(val userId: String?, val keyId: PGPIdentifier.KeyId) + +/** The outcome of a PGP key import, shown until dismissed. */ +sealed interface KeyImport { + data class Imported(val keyId: String) : KeyImport + + /** A key with the same ID is imported already: the screen asks whether to replace it. */ + data object Exists : KeyImport + + data class Failed(val message: String?) : KeyImport +} + +/** + * The PGP keys screen's state and work: the imported [keys], import with the replace question, + * deletion by key ID, and the creation of a new local store for a chosen key. Holds no Context; + * calls take one and use the application context. + */ +@HiltViewModel +class PgpKeysViewModel +@Inject +constructor( + private val keyManager: PGPKeyManager, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + + var keys: ImmutableList<KeyRow> by mutableStateOf(persistentListOf()) + private set + + /** Set while an import or a store creation runs: further taps wait. */ + var busy by mutableStateOf(false) + + var importResult by mutableStateOf<KeyImport?>(null) + private set + + /** Set once a new store is created and committed: the screen moves on. */ + var done by mutableStateOf(false) + private set + + /** The root `.gpg-id` recipients, for the `Need` screen; see [loadRecipients]. */ + var recipients: ImmutableList<String> by mutableStateOf(persistentListOf()) + private set + + /** + * The picked file while the replace question is open. Memory only, zeroed as soon as it is not + * needed ([dropPending]): it may be a private key. + */ + private var pending: ByteArray? = null + + init { + updateKeySet() + } + + /** Reads the recipients of the store at [root] off the main thread. */ + fun loadRecipients(root: File) { + viewModelScope.launch { + recipients = + withContext(dispatcherProvider.io()) { + readGpgId(root)?.let(::storeRecipients).orEmpty().map(PGPIdentifier::toString) + } + .toPersistentList() + } + } + + private fun dropPending() { + pending?.fill(0) + pending = null + } + + fun updateKeySet() { + viewModelScope.launch { + keyManager.getAllKeys().onSuccess { all -> + keys = + withContext(dispatcherProvider.default()) { all.mapNotNull(::row) }.toPersistentList() + } + } + } + + private fun row(key: PGPKey): KeyRow? { + val id = KeyUtils.tryGetId(key) ?: return null + return KeyRow(KeyUtils.tryGetEmail(key)?.email, id) + } + + fun deleteKey(row: KeyRow) { + viewModelScope.launch { + keyManager.removeKey(row.keyId) + updateKeySet() + } + } + + /** Reads the key file at [uri] (at most [MAX_KEY_BYTES]) and imports it. */ + fun import(uri: Uri, context: Context) { + if (busy) return + busy = true + val resolver = context.applicationContext.contentResolver + val tooLarge = context.getString(R.string.pgp_key_import_too_large) + val downgrade = context.getString(R.string.pgp_key_import_downgrade) + viewModelScope.launch { + try { + val bytes = + withContext(dispatcherProvider.io()) { + resolver.openInputStream(uri)?.use { + readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge) + } + } + if (bytes == null) importResult = KeyImport.Failed(null) + else add(bytes, replace = false, downgrade) + } catch (e: IOException) { + importResult = KeyImport.Failed(e.message) + } catch (e: SecurityException) { + importResult = KeyImport.Failed(e.message) + } finally { + busy = false + } + } + } + + /** Imports the key the replace question was about, over the one with its ID. */ + fun replace() { + val bytes = pending ?: return + pending = null + importResult = null + busy = true + viewModelScope.launch { + try { + add(bytes, replace = true) + } finally { + busy = false + } + } + } + + fun dismissImport() { + dropPending() + importResult = null + } + + /** + * Imports [bytes]; they are zeroed afterwards, also on failure or cancellation, unless the + * replace question keeps them. [downgrade] is the message for a public key over a secret one. + */ + private suspend fun add(bytes: ByteArray, replace: Boolean, downgrade: String? = null) { + var keep = false + try { + val result = keyManager.addKey(PGPKey(bytes), replace) + if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { + // A question still open from an earlier pick is superseded: zero its bytes first. + dropPending() + pending = bytes + keep = true + importResult = KeyImport.Exists + return + } + // The ID is read before the bytes are zeroed: the returned key shares them. + importResult = + if (result.isOk) { + val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) } + KeyImport.Imported(id?.toString().orEmpty()) + } else if (result.error is KeyDowngradeException) { + KeyImport.Failed(downgrade ?: result.error.message) + } else KeyImport.Failed(result.error.message) + if (result.isOk) updateKeySet() + } finally { + if (!keep) bytes.fill(0) + } + } + + /** + * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by + * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed; [occupied] is the + * reason given when [root] already holds a store, unless that store was made for [row] already. + */ + suspend fun createStore(root: File, row: KeyRow, occupied: String): String? = + withContext(dispatcherProvider.io()) { + if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) { + // Made by an earlier attempt for this key whose commit a recreation cancelled: done. + if (createdFor(root, row.keyId)) return@withContext null + // A restored, already-cloned store must keep its .gpg-id. + return@withContext occupied + } + try { + check(root.isDirectory || root.mkdirs()) { "Could not create $root" } + PasswordRepository.createRepository(root) + File(root, ".gpg-id").writeText(gpgIdText(listOf(row.keyId))) + null + } catch (e: IOException) { + failed(root, e) + } catch (e: GitAPIException) { + failed(root, e) + } catch (e: JGitInternalException) { + failed(root, e) + } catch (e: IllegalStateException) { + failed(root, e) + } + } + + private fun failed(root: File, e: Exception): String { + logcat(ERROR) { e.asLog() } + // Removes what this attempt made, including a fresh .git, without following links. + root.deleteWithoutFollowingLinks() + return e.message.orEmpty() + } + + fun markDone() { + done = true + } + + override fun onCleared() { + dropPending() + } +} + +/** The largest key file imported: far above any real key, small enough to hold in memory. */ +private const val MAX_KEY_BYTES = 1024 * 1024 diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt new file mode 100644 index 00000000..40dfae74 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.app.Activity +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import app.passwordstore.ui.main.ListRoute +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.settings.SettingsRoute +import kotlinx.serialization.Serializable + +/** The first screen without a store. */ +@Serializable data object WelcomeRoute + +/** Git server settings; with [clone], saving them clones the store. */ +@Serializable data class GitServerRoute(val clone: Boolean) + +/** The SSH key: state, public key, import. */ +@Serializable data object SshKeyRoute + +/** SSH key generation. */ +@Serializable data object SshKeyGenRoute + +/** The PGP keys, for [mode]. */ +@Serializable data class PgpKeysRoute(val mode: KeysMode = KeysMode.Manage) + +/** The setup screens [MainActivity.setupIntent] opens on their own. */ +enum class SetupStart(val route: Any) { + Ssh(SshKeyRoute), + SshGenerate(SshKeyGenRoute), + PgpImport(PgpKeysRoute(KeysMode.Need)), +} + +/** + * The setup destinations. In an instance started for one of them ([MainActivity.setupIntent]), + * leaving its first screen finishes the activity. + */ +fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) { + fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id + + fun close(entry: NavBackStackEntry) { + if (!onTop(entry)) return + if (nav.previousBackStackEntry == null) activity.finish() else nav.popBackStack() + } + + // Setup is complete: the new store's list replaces the whole stack. An instance started for + // this screen alone just finishes. + fun complete(entry: NavBackStackEntry) { + if (!onTop(entry)) return + if (nav.previousBackStackEntry == null) activity.finish() + else nav.navigate(ListRoute()) { popUpTo(nav.graph.id) { inclusive = true } } + } + + composable<WelcomeRoute> { entry -> + WelcomeScreen( + onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) }, + onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) }, + onSettings = { if (onTop(entry)) nav.navigate(SettingsRoute) }, + ) + } + composable<GitServerRoute> { entry -> + GitServerDestination( + activity = activity, + clone = entry.toRoute<GitServerRoute>().clone, + onClose = { close(entry) }, + onSshKey = { if (onTop(entry)) nav.navigate(SshKeyRoute) }, + onCloned = { keyNeeded -> + if (!keyNeeded) complete(entry) + else if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Need)) + }, + ) + } + composable<SshKeyRoute> { entry -> + SshKeyDestination( + activity = activity, + onClose = { close(entry) }, + onGenerate = { if (onTop(entry)) nav.navigate(SshKeyGenRoute) }, + ) + } + composable<SshKeyGenRoute> { entry -> + SshKeyGenDestination(activity = activity, onClose = { close(entry) }) + } + composable<PgpKeysRoute> { entry -> + PgpKeysDestination( + activity = activity, + mode = entry.toRoute<PgpKeysRoute>().mode, + onClose = { close(entry) }, + onDone = { complete(entry) }, + onImported = { activity.setResult(Activity.RESULT_OK) }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt new file mode 100644 index 00000000..4e32505f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.util.settings.AuthMode +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.nio.channels.Channels +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardOpenOption +import java.util.Locale + +/** Why a repository URL is questioned before it is saved, as the old server screen checked it. */ +enum class UrlProblem { + /** An HTTPS URL with a custom port, which the old screen did not support. */ + HttpsPort, + /** An scp-style SSH URL with a port: without `ssh://` the port is read as part of the path. */ + SshScheme, + /** The unencrypted `git://` protocol. */ + GitScheme, +} + +private val PORT = ":[0-9]{1,5}/".toRegex() + +private fun isHttp(url: String) = url.startsWith("http://") || url.startsWith("https://") + +/** What is wrong with [url], or null. `git://` is checked first: a port does not make it SSH. */ +fun urlProblem(url: String): UrlProblem? = + when { + url.startsWith("git://") -> UrlProblem.GitScheme + !url.contains(PORT) -> null + url.startsWith("https://") -> UrlProblem.HttpsPort + url.startsWith("http://") || url.startsWith("ssh://") -> null + else -> UrlProblem.SshScheme + } + +/** The old screen's fix for [problem] in [url], or null when there is none (`git://`). */ +fun fixUrl(url: String, problem: UrlProblem): String? = + when (problem) { + UrlProblem.HttpsPort -> url.replace(PORT, "/") + UrlProblem.SshScheme -> "ssh://$url" + UrlProblem.GitScheme -> null + } + +/** The authentication modes offered for [url], the default first. */ +fun authModes(url: String): List<AuthMode> = + if (isHttp(url)) listOf(AuthMode.None, AuthMode.Password) + else listOf(AuthMode.SshKey, AuthMode.Password) + +/** [current] when [url] still allows it, the default for [url] otherwise. */ +fun fitAuthMode(url: String, current: AuthMode): AuthMode = + authModes(url).let { modes -> if (current in modes) current else modes.first() } + +/** What a clone into the store folder finds there. */ +enum class CloneTarget { + /** Missing or empty. */ + Empty, + /** Only a `.git` folder, from a store created and abandoned: deleted without asking. */ + OnlyGit, + /** Anything else: deleted after a confirmation. */ + Occupied, +} + +fun cloneTarget(dir: File): CloneTarget { + val files = dir.listFiles().orEmpty() + return when { + files.isEmpty() -> CloneTarget.Empty + files.size == 1 && files[0].name == ".git" -> CloneTarget.OnlyGit + else -> CloneTarget.Occupied + } +} + +/** + * The recipients named by a `.gpg-id` file's [text], read as pass reads it: a `#` starts a comment, + * and a trailing `!` (GnuPG's "this exact subkey") is dropped. Blank and unparsable lines are + * skipped. + */ +fun storeRecipients(text: String): List<PGPIdentifier> = + text + .lines() + .map { it.substringBefore('#').trim().removeSuffix("!") } + .filter(String::isNotEmpty) + .mapNotNull(PGPIdentifier::fromString) + +/** + * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format + * of a desktop store made with `pass init <key ID>`. + */ +fun gpgIdText(keys: List<PGPIdentifier.KeyId>): String = + keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" } + +/** + * Whether [dir] holds a store already made for [key]: its `.gpg-id` is exactly what creating one + * for [key] writes. Blocking: call it off the main thread. + */ +fun createdFor(dir: File, key: PGPIdentifier.KeyId): Boolean = + readGpgId(dir) == gpgIdText(listOf(key)) + +private const val MAX_GPG_ID_BYTES = 64 * 1024 + +/** + * The text of [dir]'s `.gpg-id`, or null when it is missing, not a regular file, a symlink, larger + * than 64 KiB or unreadable. Blocking: call it off the main thread. + */ +fun readGpgId(dir: File): String? { + val path = File(dir, ".gpg-id").toPath() + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return null + // Opened without following a link too: one swapped in after the check is refused. + val options = setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS) + return try { + Channels.newInputStream(Files.newByteChannel(path, options)) + .use { readAtMost(it, MAX_GPG_ID_BYTES) } + ?.decodeToString() + } catch (_: IOException) { + null + } +} + +/** + * At most [limit] bytes of [input], or null when it holds more. Reads at most [limit] + 1 bytes, + * whatever the stream's size, and zeroes its buffer: the bytes may be a private key. + */ +fun readAtMost(input: InputStream, limit: Int): ByteArray? { + val buffer = ByteArray(limit + 1) + try { + var size = 0 + while (size < buffer.size) { + val read = input.read(buffer, size, buffer.size - size) + if (read < 0) break + size += read + } + return if (size > limit) null else buffer.copyOf(size) + } finally { + buffer.fill(0) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt new file mode 100644 index 00000000..f93fc9be --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.text.font.FontFamily +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import app.passwordstore.R +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.extensions.keyguardManager +import app.passwordstore.util.git.sshj.SshKey + +/** + * The SSH key: its state ([summary], a string resource), the public key with copy and share when + * the device generated it, import and generation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SshKeyScreen( + @StringRes summary: Int, + publicKey: String?, + busy: Boolean, + onImport: () -> Unit, + onGenerate: () -> Unit, + onCopy: () -> Unit, + onShare: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.ssh_keygen_existing_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + GroupedSurface { + Text( + stringResource(summary), + Modifier.padding(SpacingLarge), + style = MaterialTheme.typography.bodyLarge, + ) + } + if (publicKey != null) + GroupedSurface { + Column( + Modifier.padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Text( + stringResource(R.string.your_public_key), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectionContainer { + Text( + publicKey, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + Text( + stringResource(R.string.ssh_key_public_hint), + style = MaterialTheme.typography.bodySmall, + ) + Row { + TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_label)) } + TextButton(onClick = onShare) { Text(stringResource(R.string.ssh_keygen_share)) } + } + } + } + Column( + Modifier.padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Button(onClick = onImport, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pref_import_ssh_key_title)) + } + OutlinedButton(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pref_ssh_keygen_title)) + } + } + } + } +} + +/** A chooser sharing [publicKey] as text. */ +private fun shareKey(publicKey: String): Intent = + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, publicKey), + null, + ) + +/** + * The SSH key screen: wires [SshKeyScreen] to the [viewModel], the document picker and the replace + * confirmation. [onGenerate] opens the generation screen. + */ +@Composable +fun SshKeyDestination( + activity: MainActivity, + onClose: () -> Unit, + onGenerate: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SshKeyViewModel = hiltViewModel(), +) { + var resumes by remember { mutableIntStateOf(0) } + // Back from the generation screen (or another instance) with a new key: read it again. + LifecycleResumeEffect(Unit) { + resumes++ + onPauseOrDispose {} + } + val version = viewModel.version + val summary = remember(resumes, version) { sshKeySummary() } + val publicKey = + remember(resumes, version) { if (SshKey.canShowSshPublicKey) SshKey.sshPublicKey else null } + var replace by remember { mutableStateOf(false) } + val picker = + rememberLauncherForActivityResult(OpenDocument()) { uri -> + if (uri != null) viewModel.import(uri) + } + + LaunchedEffect(viewModel.imported) { + if (viewModel.imported) { + Toast.makeText(activity, R.string.ssh_key_success_dialog_title, Toast.LENGTH_LONG).show() + viewModel.announced() + } + } + BackHandler(enabled = viewModel.busy) {} + + Box(modifier) { + SshKeyScreen( + summary = summary, + publicKey = publicKey, + busy = viewModel.busy, + onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) }, + onGenerate = onGenerate, + onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } }, + onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } }, + onBack = { if (!viewModel.busy) onClose() }, + ) + if (replace) + SetupDialog( + title = stringResource(R.string.ssh_keygen_existing_title), + text = stringResource(R.string.ssh_keygen_existing_message), + confirm = stringResource(R.string.ssh_keygen_existing_replace), + onConfirm = { + replace = false + picker.launch(arrayOf("*/*")) + }, + onDismiss = { replace = false }, + dismiss = stringResource(R.string.ssh_keygen_existing_keep), + ) + viewModel.failure?.let { message -> + SetupDialog( + title = stringResource(R.string.ssh_key_error_dialog_title), + text = message, + confirm = stringResource(R.string.dialog_ok), + onConfirm = viewModel::dismissFailure, + onDismiss = viewModel::dismissFailure, + ) + } + } +} + +/** Key type with its explanation, the screen lock switch and Generate. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SshKeyGenScreen( + type: SshKeyType, + protect: Boolean, + canProtect: Boolean, + busy: Boolean, + onTypeChange: (SshKeyType) -> Unit, + onProtectChange: (Boolean) -> Unit, + onGenerate: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val types = SshKeyType.entries + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.pref_ssh_keygen_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + types.forEachIndexed { index, entry -> + SegmentedButton( + selected = entry == type, + onClick = { onTypeChange(entry) }, + shape = SegmentedButtonDefaults.itemShape(index, types.size), + enabled = !busy, + ) { + Text(stringResource(entry.label)) + } + } + } + // ponytail: stringResource drops the explanations' bold title; plain text reads fine. + Text(stringResource(type.explanation), style = MaterialTheme.typography.bodyMedium) + Row( + Modifier.fillMaxWidth() + .toggleable( + value = protect, + enabled = canProtect && !busy, + role = Role.Switch, + onValueChange = onProtectChange, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.ssh_keygen_require_authentication), Modifier.weight(1f)) + Switch(checked = protect, onCheckedChange = null, enabled = canProtect && !busy) + } + Button(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource( + if (busy) R.string.ssh_key_gen_generating_progress else R.string.ssh_keygen_generate + ) + ) + } + } + } +} + +/** + * The key generation screen: replace confirmation, screen lock prompt when protected, then the + * generation in the [viewModel] and the public key (or error) dialog; both dialogs close it. + */ +@Composable +fun SshKeyGenDestination( + activity: MainActivity, + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SshKeyViewModel = hiltViewModel(), +) { + val canProtect = remember { activity.keyguardManager.isDeviceSecure } + var replace by remember { mutableStateOf(false) } + // While the screen lock prompt is up. Plain remember: a recreation drops the prompt's callback, + // and the screen must not stay disabled for it. + var asking by remember { mutableStateOf(false) } + + fun generate() { + if (!(canProtect && viewModel.protect)) return viewModel.generate(requireAuthentication = false) + asking = true + BiometricAuthenticator.authenticate(activity, R.string.biometric_prompt_title_ssh_keygen) { + result -> + // Failed attempts are handled by the prompt itself. + if (result is BiometricAuthenticator.Result.Retry) return@authenticate + asking = false + if (result is BiometricAuthenticator.Result.Success) + viewModel.generate(requireAuthentication = true) + else viewModel.fail(activity.getString(R.string.biometric_auth_generic_failure)) + } + } + + BackHandler(enabled = viewModel.busy) {} + + Box(modifier) { + SshKeyGenScreen( + type = viewModel.type, + protect = canProtect && viewModel.protect, + canProtect = canProtect, + busy = viewModel.busy || asking, + onTypeChange = { viewModel.type = it }, + onProtectChange = { viewModel.protect = it }, + onGenerate = { if (SshKey.exists) replace = true else generate() }, + onBack = { if (!viewModel.busy) onClose() }, + ) + if (replace) + SetupDialog( + title = stringResource(R.string.ssh_keygen_existing_title), + text = stringResource(R.string.ssh_keygen_existing_message), + confirm = stringResource(R.string.ssh_keygen_existing_replace), + onConfirm = { + replace = false + generate() + }, + onDismiss = { replace = false }, + dismiss = stringResource(R.string.ssh_keygen_existing_keep), + onDismissButton = onClose, + ) + viewModel.generated?.let { key -> + SetupDialog( + title = stringResource(R.string.your_public_key), + text = stringResource(R.string.ssh_keygen_message, key), + confirm = stringResource(R.string.ssh_keygen_share), + onConfirm = { + activity.startActivity(shareKey(key)) + onClose() + }, + onDismiss = onClose, + dismiss = stringResource(R.string.ssh_keygen_later), + ) + } + viewModel.failure?.let { message -> + SetupDialog( + title = stringResource(R.string.error_generate_ssh_key), + text = stringResource(R.string.ssh_key_error_dialog_text) + message, + confirm = stringResource(R.string.dialog_ok), + onConfirm = onClose, + onDismiss = onClose, + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt new file mode 100644 index 00000000..bc3e9474 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.passwordstore.R +import app.passwordstore.util.coroutines.DispatcherProvider +import app.passwordstore.util.git.sshj.SshKey +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The key types the generation screen offers, in the old screen's order. */ +enum class SshKeyType(@StringRes val label: Int, @StringRes val explanation: Int) { + Rsa(R.string.ssh_keygen_label_rsa, R.string.ssh_keygen_explanation_rsa), + Ecdsa(R.string.ssh_keygen_label_ecdsa, R.string.ssh_keygen_explanation_ecdsa), + Ed25519(R.string.ssh_keygen_label_ed25519, R.string.ssh_keygen_explanation_ed25519), +} + +/** The current SSH key's state, as a string resource. */ +@StringRes +fun sshKeySummary(): Int = + when { + !SshKey.exists -> R.string.ssh_key_state_none + SshKey.canShowSshPublicKey -> R.string.ssh_key_state_generated + else -> R.string.ssh_key_state_imported + } + +/** + * The SSH key screens' work: import a key file, generate a key. Both run in [viewModelScope] (no + * activity needed), so their outcome survives a recreation; the screens show it from [generated], + * [imported] and [failure]. [version] changes with the key, so screens re-read [SshKey]. + */ +@HiltViewModel +class SshKeyViewModel @Inject constructor(private val dispatcherProvider: DispatcherProvider) : + ViewModel() { + + var type by mutableStateOf(SshKeyType.Ecdsa) + + /** The "protect with screen lock" switch; only effective when the device is secure. */ + var protect by mutableStateOf(true) + + var busy by mutableStateOf(false) + private set + + var version by mutableIntStateOf(0) + private set + + /** Set after a successful import, until the screen showed its toast. */ + var imported by mutableStateOf(false) + private set + + /** The new public key after a generation. */ + var generated by mutableStateOf<String?>(null) + private set + + /** Why the last import or generation failed. */ + var failure by mutableStateOf<String?>(null) + private set + + /** + * Imports the private key at [uri]; [SshKey.import] reads it and keeps the old key on refusal. + */ + fun import(uri: Uri) { + if (busy) return + busy = true + viewModelScope.launch { + try { + withContext(dispatcherProvider.io()) { SshKey.import(uri) } + imported = true + version++ + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Any failure of a provider or of sshj, not only I/O: an uncaught one would crash the app. + failure = e.message.orEmpty() + } finally { + busy = false + } + } + } + + /** Generates a key of [type], replacing the current one; the prompt already ran if needed. */ + fun generate(requireAuthentication: Boolean) { + if (busy) return + busy = true + val type = type + viewModelScope.launch { + try { + generated = + withContext(dispatcherProvider.io()) { + when (type) { + SshKeyType.Rsa -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + SshKeyType.Ecdsa -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + SshKeyType.Ed25519 -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + } + SshKey.sshPublicKey.orEmpty() + } + version++ + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Keystore and provider failures come as many exception types; none may crash the app. + failure = e.message.orEmpty() + } finally { + busy = false + } + } + } + + /** Reports a failure found before generating (the screen lock prompt). */ + fun fail(message: String) { + failure = message + } + + fun dismissFailure() { + failure = null + } + + fun announced() { + imported = false + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt new file mode 100644 index 00000000..b4197604 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingLargest + +/** First start without a store: clone one, create a local one, or open the settings. */ +@Composable +fun WelcomeScreen( + onClone: () -> Unit, + onCreate: () -> Unit, + onSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLargest), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier.size(144.dp), + ) + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) + Text( + stringResource(R.string.select_repo_type_text), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Button(onClick = onClone, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.clone_remote_repo)) + } + OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.create_local_repo)) + } + TextButton(onClick = onSettings) { Text(stringResource(R.string.action_settings)) } + } + } +} + +/** + * A plain dialog for the setup screens: optional [title], [text], [confirm] and, when [dismiss] is + * given, a second button running [onDismissButton] (by default the same as dismissing). + */ +@Composable +internal fun SetupDialog( + text: String, + confirm: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + dismiss: String? = null, + onDismissButton: () -> Unit = onDismiss, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = + if (title != null) { + { Text(title) } + } else null, + text = { Text(text) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } }, + dismissButton = + if (dismiss != null) { + { TextButton(onClick = onDismissButton) { Text(dismiss) } } + } else null, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt deleted file mode 100644 index 3f04e35e..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ShowSshKeyFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val activity = requireActivity() - val publicKey = SshKey.sshPublicKey - return MaterialAlertDialogBuilder(requireActivity()).run { - setMessage(getString(R.string.ssh_keygen_message, publicKey)) - setTitle(R.string.your_public_key) - setNegativeButton(R.string.ssh_keygen_later) { _, _ -> - (activity as? SshKeyGenActivity)?.finish() - } - setPositiveButton(R.string.ssh_keygen_share) { _, _ -> - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, publicKey) - } - startActivity(Intent.createChooser(sendIntent, null)) - (activity as? SshKeyGenActivity)?.finish() - } - create() - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt deleted file mode 100644 index 618a4be0..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.sshkeygen - -import android.content.SharedPreferences -import android.os.Bundle -import android.security.keystore.UserNotAuthenticatedException -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.lifecycle.lifecycleScope -import app.passwordstore.R -import app.passwordstore.databinding.ActivitySshKeygenBinding -import app.passwordstore.injection.prefs.GitPreferences -import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.keyguardManager -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - -@AndroidEntryPoint -class SshKeyGenActivity : AppCompatActivity() { - - private var keyGenType = KeyGenType.Ecdsa - private val binding by viewBinding(ActivitySshKeygenBinding::inflate) - @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences - @Inject lateinit var dispatcherProvider: DispatcherProvider - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - generate.setOnClickListener { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - lifecycleScope.launch { generate() } - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> - setResult(RESULT_CANCELED) - finish() - } - show() - } - } else { - lifecycleScope.launch { generate() } - } - } - keyTypeGroup.check(R.id.key_type_ecdsa) - keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) - keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - keyGenType = - when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa - else -> throw IllegalStateException("Impossible key type selection") - } - keyTypeExplanation.setText( - when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa - } - ) - } - } - keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure - keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private suspend fun generate() { - binding.generate.apply { - text = getString(R.string.ssh_key_gen_generating_progress) - isEnabled = false - } - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val result = runCatching { - withContext(dispatcherProvider.io()) { - val requireAuthentication = binding.keyRequireAuthentication.isChecked - if (requireAuthentication) { - val result = - withContext(dispatcherProvider.main()) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate( - this@SshKeyGenActivity, - R.string.biometric_prompt_title_ssh_keygen, - ) { result -> - // Do not cancel on failed attempts as these are handled by the - // authenticator UI. - if (result !is Result.Retry) cont.resume(result) - } - } - } - if (result !is Result.Success) - throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) - } - keyGenType.generateKey(requireAuthentication) - } - } - gitPrefs.edit { remove("ssh_key_local_passphrase") } - binding.generate.apply { - text = getString(R.string.ssh_keygen_generate) - isEnabled = true - } - result.fold( - success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") }, - failure = { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.error_generate_ssh_key)) - .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> - setResult(RESULT_OK) - finish() - } - .show() - }, - ) - hideKeyboard() - } - - private fun hideKeyboard() { - val imm = getSystemService<InputMethodManager>() ?: return - var view = currentFocus - if (view == null) { - view = View(this) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt deleted file mode 100644 index e4740741..00000000 --- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.sshkeygen - -import android.net.Uri -import android.os.Bundle -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import app.passwordstore.R -import app.passwordstore.util.git.sshj.SshKey -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class SshKeyImportActivity : AppCompatActivity() { - - private val sshKeyImportAction = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) { - finish() - return@registerForActivityResult - } - runCatching { - SshKey.import(uri) - Toast.makeText( - this, - resources.getString(R.string.ssh_key_success_dialog_title), - Toast.LENGTH_LONG, - ) - .show() - setResult(RESULT_OK) - finish() - } - .onFailure { e -> - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) - .setMessage(e.message) - .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } - .show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } - setOnCancelListener { finish() } - show() - } - } else { - importSshKey() - } - } - - private fun importSshKey() { - sshKeyImportAction.launch(arrayOf("*/*")) - } -} diff --git a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt deleted file mode 100644 index cd29f626..00000000 --- a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.ui.util - -import androidx.recyclerview.widget.DefaultItemAnimator -import androidx.recyclerview.widget.RecyclerView - -class OnOffItemAnimator : DefaultItemAnimator() { - - var isEnabled: Boolean = true - set(value) { - // Defer update until no animation is running anymore. - isRunning { field = value } - } - - private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { - dispatchAnimationFinished(viewHolder) - return false - } - - override fun animateAppearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo?, - postLayoutInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animateChange( - oldHolder: RecyclerView.ViewHolder, - newHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animateChange(oldHolder, newHolder, preInfo, postInfo) - } else { - dontAnimate(oldHolder) - } - } - - override fun animateDisappearance( - viewHolder: RecyclerView.ViewHolder, - preLayoutInfo: ItemHolderInfo, - postLayoutInfo: ItemHolderInfo?, - ): Boolean { - return if (isEnabled) { - super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) - } else { - dontAnimate(viewHolder) - } - } - - override fun animatePersistence( - viewHolder: RecyclerView.ViewHolder, - preInfo: ItemHolderInfo, - postInfo: ItemHolderInfo, - ): Boolean { - return if (isEnabled) { - super.animatePersistence(viewHolder, preInfo, postInfo) - } else { - dontAnimate(viewHolder) - } - } -} diff --git a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt index 4aad4588..98f2229a 100644 --- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt +++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt @@ -46,7 +46,7 @@ object BiometricAuthenticator { /** * An incorrect biometric was entered, but the prompt UI is offering the option to retry the - * operation. + * operation. The prompt is still open; a terminal result follows. */ data object Retry : Result() @@ -100,7 +100,8 @@ object BiometricAuthenticator { when (errorCode) { /** Keep in sync with [androidx.biometric.BiometricPrompt.AuthenticationError] */ BiometricPrompt.ERROR_HW_UNAVAILABLE -> callback(Result.HardwareUnavailableOrDisabled) - BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> callback(Result.Retry) + // An error always closes the prompt, so none of them is a Retry. + BiometricPrompt.ERROR_UNABLE_TO_PROCESS, BiometricPrompt.ERROR_TIMEOUT -> callback( Result.Failure( diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index ebe4bc7d..59fd5cca 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -20,7 +20,7 @@ import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.ui.main.LaunchActivity import java.io.File data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) @@ -55,7 +55,7 @@ fun makeInlinePresentation( PendingIntent.getActivity( context, 0, - Intent(context, PasswordStore::class.java), + Intent(context, LaunchActivity::class.java), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE } else { diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index a13484c6..6528334d 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -8,7 +8,6 @@ package app.passwordstore.util.extensions import android.app.KeyguardManager import android.content.ClipboardManager import android.content.Context -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo @@ -17,12 +16,8 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build import android.util.Base64 -import android.util.TypedValue import android.view.View import android.view.autofill.AutofillManager -import androidx.activity.ComponentActivity -import androidx.compose.ui.Modifier -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig @@ -56,13 +51,6 @@ val Context.keyguardManager: KeyguardManager val Context.sharedPrefs: SharedPreferences get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0) -/** Resolve [attr] from the [Context]'s theme */ -fun Context.resolveAttribute(attr: Int): Int { - val typedValue = TypedValue() - this.theme.resolveAttribute(attr, typedValue, true) - return typedValue.data -} - /** * Commit changes to the store from a [FragmentActivity] using a custom implementation of * [GitOperation] @@ -90,32 +78,17 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab .execute() } -/** Check if [permission] has been granted to the app. */ -fun FragmentActivity.isPermissionGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -} - -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a - * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the - * [view] - */ +/** Show a [Snackbar] in a [FragmentActivity]. */ fun FragmentActivity.snackbar( view: View = findViewById(android.R.id.content), message: String, length: Int = Snackbar.LENGTH_SHORT, ): Snackbar { val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) snackbar.show() return snackbar } -/** Launch an activity denoted by [clazz]. */ -fun <T : ComponentActivity> ComponentActivity.launchActivity(clazz: Class<T>) { - startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW)) -} - /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) @@ -139,12 +112,3 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap getApplicationInfo(packageName, flags) } } - -/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */ -fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier { - return if (isEnabled) { - then(modifier()) - } else { - this - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt deleted file mode 100644 index 2dd3af0c..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import app.passwordstore.R - -/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ -fun Fragment.isPermissionGranted(permission: String): Boolean { - return requireActivity().isPermissionGranted(permission) -} - -/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */ -fun Fragment.finish() = requireActivity().finish() - -/** - * Perform a [commit] on this [FragmentManager] with custom animations and adding the - * [destinationFragment] to the fragment backstack - */ -fun FragmentManager.performTransactionWithBackStack( - destinationFragment: Fragment, - @IdRes containerViewId: Int = android.R.id.content, -) { - commit { - addToBackStack(destinationFragment.tag) - setCustomAnimations( - R.animator.slide_in_left, - R.animator.slide_out_left, - R.animator.slide_in_right, - R.animator.slide_out_right, - ) - replace(containerViewId, destinationFragment) - } -} diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt deleted file mode 100644 index 0050f2e5..00000000 --- a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.extensions - -import android.view.LayoutInflater -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty - -/** - * Imported from - * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c - */ -class FragmentViewBindingDelegate<T : ViewBinding>( - val fragment: Fragment, - val viewBindingFactory: (View) -> T, -) : ReadOnlyProperty<Fragment, T> { - - private var binding: T? = null - - init { - fragment.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> - viewLifecycleOwner.lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - } - ) - } - } - } - ) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding - } - - val lifecycle = fragment.viewLifecycleOwner.lifecycle - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException( - "Should not attempt to get bindings when Fragment views are destroyed." - ) - } - - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } - } -} - -fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) - -inline fun <T : ViewBinding> AppCompatActivity.viewBinding( - crossinline bindingInflater: (LayoutInflater) -> T -) = unsafeLazy { bindingInflater.invoke(layoutInflater) } diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt deleted file mode 100644 index 329db6d5..00000000 --- a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.git - -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.util.extensions.asLog -import app.passwordstore.util.extensions.hash -import app.passwordstore.util.extensions.time -import app.passwordstore.util.extensions.unsafeLazy -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import logcat.LogPriority.ERROR -import logcat.logcat -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.revwalk.RevCommit - -private val TAG = GitLogModel::class.java.simpleName - -private fun commits(): Iterable<RevCommit> { - val repo = PasswordRepository.repository - if (repo == null) { - logcat(TAG, ERROR) { "Could not access git repository" } - return listOf() - } - return runCatching { Git(repo).log().call() } - .getOrElse { e -> - logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") } - listOf() - } -} - -/** - * Provides [GitCommit] s from a git-log of the password git repository. - * - * All commits are acquired on the first request to this object. - */ -class GitLogModel { - - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList<GitCommit> by unsafeLazy { - commits() - .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } - .toMutableList() - } - val size = cache.size - - fun get(index: Int): GitCommit? { - if (index >= size) - logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } -} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt index d55d9970..4b4913d0 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt @@ -57,9 +57,8 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call .setMessage( callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) ) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } + // Only closes the dialog: the calling activity is the app's main screen. + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null) .show() false } else { diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index a3edac7d..25d99d6d 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -9,16 +9,13 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledBySystem import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser -import app.passwordstore.util.auth.BiometricAuthenticator.Result.Failure import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.git.GitCommandExecutor import app.passwordstore.util.git.sshj.SshAuthMethod import app.passwordstore.util.git.sshj.SshKey @@ -27,8 +24,6 @@ import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -37,9 +32,6 @@ import dagger.hilt.components.SingletonComponent import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.userauth.password.PasswordFinder @@ -106,12 +98,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } private fun getSshKey(generateKey: Boolean) { - runCatching { - callingActivity.launchActivity( - if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java - ) - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } + callingActivity.startActivity( + MainActivity.setupIntent( + callingActivity, + if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh, + ) + ) } private fun registerAuthProviders( @@ -152,10 +144,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { ) { _, _ -> getSshKey(true) } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - } + // Cancel only closes the dialog: the calling activity is the app's own screen now. + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null) .show() } @@ -171,7 +161,9 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { callingActivity, R.string.biometric_prompt_title_ssh_auth, ) { result -> - if (result !is Failure && result !is Retry) cont.resume(result) + // Retry: a failed attempt while the prompt stays open. Every other result + // closes it, so waiting for more would leave the operation hanging. + if (result !is Retry) cont.resume(result) } } } @@ -182,10 +174,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { is CanceledByUser -> { return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } - is Failure, - is CanceledBySystem -> { - throw IllegalStateException("Biometric authentication failures should be ignored") - } else -> { // There is a chance we succeed if the user recently confirmed // their screen lock. Doing so would have a potential to confuse @@ -197,7 +185,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { Toast.LENGTH_LONG, ) .show() - callingActivity.finish() + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } } } else { diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 109e36c9..7c8f5ba5 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -16,6 +16,7 @@ import android.util.Base64 import androidx.core.content.edit import app.passwordstore.Application import app.passwordstore.R +import app.passwordstore.ui.setup.readAtMost import app.passwordstore.util.crypto.keystoreAesKey import app.passwordstore.util.crypto.requireUserAuthentication import app.passwordstore.util.crypto.seal @@ -29,6 +30,8 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.runCatching import java.io.File import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.security.KeyFactory import java.security.KeyPair import java.security.KeyPairGenerator @@ -46,7 +49,9 @@ import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Ed25519KeyFactory import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyFormat import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" @@ -82,6 +87,25 @@ internal fun ed25519KeyPair(seed: ByteArray) = Ed25519KeyFactory.getPrivateKey(seed), ) +/** + * True when [lines] look like an SSH private key sshj can load: private key markers on the first + * and last line, in a format sshj recognizes. An OpenPGP private key block has the same markers and + * is refused by the format check. + */ +internal fun isSshPrivateKey(lines: List<String>): Boolean { + if ( + lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + return false + return try { + KeyProviderUtil.detectKeyFileFormat(lines.joinToString("\n"), false) != KeyFormat.Unknown + } catch (_: IOException) { + false + } +} + object SshKey { val sshPublicKey @@ -182,15 +206,20 @@ object SshKey { } private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (privateKeyFile.isFile) { privateKeyFile.delete() } + deleteKeyExtras() + type = null + } + + /** Everything of the current key but its private key file. */ + private fun deleteKeyExtras() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (publicKeyFile.isFile) { publicKeyFile.delete() } context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - type = null } fun import(uri: Uri) { @@ -198,9 +227,8 @@ object SshKey { val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) + // A single row; none when the provider cannot tell: the IOException below. + if (cursor.moveToFirst()) cursor.getInt(0) else null } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. @@ -212,26 +240,43 @@ object SshKey { val sshKeyInputStream = context.contentResolver.openInputStream(uri) ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() } - - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if ( - lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) + // Bounded whatever the provider claimed above: a larger stream is refused, not read whole. + val bytes = + sshKeyInputStream.use { readAtMost(it, 100_000) } + ?: throw IllegalArgumentException( + context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + ) + val lines = + try { + bytes.inputStream().bufferedReader().readLines() + } finally { + bytes.fill(0) + } + + // Private key markers and a format sshj can read; the current key stays when this fails. + if (!isSshPrivateKey(lines)) throw IllegalArgumentException( context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) ) // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - + // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's + // Keystore entry, public key and passphrase go. + val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp") + try { + // Canonicalize line endings to '\n'. + temp.writeText(lines.joinToString("\n")) + Files.move( + temp.toPath(), + privateKeyFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } type = Type.Imported + deleteKeyExtras() } /** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */ diff --git a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt index 1b521141..6382e1ed 100644 --- a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt +++ b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt @@ -42,13 +42,10 @@ class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) { ) val user = gitSettings.proxyUsername ?: "" val password = gitSettings.proxyPassword ?: "" - if (user.isEmpty() || password.isEmpty()) { - System.clearProperty(HTTP_PROXY_USER_PROPERTY) - System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) - } else { - System.setProperty(HTTP_PROXY_USER_PROPERTY, user) - System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) - } + // The Authenticator below supplies the credentials; never copy the password into a + // process-wide system property. + System.clearProperty(HTTP_PROXY_USER_PROPERTY) + System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) Authenticator.setDefault( object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index d315daf3..004028c4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -108,7 +108,7 @@ constructor( } var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME) set(value) { proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } } diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 06075655..82847f02 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti migrateToDiceware(sharedPrefs) removeExternalStorageProperties(sharedPrefs) removeCurrentBranchValue(sharedPrefs) + migrateRemovedSortOrders(sharedPrefs) + removeOpenKeystoreKeyId(sharedPrefs) } /** @@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) { } } } + +/** + * "Files first" and "type independent" were removed with the Compose list, which shows folders and + * entries in separate groups: a stored one becomes folders first, the list's default. + */ +private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) { + val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return + if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return + logcat(TAG, INFO) { "Replacing the removed sort order $stored" } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) } +} + +/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */ +private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) { + if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt index 224d9d66..921e9bc4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) } ), - INDEPENDENT( - Comparator { p1: PasswordItem, p2: PasswordItem -> - p1.name.compareTo(p2.name, ignoreCase = true) - } - ), RECENTLY_USED( Comparator { p1: PasswordItem, p2: PasswordItem -> val recentHistory = @@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) else -> p1.name.compareTo(p2.name, ignoreCase = true) } } - ), - FILE_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) - } ); companion object { + /** + * The stored sort order; anything else (the removed "files first" and "type independent", a + * value from another version) reads as [FOLDER_FIRST]. + */ @JvmStatic fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + val stored = settings.getString(PreferenceKeys.SORT_ORDER) + return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST } } } diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt deleted file mode 100644 index 5c7429cd..00000000 --- a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.util.viewmodel - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import app.passwordstore.crypto.KeyUtils -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.crypto.PGPKeyManager -import com.github.michaelbull.result.map -import com.github.michaelbull.result.onSuccess -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.launch - -@HiltViewModel -class PGPKeyListViewModel @Inject constructor(private val keyManager: PGPKeyManager) : ViewModel() { - var keys: ImmutableList<PGPIdentifier> by mutableStateOf(persistentListOf()) - - init { - updateKeySet() - } - - fun updateKeySet() { - viewModelScope.launch { - keyManager - .getAllKeys() - .map { keys -> keys.mapNotNull { key -> KeyUtils.tryGetEmail(key) } } - .onSuccess { keys = it.toPersistentList() } - } - } - - fun deleteKey(identifier: PGPIdentifier) { - viewModelScope.launch { - keyManager.removeKey(identifier) - updateKeySet() - } - } -} diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 48804e7a..5f35644d 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -6,24 +6,12 @@ package app.passwordstore.util.viewmodel import android.app.Application import android.content.SharedPreferences -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.lifecycle.AndroidViewModel -import androidx.recyclerview.selection.ItemDetailsLookup -import androidx.recyclerview.selection.ItemKeyProvider -import androidx.recyclerview.selection.Selection -import androidx.recyclerview.selection.SelectionPredicates -import androidx.recyclerview.selection.SelectionTracker -import androidx.recyclerview.selection.StorageStrategy -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences +import app.passwordstore.ui.list.listRows import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.checkMainThread import app.passwordstore.util.coroutines.DispatcherProvider @@ -34,9 +22,7 @@ import com.github.androidpasswordstore.sublimefuzzy.Fuzzy import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import java.text.Collator -import java.util.Locale import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -50,9 +36,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.yield -import me.zhanghai.android.fastscroll.PopupTextProvider private fun File.toPasswordItem() = if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) @@ -70,12 +54,7 @@ private fun PasswordItem.Companion.makeComparator( directoryStructure: DirectoryStructure, ): Comparator<PasswordItem> { return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we - // simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.FOLDER_FIRST -> compareBy<PasswordItem> { it.type } PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator } .then( @@ -88,9 +67,6 @@ private fun PasswordItem.Companion.makeComparator( ) } -val PasswordItem.stableId: String - get() = file.absolutePath - enum class FilterMode { NoFilter, StrictDomain, @@ -105,7 +81,6 @@ enum class SearchMode { enum class ListMode { FilesOnly, - DirectoriesOnly, AllEntries, } @@ -191,7 +166,7 @@ constructor( ) ) - data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) + data class SearchResult(val passwordItems: List<PasswordItem>) val searchResult = searchActionFlow @@ -207,8 +182,6 @@ constructor( when (searchAction.listMode) { ListMode.FilesOnly -> listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io()) - ListMode.DirectoriesOnly -> - listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io()) ListMode.AllEntries -> listResultFlow } val passwordList = @@ -264,7 +237,7 @@ constructor( .map { it.second } } } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + SearchResult(passwordList) } .flowOn(dispatcherProvider.io()) @@ -302,55 +275,28 @@ constructor( private val _currentDir = MutableStateFlow(root) val currentDir = _currentDir.asStateFlow() - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = ArrayDeque<NavigationStackEntry>() + /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */ + val rows = + searchResult + .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) } + .flowOn(dispatcherProvider.io()) - fun navigateTo( - newDirectory: File = root, - listMode: ListMode = ListMode.AllEntries, - recyclerViewState: Parcelable? = null, - pushPreviousLocation: Boolean = true, - ) { + /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */ + fun navigateTo(newDirectory: File) { if (!newDirectory.exists()) return require(newDirectory.isDirectory) { "Can only navigate to a directory" } - if (pushPreviousLocation) { - navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState)) - } searchActionFlow.update { makeSearchAction( filter = "", baseDirectory = newDirectory, filterMode = FilterMode.NoFilter, searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = listMode, + listMode = ListMode.AllEntries, ) } _currentDir.update { newDirectory } } - val canNavigateBack - get() = navigationStack.isNotEmpty() - - /** - * Navigate back to the last location on the [navigationStack] and restore a cached scroll - * position if possible. - * - * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. - */ - fun navigateBack(): Parcelable? { - if (!canNavigateBack) return null - val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst() - navigateTo(oldDir, pushPreviousLocation = false) - return oldRecyclerViewState - } - - fun reset() { - navigationStack.clear() - forceUpdateOnNextSearchAction() - navigateTo(pushPreviousLocation = false) - } - fun search( filter: String, baseDirectory: File? = null, @@ -397,114 +343,3 @@ constructor( } } } - -private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() { - - override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem.file.absolutePath == newItem.file.absolutePath - - override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem -} - -open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val coroutineScope: CoroutineScope, - private val dispatcherProvider: DispatcherProvider, - private val viewHolderBinder: - suspend T.(item: PasswordItem, dispatcherProvider: DispatcherProvider) -> Unit, -) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider { - - fun <T : ItemDetailsLookup<String>> makeSelectable( - recyclerView: RecyclerView, - itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T, - ) { - selectionTracker = - SelectionTracker.Builder( - "SearchableRepositoryAdapter", - recyclerView, - itemKeyProvider, - itemDetailsLookupCreator(recyclerView), - StorageStrategy.createStringStorage(), - ) - .withSelectionPredicate(SelectionPredicates.createSelectAnything()) - .build() - .apply { - addObserver( - object : SelectionTracker.SelectionObserver<String>() { - override fun onSelectionChanged() { - this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( - requireSelectionTracker().selection - ) - } - } - ) - } - } - - private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null - - open fun onItemClicked( - listener: (holder: T, item: PasswordItem) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onItemClickedListener == null) { - "Only a single listener can be registered for onItemClicked" - } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null - - open fun onSelectionChanged( - listener: (selection: Selection<String>) -> Unit - ): SearchableRepositoryAdapter<T> { - check(onSelectionChangedListener == null) { - "Only a single listener can be registered for onSelectionChanged" - } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = - object : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position).stableId - - override fun getPosition(key: String) = - (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION - } - - private var selectionTracker: SelectionTracker<String>? = null - - fun requireSelectionTracker() = selectionTracker!! - - private val selectedFiles - get() = requireSelectionTracker().selection.map { File(it) } - - fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } - - fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) - - final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { - val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false) - return viewHolderCreator(view) - } - - final override fun onBindViewHolder(holder: T, position: Int) { - val item = getItem(position) - holder.apply { - coroutineScope.launch(dispatcherProvider.mainImmediate()) { - viewHolderBinder.invoke(holder, item, dispatcherProvider) - } - selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } - itemView.setOnClickListener { - // Do not emit custom click events while the user is selecting items. - if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item) - } - } - } - - final override fun getPopupText(view: View, position: Int): String { - return getItem(position).name[0].toString().uppercase(Locale.getDefault()) - } -} |
