diff options
Diffstat (limited to 'app/src/main')
226 files changed, 9153 insertions, 10322 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1cbe76a9..0eabbab4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,40 +30,25 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppThemeM3" - tools:ignore="GoogleAppIndexingWarning" - tools:targetApi="s"> - - <activity - android:name=".ui.passwords.PasswordStore" - android:configChanges="orientation|screenSize" - android:exported="false" /> - - <activity - android:name=".ui.onboarding.activity.OnboardingActivity" - android:configChanges="orientation|screenSize" - android:exported="false" /> - - <activity - android:name=".ui.proxy.ProxySelectorActivity" - android:exported="false" - android:label="@string/activity_proxy_label" - android:windowSoftInputMode="adjustResize" /> - - <activity - android:name=".ui.crypto.DecryptActivity" - android:exported="false" /> + tools:ignore="GoogleAppIndexingWarning"> <activity android:name=".ui.main.LaunchActivity" android:configChanges="orientation|screenSize" - android:exported="true" - android:theme="@style/NoBackgroundThemeM3"> + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> + <!-- ponytail: rotation must not cancel store and git work in lifecycleScope; theme or locale changes still recreate. --> + <activity + android:name=".ui.main.MainActivity" + android:configChanges="orientation|screenSize" + android:exported="false" + android:windowSoftInputMode="adjustResize" /> + <activity android:name="com.journeyapps.barcodescanner.CaptureActivity" android:clearTaskOnLaunch="true" @@ -73,35 +58,6 @@ android:windowSoftInputMode="stateAlwaysHidden" tools:node="replace" /> - <activity - android:name=".ui.git.config.GitServerConfigActivity" - android:exported="false" - android:label="@string/title_activity_git_clone" - android:windowSoftInputMode="adjustResize" /> - - <activity - android:name=".ui.git.config.GitConfigActivity" - android:exported="false" - android:label="@string/title_activity_git_config" - android:windowSoftInputMode="adjustResize" /> - - <activity - android:name=".ui.git.log.GitLogActivity" - android:exported="false" - android:label="@string/title_activity_git_log" /> - - <activity - android:name=".ui.settings.SettingsActivity" - android:exported="false" - android:label="@string/action_settings" - android:parentActivityName=".ui.passwords.PasswordStore" /> - - <activity - android:name=".ui.crypto.PasswordCreationActivity" - android:exported="false" - android:label="@string/new_password_title" - android:windowSoftInputMode="adjustResize" /> - <service android:name=".util.services.ClipboardService" android:exported="false" @@ -124,21 +80,11 @@ android:resource="@xml/oreo_autofill_service" /> </service> - <activity - android:name=".ui.folderselect.SelectFolderActivity" - android:exported="false" /> - <activity - android:name=".ui.sshkeygen.SshKeyImportActivity" - android:exported="false" - android:theme="@style/NoBackgroundThemeM3" - android:windowSoftInputMode="adjustResize" /> - <activity - android:name=".ui.sshkeygen.SshKeyGenActivity" - android:exported="false" - android:label="@string/pref_ssh_keygen_title" - android:windowSoftInputMode="adjustResize" /> + <!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress; + other recreations (theme, locale) cancel it. --> <activity android:name=".ui.autofill.AutofillDecryptActivity" + android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" android:exported="false" android:theme="@style/NoBackgroundThemeM3" /> <activity @@ -147,24 +93,18 @@ android:exported="false" android:theme="@style/DialogLikeThemeM3" android:windowSoftInputMode="adjustNothing" /> + <!-- ponytail: rotation must not cancel a save running in lifecycleScope, as in MainActivity. --> <activity android:name=".ui.autofill.AutofillSaveActivity" + android:configChanges="orientation|screenSize" android:exported="false" - android:theme="@style/NoBackgroundThemeM3" /> + android:windowSoftInputMode="adjustResize" /> <activity android:name=".ui.autofill.AutofillPublisherChangedActivity" android:configChanges="orientation|keyboardHidden" android:exported="false" android:theme="@style/DialogLikeThemeM3" android:windowSoftInputMode="adjustNothing" /> - <activity - android:name=".ui.pgp.PGPKeyImportActivity" - android:theme="@style/NoBackgroundThemeM3" /> - <activity - android:name=".ui.pgp.PGPKeyListActivity" - android:exported="false" - android:label="@string/activity_label_pgp_key_manager" - android:parentActivityName=".ui.settings.SettingsActivity" /> </application> <queries> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png Binary files differdeleted file mode 100644 index 8de23a46..00000000 --- a/app/src/main/ic_launcher-playstore.png +++ /dev/null diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt index bda61265..dbeb4ffb 100644 --- a/app/src/main/java/app/passwordstore/Application.kt +++ b/app/src/main/java/app/passwordstore/Application.kt @@ -4,14 +4,13 @@ */ package app.passwordstore +import android.app.UiModeManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences -import android.os.Build import android.os.StrictMode -import androidx.appcompat.app.AppCompatDelegate.* import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.injection.context.FilesDirPath import app.passwordstore.injection.prefs.SettingsPreferences @@ -24,7 +23,6 @@ import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.settings.removeSecurityCryptoData import app.passwordstore.util.settings.runMigrations -import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import java.util.concurrent.Executors import javax.inject.Inject @@ -65,7 +63,6 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere removeSecurityCryptoData(this, prefs) runMigrations(filesDirPath, prefs, gitSettings) proxyUtils.setDefaultProxy() - DynamicColors.applyToActivitiesIfAvailable(this) setupScreenOffHandler() } @@ -92,49 +89,35 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } private fun setVmPolicy() { - val builder = + StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectActivityLeaks() .detectFileUriExposure() .detectLeakedClosableObjects() .detectLeakedRegistrationObjects() .detectLeakedSqlLiteObjects() - - builder.detectContentUriWithoutPermission() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.detectCredentialProtectedWhileLocked().detectImplicitDirectBoot() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.detectNonSdkApiUsage() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.detectIncorrectContextUse().detectUnsafeIntentLaunch() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - builder.penaltyListener(Executors.newSingleThreadExecutor()) { violation -> - logcat(VERBOSE) { violation.stackTraceToString() } - } - } else { - builder.penaltyLog() - } - - val policy = builder.build() - StrictMode.setVmPolicy(policy) + .detectContentUriWithoutPermission() + .detectCredentialProtectedWhileLocked() + .detectImplicitDirectBoot() + .detectNonSdkApiUsage() + .detectIncorrectContextUse() + .detectUnsafeIntentLaunch() + .penaltyListener(Executors.newSingleThreadExecutor()) { violation -> + logcat(VERBOSE) { violation.stackTraceToString() } + } + .build() + ) } private fun setNightMode() { - setDefaultNightMode( - when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { - "light" -> MODE_NIGHT_NO - "dark" -> MODE_NIGHT_YES - "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM - else -> MODE_NIGHT_AUTO_BATTERY - } - ) + getSystemService(UiModeManager::class.java) + .setApplicationNightMode( + when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { + "light" -> UiModeManager.MODE_NIGHT_NO + "dark" -> UiModeManager.MODE_NIGHT_YES + else -> UiModeManager.MODE_NIGHT_AUTO + } + ) } companion object { 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/AutofillResponseBuilderModule.kt b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt index 46e61397..1717e4cc 100644 --- a/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt +++ b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt @@ -5,8 +5,6 @@ package app.passwordstore.injection -import android.os.Build -import app.passwordstore.util.autofill.Api26AutofillResponseBuilder import app.passwordstore.util.autofill.Api30AutofillResponseBuilder import app.passwordstore.util.autofill.AutofillResponseBuilder import dagger.Module @@ -21,11 +19,6 @@ object AutofillResponseBuilderModule { @Provides @Reusable - fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Api30AutofillResponseBuilder.Factory - } else { - Api26AutofillResponseBuilder.Factory - } - } + fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory = + Api30AutofillResponseBuilder.Factory } 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..3b62de4c 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.os.Build +import android.content.SharedPreferences import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +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.fragment.app.FragmentActivity 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,162 @@ 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 : FragmentActivity() { + @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) + private var passphraseWrong by mutableStateOf(false) + + // 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 = passphraseWrong, + 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 + ) { + // A toast on the application context, not a snackbar: this window finishes right after, + // and the toast must outlive it. + Toast.makeText(applicationContext, it, Toast.LENGTH_LONG).show() + } + ?: run { + finish() + return + } lifecycleScope.launch(dispatcherProvider.main()) { when (authResult) { // Internally handled by the prompt dialog @@ -111,8 +229,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 +244,40 @@ class AutofillDecryptActivity : BasePGPActivity() { gpgIdentifiers.first(), ) if (cachedPassphrase != null) { - decryptWithPassphrase( - File(filePath), - gpgIdentifiers, - clientState, - action, - cachedPassphrase, - ) + decryptWithPassphrase(gpgIdentifiers, cachedPassphrase, cached = true) } 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>, wrong: Boolean = false) { 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) - } - } - } + passphraseWrong = wrong + passphraseFor = identifiers } + /** + * Caches [password] only when the user [typed] it, as the entry view does. A [cached] passphrase + * that fails (the key's passphrase changed) is not tried again: the prompt asks for the new one, + * which replaces it in the cache. + */ private suspend fun decryptWithPassphrase( - filePath: File, identifiers: List<PGPIdentifier>, - clientState: Bundle, - action: AutofillAction, password: String, + typed: Boolean = false, + cached: Boolean = false, ) { - val credentials = decryptCredential(filePath, password, identifiers) + val credentials = decryptCredential(File(filePath), password, identifiers, typed) + if (credentials == null && cached) { + askPassphrase(identifiers, wrong = true) + return + } if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -201,6 +302,7 @@ class AutofillDecryptActivity : BasePGPActivity() { file: File, password: String, identifiers: List<PGPIdentifier>, + typed: Boolean, ): Credentials? { runCatching { file.readBytes().inputStream() } .onFailure { e -> @@ -218,7 +320,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) @@ -263,11 +367,7 @@ class AutofillDecryptActivity : BasePGPActivity() { context, decryptFileRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } 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..474005b5 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -8,43 +8,42 @@ import android.app.PendingIntent import android.content.Context 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.fragment.app.FragmentActivity +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 @AndroidEntryPoint -class AutofillFilterView : AppCompatActivity() { +class AutofillFilterView : FragmentActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -74,22 +73,19 @@ class AutofillFilterView : AppCompatActivity() { context, matchAndDecryptFileRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } } 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 +96,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 +128,76 @@ 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..74637ca1 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -9,30 +9,28 @@ import android.content.Context import android.content.Intent import android.content.IntentSender import android.content.pm.PackageManager -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.appcompat.app.AppCompatActivity +import androidx.activity.compose.setContent import androidx.core.content.IntentCompat +import androidx.fragment.app.FragmentActivity 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 -class AutofillPublisherChangedActivity : AppCompatActivity() { +class AutofillPublisherChangedActivity : FragmentActivity() { companion object { @@ -55,89 +53,78 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { context, publisherChangedRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } } - 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" } finish() 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..2ed0adef 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,18 @@ 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.appcompat.app.AppCompatActivity +import androidx.activity.compose.setContent import androidx.core.os.bundleOf -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.PasswordCreationActivity +import androidx.fragment.app.FragmentActivity +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.ui.prompts.PromptHost import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder @@ -23,12 +29,17 @@ 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() { +class AutofillSaveActivity : FragmentActivity() { companion object { @@ -102,60 +113,69 @@ 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, ) + PromptHost(this@AutofillSaveActivity) } - 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..626ec130 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -0,0 +1,134 @@ +/* + * 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.os.PersistableBundle +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.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; [sensitive] text is hidden from its preview. */ +fun FragmentActivity.copyTextToClipboard(text: String?, sensitive: Boolean = true) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + if (sensitive) { + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + } + clipboard.setPrimaryClip(clip) +} + +/** + * 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 its message passed to [onInvalid]: further execution must stop to let the + * user correct the problem. + */ +fun getPGPIdentifiers(subDir: String, onInvalid: (message: Int) -> Unit): 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 + onInvalid(message) + 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..8e433957 --- /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.SnackbarDuration +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.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 = + getPGPIdentifiers(target.parentFile?.relativeTo(root)?.path.orEmpty(), ::say) ?: 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 = SnackbarDuration.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..fe5ee772 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -0,0 +1,267 @@ +/* + * 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 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 = + ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) + .copy(Bitmap.Config.ARGB_8888, false) + 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..c414d4d9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -0,0 +1,249 @@ +/* + * 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.extensions.snackbar +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 = + getPGPIdentifiers(getParentPath(file.absolutePath, root.absolutePath)) { + activity.snackbar(message = activity.getString(it)) + } ?: 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/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt index 32872761..f7181c4e 100644 --- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -5,10 +5,11 @@ package app.passwordstore.ui.git.base import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.git.ErrorMessages @@ -25,10 +26,8 @@ import com.github.michaelbull.result.Err import com.github.michaelbull.result.Result import com.github.michaelbull.result.andThen import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat import net.schmizz.sshj.common.DisconnectReason @@ -37,11 +36,11 @@ import net.schmizz.sshj.transport.TransportException import net.schmizz.sshj.userauth.UserAuthException /** - * Abstract [AppCompatActivity] that holds some information that is commonly shared across + * Abstract [FragmentActivity] that holds some information that is commonly shared across * git-related tasks and makes sense to be held here. */ @AndroidEntryPoint -abstract class BaseGitActivity : AppCompatActivity() { +abstract class BaseGitActivity : FragmentActivity() { /** Enum of possible Git operations than can be run through [launchGitOperation]. */ enum class GitOp { @@ -100,23 +99,17 @@ abstract class BaseGitActivity : AppCompatActivity() { finish() } - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + suspend fun promptOnErrorHandler(err: Throwable) { val error = rootCauseException(err) if (!isExplicitlyUserInitiatedError(error)) { gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } logcat { error.asLog() } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { onPromptDone() } - show() - } - } - } else { - onPromptDone() + prompts.ask( + title = getString(R.string.jgit_error_dialog_title), + message = ErrorMessages[error], + confirm = getString(R.string.dialog_ok), + ) } } 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..97092716 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -0,0 +1,376 @@ +/* + * 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.extensions.snackbar +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.PreferenceKeys +import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel +import com.github.michaelbull.result.onSuccess +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, + onAbout: () -> 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).onSuccess { + activity.snackbar( + message = activity.getString(R.string.git_tool_done, activity.getString(op.label)) + ) + } + 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, + onAbout = onAbout, + 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..593c744b --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt @@ -0,0 +1,349 @@ +/* + * 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, + onAbout: () -> 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, onAbout, 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, + onAbout: () -> 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() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.about)) }, + onClick = { + menu = false + onAbout() + }, + ) + } + } + } + }, + ) + }, + 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)) + } +} + +internal 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..cff0964c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -0,0 +1,171 @@ +/* + * 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.ui.prompts.Choice +import app.passwordstore.ui.prompts.prompts +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 java.io.File +import java.nio.file.Files +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." } + prompts.ask( + title = getString(R.string.password_move_error_title), + message = getString(R.string.password_move_error_message, source, destination), + confirm = getString(R.string.dialog_ok), + ) + } + return moved +} + +/** Shows [message] under [title] and suspends until answered; true when confirmed. */ +private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean = + prompts.ask( + title = title, + message = message, + confirm = getString(R.string.dialog_ok), + cancel = getString(R.string.dialog_cancel), + ) == Choice.Confirm 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..54aaf9de 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -9,12 +9,9 @@ import android.content.Intent import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity 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 @@ -22,12 +19,23 @@ import app.passwordstore.util.settings.PreferenceKeys import java.io.File @SuppressLint("CustomSplashScreen") -class LaunchActivity : AppCompatActivity() { +class LaunchActivity : FragmentActivity() { + + 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..5037e646 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -0,0 +1,286 @@ +/* + * 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.prompts.PromptHost +import app.passwordstore.ui.settings.AboutRoute +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] (a FragmentActivity, which 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) + }, + onAbout = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(AboutRoute) }, + 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) + } + PromptHost(this@MainActivity) + } + } + } + + /** 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): Result<Unit, Throwable> = + 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 if git operations could reach the nav controller. + 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/prompts/Prompts.kt b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt new file mode 100644 index 00000000..1af3f01c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.mutableStateListOf +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.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.text.input.VisualTransformation +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import app.passwordstore.R +import app.passwordstore.ui.compose.R as composeR +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */ +enum class Choice { + Confirm, + Other, + Cancel, +} + +/** What the user entered in a [CredentialRequest]. */ +class CredentialAnswer(val text: String, val remember: Boolean) + +/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */ +sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) { + internal fun answer(value: T) = deliver(value) + + internal fun cancel() = deliver(cancelled) +} + +class Question( + val title: String, + val message: String, + val confirm: String, + val cancel: String?, + val other: String?, + deliver: (Choice) -> Unit, +) : Prompt<Choice>(Choice.Cancel, deliver) + +class CredentialRequest( + val message: String, + val hint: String, + val remember: String, + val error: String?, + deliver: (CredentialAnswer?) -> Unit, +) : Prompt<CredentialAnswer?>(null, deliver) + +/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */ +class Snack(val message: String, val length: SnackbarDuration) + +/** + * The dialogs and the snackbar of one activity, for code with no composable of its own (git + * operations and store actions), drawn by [PromptHost]. Reach it through [prompts]. + */ +class Prompts : ViewModel() { + + private val queue = mutableStateListOf<Prompt<*>>() + + /** Set once the activity is gone for good: nothing draws a new dialog any more. */ + private var closed = false + + /** The dialog on screen. The others wait for it to be answered. */ + val current: Prompt<*>? + get() = queue.firstOrNull() + + var snack by mutableStateOf<Snack?>(null) + private set + + /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */ + suspend fun ask( + title: String, + message: String, + confirm: String, + cancel: String? = null, + other: String? = null, + ): Choice = suspendCancellableCoroutine { cont -> + val question = Question(title, message, confirm, cancel, other) { cont.resume(it) } + queue += question + cont.invokeOnCancellation { queue.remove(question) } + } + + /** + * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation + * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one. + */ + fun askCredential( + message: String, + hint: String, + remember: String, + error: String?, + deliver: (CredentialAnswer?) -> Unit, + ) { + if (closed) { + deliver(null) + return + } + queue += CredentialRequest(message, hint, remember, error, deliver) + } + + /** Answers [prompt], once: a double tap must not resume its caller twice. */ + fun <T> answer(prompt: Prompt<T>, value: T) { + if (queue.remove(prompt)) prompt.answer(value) + } + + fun cancel(prompt: Prompt<*>) { + if (queue.remove(prompt)) prompt.cancel() + } + + fun cancelAll() { + queue.toList().forEach(::cancel) + } + + /** Shows [message] in place of any current snackbar, as the View snackbar did. */ + fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack = + Snack(message, length).also { snack = it } + + /** Hides [shown], unless another snackbar replaced it. */ + fun dismiss(shown: Snack) { + if (snack === shown) snack = null + } + + internal fun close() { + closed = true + cancelAll() + } + + override fun onCleared() = close() +} + +/** The [Prompts] of this activity; it survives recreation. */ +val FragmentActivity.prompts: Prompts + get() = ViewModelProvider(this)[Prompts::class.java] + +/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */ +@Composable +fun PromptHost(activity: FragmentActivity, modifier: Modifier = Modifier) { + val prompts = remember(activity) { activity.prompts } + val snackbars = remember { SnackbarHostState() } + val snack = prompts.snack + // A new snack cancels this effect, which takes the old one off the screen. + LaunchedEffect(snack) { + if (snack != null) { + snackbars.showSnackbar(snack.message, duration = snack.length) + prompts.dismiss(snack) + } + } + Box(modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) { + SnackbarHost(snackbars) + } + val prompt = prompts.current + // Keyed: a retry is a new request and starts with an empty field. + key(prompt) { + when (prompt) { + is Question -> QuestionDialog(prompt, prompts) + is CredentialRequest -> CredentialDialog(prompt, prompts) + null -> {} + } + } +} + +@Composable +private fun QuestionDialog(question: Question, prompts: Prompts) { + AlertDialog( + onDismissRequest = { prompts.cancel(question) }, + title = { Text(question.title) }, + text = { Text(question.message) }, + confirmButton = { + TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) { Text(question.confirm) } + }, + dismissButton = { + // No Row: the dialog's button area wraps its children when they do not fit. + question.cancel?.let { + TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) } + } + question.other?.let { + TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) } + } + }, + ) +} + +/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */ +@Composable +private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) { + // remember, never rememberSaveable: the credential must not reach saved state. + var text by remember { mutableStateOf("") } + var keep by remember { mutableStateOf(true) } + var revealed by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(request.error) } + val focus = remember { FocusRequester() } + fun submit() = prompts.answer(request, CredentialAnswer(text, keep)) + AlertDialog( + onDismissRequest = { prompts.cancel(request) }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.passphrase_dialog_title)) }, + text = { + Column { + Text(request.message) + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(request.hint) }, + supportingText = error?.let { { Text(it) } }, + isError = error != null, + 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, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + ) + Row( + Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = keep, onCheckedChange = null) + Text(request.remember) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } }, + dismissButton = { + TextButton(onClick = { prompts.cancel(request) }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} 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/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt new file mode 100644 index 00000000..4a366390 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.util.extensions.getPackageInfoCompat +import app.passwordstore.util.extensions.snackbar +import java.security.MessageDigest + +private const val REPOSITORY = "https://github.com/danixland/HardenedPass" +private const val DOCUMENTATION = "https://danix.xyz/hardenedpass" +private const val KO_FI = "https://ko-fi.com/K6Y520C9I6" + +/** + * What ships in the APK and its license, by hand: update it when a dependency comes or goes. A + * generating plugin would be one more build dependency for a list this short. + */ +private val LICENSES = + listOf( + "AndroidX, Jetpack Compose" to "Apache-2.0", + "Apache Commons Codec, Curtains, Guava, JavaEWAH, JSpecify, uri-kmp" to "Apache-2.0", + "Bouncy Castle" to "Bouncy Castle Licence (MIT)", + "Dagger, Hilt" to "Apache-2.0", + "EFF Diceware word list" to "CC-BY", + "JGit" to "EDL-1.0 (BSD-3-Clause)", + "kotlin-result" to "ISC", + "Kotlin, kotlinx" to "Apache-2.0", + "LeakCanary plumber, logcat" to "Apache-2.0", + "Manrope" to "OFL-1.1", + "PGPainless" to "Apache-2.0", + "Public Suffix List" to "MPL-2.0", + "SLF4J" to "MIT", + "sshj" to "Apache-2.0", + "sublime-fuzzy" to "MIT", + "ZXing, zxing-android-embedded" to "Apache-2.0", + ) + +/** `0.3.0 (300)`, followed by the [commit] on the debug builds that have one. */ +internal fun versionLine(name: String, code: Int, commit: String): String = + if (commit.isEmpty()) "$name ($code)" else "$name ($code) · $commit" + +/** The changelog at the tag of [versionName], without a suffix like `-debug`. */ +internal fun changelogUrl(versionName: String): String = + "$REPOSITORY/blob/v${versionName.substringBefore('-')}/CHANGELOG.md" + +/** SHA-256 of [certificate] as `apksigner` and `keytool` users compare it, colon separated. */ +internal fun certificateFingerprint(certificate: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(certificate).joinToString(":") { "%02X".format(it) } + +/** The certificate this copy was really signed with, so it can be compared with the published. */ +private fun Context.signingFingerprint(): String? = + packageManager + .getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + .signingInfo + ?.apkContentsSigners + ?.firstOrNull() + ?.let { certificateFingerprint(it.toByteArray()) } + +/** The about screen. Links leave for the browser: the app itself makes no connection for them. */ +@Composable +fun AboutDestination( + activity: FragmentActivity, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val fingerprint = remember { activity.signingFingerprint() } + AboutScreen( + version = + versionLine(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT), + changelog = changelogUrl(BuildConfig.VERSION_NAME), + fingerprint = fingerprint, + onCopy = { activity.copyTextToClipboard(it, sensitive = false) }, + onOpen = { url -> + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + activity.snackbar(message = activity.getString(R.string.about_no_browser)) + } + }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun AboutScreen( + version: String, + changelog: String, + fingerprint: String?, + onCopy: (String) -> Unit, + onOpen: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(R.string.about), onBack, modifier) { + Column( + Modifier.fillMaxWidth().padding(SpacingMedium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Box(Modifier.size(96.dp).clip(CircleShape)) { + Image(painterResource(R.drawable.ic_launcher_background), contentDescription = null) + Image(painterResource(R.drawable.ic_launcher_foreground), contentDescription = null) + } + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.about_summary), textAlign = TextAlign.Center) + } + AboutRow(stringResource(R.string.about_version), version) { onCopy(version) } + LinkRow(stringResource(R.string.about_docs)) { onOpen(DOCUMENTATION) } + LinkRow(stringResource(R.string.about_changelog)) { onOpen(changelog) } + LinkRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) } + LinkRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") } + // White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi + // asks that the logo itself stays as it is. + Button( + onClick = { onOpen(KO_FI) }, + modifier = Modifier.fillMaxWidth().padding(SpacingMedium), + colors = + ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Image( + painterResource(R.drawable.ic_kofi_symbol), + contentDescription = null, + modifier = Modifier.height(24.dp), + ) + Box(Modifier.width(SpacingMedium)) + Text(stringResource(R.string.about_support)) + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_privacy_title)) }, + supportingContent = { Text(stringResource(R.string.about_privacy)) }, + colors = rowColors(), + ) + if (fingerprint != null) + ListItem( + headlineContent = { Text(stringResource(R.string.about_certificate)) }, + supportingContent = { Text(fingerprint, fontFamily = FontFamily.Monospace) }, + modifier = Modifier.clickable(role = Role.Button) { onCopy(fingerprint) }, + colors = rowColors(), + ) + AboutRow(stringResource(R.string.about_license), stringResource(R.string.about_license_text)) { + onOpen("$REPOSITORY/blob/master/LICENSE.txt") + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_libraries)) }, + colors = rowColors(), + ) + LICENSES.forEach { (name, license) -> + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(license) }, + colors = rowColors(), + ) + } + } +} + +@Composable +private fun AboutRow( + title: String, + summary: String? = null, + trailing: (@Composable () -> Unit)? = null, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { { Text(it) } }, + trailingContent = trailing, + modifier = Modifier.clickable(role = Role.Button, onClick = onClick), + colors = rowColors(), + ) +} + +/** A row that leaves for the browser, and says so. */ +@Composable +private fun LinkRow(title: String, summary: String? = null, onClick: () -> Unit) { + AboutRow( + title = title, + summary = summary, + trailing = { + Icon( + painterResource(R.drawable.ic_open_in_new_24dp), + contentDescription = stringResource(R.string.about_opens_browser), + ) + }, + onClick = onClick, + ) +} 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..ed07c389 --- /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.SnackbarDuration +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 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 = SnackbarDuration.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..10f82178 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -0,0 +1,58 @@ +/* + * 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.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 { + return InetAddresses.isNumericAddress(text) || + (!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..ba4d158c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -0,0 +1,93 @@ +/* + * 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 + +/** Version, links, certificate and licenses. */ +@Serializable data object AboutRoute + +/** 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<AboutRoute> { entry -> + AboutDestination(activity = activity, 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..ed78608f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -0,0 +1,350 @@ +/* + * 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.Constants +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 +internal 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 { Constants.DEFAULT_DECRYPTION_TIMEOUT.toString() }, + ), + 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..4b24375b --- /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.SnackbarDuration +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 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 = SnackbarDuration.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..6004ec90 --- /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.SnackbarDuration +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 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 = SnackbarDuration.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..390f1160 --- /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, sensitive = false) } }, + 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..8f912879 100644 --- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt +++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt @@ -5,7 +5,6 @@ package app.passwordstore.util.auth import android.app.KeyguardManager -import android.os.Build import androidx.annotation.StringRes import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators @@ -23,14 +22,10 @@ object BiometricAuthenticator { /** * Keystore keys that require user authentication are unlocked only by the device credential or a * Class 3 (strong) biometric, so a weak biometric would pass the prompt and then fail to sign or - * decrypt with `User not authenticated`. Android 9 and 10 do not support combining - * [Authenticators.BIOMETRIC_STRONG] with [Authenticators.DEVICE_CREDENTIAL], so they keep the - * weak class. + * decrypt with `User not authenticated`. */ private val VALID_AUTHENTICATORS = - Authenticators.DEVICE_CREDENTIAL or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Authenticators.BIOMETRIC_STRONG - else Authenticators.BIOMETRIC_WEAK + Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_STRONG /** * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted @@ -46,7 +41,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 +95,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/Api26AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt deleted file mode 100644 index f38011a7..00000000 --- a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.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.util.autofill - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillRequest -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import app.passwordstore.ui.autofill.AutofillDecryptActivity -import app.passwordstore.ui.autofill.AutofillFilterView -import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity -import app.passwordstore.ui.autofill.AutofillSaveActivity -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import java.io.File -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class Api26AutofillResponseBuilder private constructor(form: FillableForm) : - AutofillResponseBuilder { - - object Factory : AutofillResponseBuilder.Factory { - override fun create(form: FillableForm): AutofillResponseBuilder = - Api26AutofillResponseBuilder(form) - } - - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - @Suppress("DEPRECATION") - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - build() - } - } - - private fun makeMatchDataset(context: Context, file: File): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makeSearchDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) - } - - private fun makeGenerateDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, emptyList()) - val intentSender = - AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, - publisherChangedException, - fillResponseAfterReset, - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: - // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) { "saveFlags must not be null" } - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { - var datasetCount = 0 - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context)?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setHeader( - makeRemoteView( - context, - makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)), - ) - ) - } - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ - override fun fillCredentials(context: Context, fillRequest: FillRequest, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin) - .fold( - success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) }, - failure = { e -> - logcat(ERROR) { e.asLog() } - callback.onSuccess(makePublisherChangedResponse(context, e)) - }, - ) - } -} diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt index 55679b21..bf41327f 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt @@ -31,7 +31,6 @@ import logcat.asLog import logcat.logcat /** Implements [AutofillResponseBuilder]'s methods for API 30 and above */ -@RequiresApi(Build.VERSION_CODES.R) class Api30AutofillResponseBuilder private constructor(form: FillableForm) : AutofillResponseBuilder { diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt index bfa47d74..a98aefc5 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt @@ -6,7 +6,6 @@ package app.passwordstore.util.autofill import android.content.Context -import android.os.Build import android.os.Bundle import android.service.autofill.Dataset import android.service.autofill.FillCallback @@ -34,19 +33,11 @@ interface AutofillResponseBuilder { action: AutofillAction, ): Dataset { val scenario = AutofillScenario.fromClientState(clientState) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are rarely shown. // FIXME: We should clone the original dataset here and add the credentials to be filled // in. Otherwise, the entry in the cached list of datasets will be overwritten by the // fill-in dataset without any visual representation. This causes it to be missing from // the Autofill suggestions shown after the user clears the filled out form fields. - val builder = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - @Suppress("DEPRECATION") Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) - } - return builder.run { + return Dataset.Builder().run { if (scenario != null) fillWith(scenario, action, credentials) else logcat(LogPriority.ERROR) { "Failed to recover scenario from client state" } build() 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..321c111a 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -10,7 +10,6 @@ import android.content.Context import android.content.Intent import android.content.res.Resources import android.graphics.drawable.Icon -import android.os.Build import android.service.autofill.InlinePresentation import android.view.View import android.widget.RemoteViews @@ -20,7 +19,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) @@ -47,20 +46,14 @@ fun makeInlinePresentation( imeSpec: InlinePresentationSpec, metadata: DatasetMetadata, ): InlinePresentation? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null - if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null val launchIntent = PendingIntent.getActivity( context, 0, - Intent(context, PasswordStore::class.java), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - }, + Intent(context, LaunchActivity::class.java), + PendingIntent.FLAG_MUTABLE, ) val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { @@ -101,8 +94,6 @@ fun makeGenerateAndFillMetadata(context: Context) = R.drawable.ic_autofill_new_password, ) -fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) - fun makeWarningMetadata(context: Context) = DatasetMetadata( context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), diff --git a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt index f4d43e95..e23ec896 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt @@ -7,7 +7,6 @@ package app.passwordstore.util.crypto import android.content.Context import android.content.SharedPreferences -import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException @@ -66,18 +65,14 @@ fun keystoreAesKey( .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .apply(configure) - .apply { - if (useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(true) - } - } + .setIsStrongBoxBacked(useStrongBox) .build() return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { init(spec) generateKey() } } - if (!strongBox || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return generate(false) + if (!strongBox) return generate(false) return try { generate(true) } catch (_: StrongBoxUnavailableException) { @@ -91,14 +86,10 @@ fun keystoreAesKey( */ fun KeyGenParameterSpec.Builder.requireUserAuthentication(seconds: Int) { setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters( - seconds, - KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, - ) - } else { - @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(seconds) - } + setUserAuthenticationParameters( + seconds, + KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, + ) } /** 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..382a5617 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,25 +16,21 @@ 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.compose.material3.SnackbarDuration import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig -import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.Snack +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.crypto.keystorePreferences import app.passwordstore.util.git.operation.GitOperation import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.google.android.material.snackbar.Snackbar import logcat.logcat -/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */ +/** Get an instance of [AutofillManager] */ val Context.autofillManager: AutofillManager? get() = getSystemService() @@ -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] @@ -82,7 +70,7 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab git.commit().setAll(true).setMessage(message), ) - override fun preExecute(): Boolean { + override suspend fun preExecute(): Boolean { logcat { "Committing with message: '$message'" } return true } @@ -90,31 +78,11 @@ 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] - */ +/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */ 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)) -} + length: SnackbarDuration = SnackbarDuration.Short, +): Snack = prompts.snackbar(message, length) /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) @@ -139,12 +107,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/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt index feb2cb2d..6d497bce 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -6,8 +6,10 @@ package app.passwordstore.util.git import android.widget.Toast +import androidx.compose.material3.SnackbarDuration import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.unsafeLazy @@ -17,7 +19,6 @@ import app.passwordstore.util.git.operation.GitOperation import app.passwordstore.util.settings.GitSettings import com.github.michaelbull.result.Result import com.github.michaelbull.result.runCatching -import com.google.android.material.snackbar.Snackbar import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -45,10 +46,10 @@ class GitCommandExecutor( suspend fun execute(): Result<Unit, Throwable> { val gitSettings = hiltEntryPoint.gitSettings() val dispatcherProvider = hiltEntryPoint.dispatcherProvider() - val snackbar = + val running = activity.snackbar( message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, + length = SnackbarDuration.Indefinite, ) // Count the number of uncommitted files var nbChanges = 0 @@ -123,7 +124,7 @@ class GitCommandExecutor( } } } - .also { snackbar.dismiss() } + .also { activity.prompts.dismiss(running) } } @EntryPoint 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..a08f2a35 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 @@ -4,17 +4,17 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.RebaseCommand import org.eclipse.jgit.api.ResetCommand import org.eclipse.jgit.lib.RepositoryState -class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class BreakOutOfDetached(callingActivity: FragmentActivity) : GitOperation(callingActivity) { private val merging = repository.repositoryState == RepositoryState.MERGING private val localBranch = PasswordRepository.getCurrentBranch() @@ -50,17 +50,14 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call } } - override fun preExecute() = + override suspend fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage( - callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) - ) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } - .show() + val resources = callingActivity.resources + callingActivity.prompts.ask( + title = resources.getString(R.string.git_abort_and_push_title), + message = resources.getString(R.string.git_break_out_of_detached_unneeded), + confirm = resources.getString(R.string.dialog_ok), + ) false } else { true diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt index 0386f674..0d74835f 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt @@ -4,7 +4,7 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.GitCommand @@ -14,7 +14,7 @@ import org.eclipse.jgit.api.GitCommand * @param uri URL to clone the repository from * @param callingActivity the calling activity */ -class CloneOperation(callingActivity: AppCompatActivity, uri: String) : +class CloneOperation(callingActivity: FragmentActivity, uri: String) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt index e0ecb170..a0d6d16d 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt @@ -5,24 +5,17 @@ package app.passwordstore.util.git.operation -import android.annotation.SuppressLint import android.content.SharedPreferences -import android.view.LayoutInflater -import android.view.WindowManager import androidx.annotation.StringRes import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.Prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.sshj.InteractivePasswordFinder import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -32,6 +25,7 @@ import kotlin.coroutines.resume class CredentialFinder( private val callingActivity: FragmentActivity, + private val prompts: Prompts, private val authMode: AuthMode, dispatcherProvider: DispatcherProvider, ) : InteractivePasswordFinder(dispatcherProvider) { @@ -71,42 +65,18 @@ class CredentialFinder( if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } val storedCredential = gitOperationPrefs.getString(credentialPref, null) if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = - dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = - dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity) - .run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { putString(credentialPref, credential) } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } - setOnCancelListener { cont.resume(null) } - create() - } - .run { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - show() + val resources = callingActivity.resources + prompts.askCredential( + message = resources.getString(messageRes), + hint = resources.getString(hintRes), + remember = resources.getString(rememberRes), + error = if (isRetry) resources.getString(errorRes) else null, + ) { answer -> + if (answer?.remember == true) { + gitOperationPrefs.edit { putString(credentialPref, answer.text) } } + cont.resume(answer?.text) + } } else { cont.resume(storedCredential) } diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt index c1487fbf..64467f3e 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt @@ -5,7 +5,7 @@ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.internal.storage.file.FileRepository import org.eclipse.jgit.internal.storage.file.GC @@ -14,7 +14,7 @@ import org.eclipse.jgit.internal.storage.file.GC * Run an aggressive garbage collection job on the repository, expiring every loose object to * achieve the best compression. */ -class GcOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class GcOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) { override val requiresAuth: Boolean = false override val commands: Array<GitCommand<out Any>> = 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..f4ed7cd1 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 @@ -5,20 +5,18 @@ package app.passwordstore.util.git.operation import android.widget.Toast -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.prompts.Choice +import app.passwordstore.ui.prompts.prompts +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,9 +25,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 import dagger.hilt.android.EntryPointAccessors @@ -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 @@ -71,8 +63,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { EntryPointAccessors.fromApplication<GitOperationEntryPoint>(callingActivity) protected val repository = PasswordRepository.repository!! protected val git = Git(repository) - private val authActivity - get() = callingActivity as AppCompatActivity private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { @@ -106,12 +96,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( @@ -139,24 +129,22 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { return operationResult } - private fun onMissingSshKeyFile() { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.button_label_import)) { _, _ - -> - getSshKey(false) - } - .setNegativeButton( - callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate) - ) { _, _ -> - getSshKey(true) - } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - } - .show() + private suspend fun onMissingSshKeyFile() { + val resources = callingActivity.resources + val choice = + callingActivity.prompts.ask( + title = resources.getString(R.string.ssh_preferences_dialog_title), + message = resources.getString(R.string.ssh_preferences_dialog_text), + confirm = resources.getString(R.string.button_label_import), + cancel = resources.getString(R.string.dialog_cancel), + other = resources.getString(R.string.ssh_preferences_dialog_generate), + ) + when (choice) { + Choice.Confirm -> getSshKey(false) + Choice.Other -> getSshKey(true) + // Only closes the dialog: the calling activity is the app's own screen now. + Choice.Cancel -> {} + } } suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { @@ -171,21 +159,19 @@ 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) } } } when (result) { is Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + registerAuthProviders(SshAuthMethod.SshKey(callingActivity)) } 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,11 +183,11 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { Toast.LENGTH_LONG, ) .show() - callingActivity.finish() + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } } } else { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + registerAuthProviders(SshAuthMethod.SshKey(callingActivity)) } } else { onMissingSshKeyFile() @@ -214,11 +200,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { HttpsCredentialsProvider( CredentialFinder( callingActivity, + callingActivity.prompts, AuthMode.Password, hiltEntryPoint.dispatcherProvider(), ) ) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) + registerAuthProviders(SshAuthMethod.Password(callingActivity), httpsCredentialProvider) } AuthMode.None -> {} } @@ -226,7 +213,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } /** Called before execution of the Git operation. Return false to cancel. */ - open fun preExecute() = true + open suspend fun preExecute() = true private suspend fun postExecute() { withContext(hiltEntryPoint.dispatcherProvider().io()) { sshSessionFactory?.close() } diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt index 1966c4ab..4738d7ee 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class PullOperation(callingActivity: AppCompatActivity, rebase: Boolean) : +class PullOperation(callingActivity: FragmentActivity, rebase: Boolean) : GitOperation(callingActivity) { /** diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt index e013b401..a998cda5 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class PushOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class PushOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = arrayOf(git.push().setPushAll().setRemote("origin")) diff --git a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt index 7ca1282b..904c62ee 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt @@ -4,12 +4,12 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode.TRACK import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.ResetCommand -class ResetToRemoteOperation(callingActivity: AppCompatActivity, remoteBranch: String) : +class ResetToRemoteOperation(callingActivity: FragmentActivity, remoteBranch: String) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt index 25078e8a..c598b829 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class SyncOperation(callingActivity: AppCompatActivity, rebase: Boolean) : +class SyncOperation(callingActivity: FragmentActivity, rebase: Boolean) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = 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..e2a2c2dd 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 @@ -7,7 +7,6 @@ package app.passwordstore.util.git.sshj import android.content.Context import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.provider.OpenableColumns import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyInfo @@ -16,6 +15,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 +29,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 +48,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 +86,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 @@ -133,9 +156,7 @@ object SshKey { context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) } private val isStrongBoxSupported by unsafeLazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - else false + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) } private enum class Type(val value: String) { @@ -174,23 +195,26 @@ object SshKey { setKeySize(256) setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(isStrongBoxSupported) - } + setIsStrongBoxBacked(isStrongBoxSupported) }, ), } 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 +222,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 +235,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. */ @@ -271,17 +311,7 @@ object SshKey { val parameterSpec = KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { apply(algorithm.applyToSpec) - if (requireAuthentication) { - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters( - 30, - KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, - ) - } else { - @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30) - } - } + if (requireAuthentication) requireUserAuthentication(30) build() } val keyPair = diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 51657b02..631e416f 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -5,7 +5,9 @@ package app.passwordstore.util.git.sshj import android.util.Base64 -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import app.passwordstore.ui.prompts.Prompts +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.operation.CredentialFinder import app.passwordstore.util.settings.AuthMode @@ -42,10 +44,16 @@ import org.eclipse.jgit.transport.SshSessionFactory import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.util.FS -sealed class SshAuthMethod(val activity: AppCompatActivity) { - class Password(activity: AppCompatActivity) : SshAuthMethod(activity) +sealed class SshAuthMethod(val activity: FragmentActivity) { + /** + * The activity's [Prompts], taken while it is alive: the SSH thread reads it later, and asking an + * activity that was finished meanwhile for it would create one nothing draws. + */ + val prompts = activity.prompts + + class Password(activity: FragmentActivity) : SshAuthMethod(activity) - class SshKey(activity: AppCompatActivity) : SshAuthMethod(activity) + class SshKey(activity: FragmentActivity) : SshAuthMethod(activity) } abstract class InteractivePasswordFinder(private val dispatcherProvider: DispatcherProvider) : @@ -156,7 +164,14 @@ private class SshjSession( ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) if (!ssh.isConnected) throw IOException() val passwordAuth = - AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password, dispatcherProvider)) + AuthPassword( + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.Password, + dispatcherProvider, + ) + ) when (authMethod) { is SshAuthMethod.Password -> { ssh.auth(username, passwordAuth) @@ -166,7 +181,12 @@ private class SshjSession( AuthPublickey( SshKey.provide( ssh, - CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider), + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.SshKey, + dispatcherProvider, + ), ) ) ssh.auth(username, pubkeyAuth, passwordAuth) 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/services/ClipboardService.kt b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt index b4bb0438..3347da78 100644 --- a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt +++ b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt @@ -12,7 +12,6 @@ import android.app.PendingIntent import android.app.Service import android.content.ClipData import android.content.Intent -import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService @@ -121,11 +120,7 @@ class ClipboardService : Service() { this, 0, clearIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ) val notification = createNotification(pendingIntent, clearTimeMs) diff --git a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt index beeb4be7..a8b525d5 100644 --- a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt +++ b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt @@ -5,7 +5,6 @@ package app.passwordstore.util.services import android.content.Context -import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback @@ -74,16 +73,12 @@ class OreoAutofillService : AutofillService() { return } if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - callback.onSuccess( - FillResponse.Builder().run { - disableAutofill(DISABLE_AUTOFILL_DURATION_MS) - build() - } - ) - } else { - callback.onSuccess(null) - } + callback.onSuccess( + FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + } + ) return } val formToFill = diff --git a/app/src/main/java/app/passwordstore/util/settings/Constants.kt b/app/src/main/java/app/passwordstore/util/settings/Constants.kt index a0c4b038..2d5760e4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Constants.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Constants.kt @@ -6,5 +6,5 @@ package app.passwordstore.util.settings object Constants { - const val DEFAULT_DECRYPTION_TIMEOUT = 60 + const val DEFAULT_DECRYPTION_TIMEOUT = 45 } 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()) - } -} diff --git a/app/src/main/res/anim/scale_down.xml b/app/src/main/res/anim/scale_down.xml deleted file mode 100644 index cbb4cb97..00000000 --- a/app/src/main/res/anim/scale_down.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - <scale - android:duration="300" - android:fromXScale="1.0" - android:fromYScale="1.0" - android:interpolator="@android:anim/linear_interpolator" - android:pivotX="50%" - android:pivotY="50%" - android:toXScale="0" - android:toYScale="0" /> -</set> diff --git a/app/src/main/res/anim/scale_up.xml b/app/src/main/res/anim/scale_up.xml deleted file mode 100644 index 1e149e38..00000000 --- a/app/src/main/res/anim/scale_up.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - <scale - android:duration="300" - android:fromXScale="0" - android:fromYScale="0" - android:interpolator="@android:anim/linear_interpolator" - android:pivotX="50%" - android:pivotY="50%" - android:toXScale="1.0" - android:toYScale="1.0" /> -</set> diff --git a/app/src/main/res/animator/slide_in_left.xml b/app/src/main/res/animator/slide_in_left.xml deleted file mode 100644 index 355aeefb..00000000 --- a/app/src/main/res/animator/slide_in_left.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="1000" - android:valueTo="0" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_in_right.xml b/app/src/main/res/animator/slide_in_right.xml deleted file mode 100644 index 685bda4d..00000000 --- a/app/src/main/res/animator/slide_in_right.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="-1000" - android:valueTo="0" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_out_left.xml b/app/src/main/res/animator/slide_out_left.xml deleted file mode 100644 index 06c3f076..00000000 --- a/app/src/main/res/animator/slide_out_left.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="0" - android:valueTo="-1000" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/animator/slide_out_right.xml b/app/src/main/res/animator/slide_out_right.xml deleted file mode 100644 index e5c61bdc..00000000 --- a/app/src/main/res/animator/slide_out_right.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<set xmlns:android="http://schemas.android.com/apk/res/android"> - - <objectAnimator - android:duration="250" - android:propertyName="x" - android:valueFrom="0" - android:valueTo="1000" - android:valueType="floatType" /> - -</set> diff --git a/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp Binary files differnew file mode 100644 index 00000000..f31847d8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml deleted file mode 100644 index 39ec0614..00000000 --- a/app/src/main/res/drawable/divider.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<shape xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - <size android:height="1dp" /> - <solid android:color="?attr/colorPrimaryDark" /> -</shape> diff --git a/app/src/main/res/drawable/ic_action_new_folder.xml b/app/src/main/res/drawable/ic_action_new_folder.xml index 6db26eaf..7bccd8a6 100644 --- a/app/src/main/res/drawable/ic_action_new_folder.xml +++ b/app/src/main/res/drawable/ic_action_new_folder.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_action_new_password.xml b/app/src/main/res/drawable/ic_action_new_password.xml index 1b3df410..f204ea59 100644 --- a/app/src/main/res/drawable/ic_action_new_password.xml +++ b/app/src/main/res/drawable/ic_action_new_password.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_action_secure_24dp.xml b/app/src/main/res/drawable/ic_action_secure_24dp.xml index 9eb00ee4..5a62a473 100644 --- a/app/src/main/res/drawable/ic_action_secure_24dp.xml +++ b/app/src/main/res/drawable/ic_action_secure_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_add_48dp.xml b/app/src/main/res/drawable/ic_add_48dp.xml deleted file mode 100644 index 7192e79d..00000000 --- a/app/src/main/res/drawable/ic_add_48dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml deleted file mode 100644 index 234364a9..00000000 --- a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFFFF" - android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml deleted file mode 100644 index cdee1758..00000000 --- a/app/src/main/res/drawable/ic_content_copy.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5L8,5c-1.1,0 -1.99,0.9 -1.99,2L6,21c0,1.1 0.89,2 1.99,2L19,23c1.1,0 2,-0.9 2,-2L21,11l-6,-6zM8,21L8,7h6v5h5v9L8,21z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_delete_24dp.xml b/app/src/main/res/drawable/ic_delete_24dp.xml index 27865f8d..151f808e 100644 --- a/app/src/main/res/drawable/ic_delete_24dp.xml +++ b/app/src/main/res/drawable/ic_delete_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_done_24dp.xml b/app/src/main/res/drawable/ic_done_24dp.xml deleted file mode 100644 index f8b6cef6..00000000 --- a/app/src/main/res/drawable/ic_done_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml index 8bcfb841..b5f43cfd 100644 --- a/app/src/main/res/drawable/ic_edit_24dp.xml +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml deleted file mode 100644 index 6861aa87..00000000 --- a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFFFF" - android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index e83fc4a3..cd894791 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -9,6 +9,6 @@ android:viewportWidth="432" android:viewportHeight="432"> <path - android:fillColor="#00375E" + android:fillColor="#2A2F38" android:pathData="M0,0h432v432h-432z" /> </vector> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 550a5531..04ef7ff9 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,63 +1,18 @@ <?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. + ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz> ~ SPDX-License-Identifier: GPL-3.0-only --> <vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" - android:viewportWidth="432" - android:viewportHeight="432"> - <path android:pathData="M128.18,195.79C126.46,181.89 137.12,169.6 150.91,169.6H281.09C294.91,169.6 305.59,181.95 303.8,195.88L292.58,283.72C291.09,295.32 281.37,304 269.86,304H161.8C150.26,304 140.51,295.27 139.07,283.63L128.18,195.79Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:endX="216" - android:endY="304" - android:startX="216" - android:startY="169.6" - android:type="linear"> - <item - android:color="#FF2AE9DD" - android:offset="0" /> - <item - android:color="#FF0BAFAF" - android:offset="1" /> - </gradient> - </aapt:attr> - </path> - <path android:pathData="M245.33,169.6V160C245.33,144.09 232.2,131.2 216,131.2C199.8,131.2 186.67,144.09 186.67,160V169.6H167.11V160C167.11,133.49 189,112 216,112C243,112 264.89,133.49 264.89,160V169.6H245.33Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:centerX="216" - android:centerY="169.6" - android:gradientRadius="33.6" - android:type="radial"> - <item - android:color="#FF0086D1" - android:offset="0.24" /> - <item - android:color="#FF77E7FF" - android:offset="0.75" /> - </gradient> - </aapt:attr> - </path> + android:viewportWidth="108" + android:viewportHeight="108"> <path - android:fillColor="#77E7FF" - android:pathData="M245.33,169.6h19.56v38.4h-19.56z" /> + android:fillColor="#FF7A45" + android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" /> <path - android:fillColor="#77E7FF" - android:pathData="M245.33,217.6h19.56v48h-19.56z" /> - <path - android:fillColor="#77E7FF" - android:pathData="M239.11,205.19l13.83,13.58l-34.57,33.94l-13.83,-13.58z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M235.56,208a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M235.56,275.2a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M204.54,252.71m-13.83,-13.58a19.56,19.2 0,1 1,27.66 27.15a19.56,19.2 0,1 1,-27.66 -27.15" /> + android:fillColor="#B8C4CF" + android:fillType="evenOdd" + android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" /> </vector> diff --git a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml index 74af9a81..ce263547 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -1,14 +1,18 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz> ~ SPDX-License-Identifier: GPL-3.0-only --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" - android:viewportWidth="108" - android:viewportHeight="108" +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" - android:height="108dp"> + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#FFFFFF" + android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" /> <path - android:pathData="M44.556 46.3997L44.556 44.5001C44.556 39.2533 48.7844 35 54.0005 35C59.2165 35 63.4449 39.2533 63.4449 44.5001L63.4444 46.4001L66.5738 46.3997C69.2445 46.3997 71.3066 48.8438 70.9624 51.6012L68.7933 68.9857C68.5068 71.2819 66.6283 73 64.4045 73L43.5297 73C41.2994 73 39.4177 71.272 39.1389 68.9681L37.0354 51.5836C36.7024 48.8318 38.7625 46.3997 41.4263 46.3997L44.556 46.3997ZM59.6671 44.5001C59.6671 41.3521 57.1301 38.8 54.0005 38.8C50.8708 38.8 48.3338 41.3521 48.3338 44.5001L48.3338 46.3997L59.6671 46.4001L59.6667 50.7076C58.5375 51.3647 57.7778 52.5928 57.7778 53.9993C57.7778 54.0436 57.7785 54.0877 57.78 54.1316L52.765 59.1762C51.5047 58.8377 50.1043 59.1658 49.1156 60.1603C47.6403 61.6443 47.6403 64.0504 49.1156 65.5344C50.5909 67.0184 52.9829 67.0184 54.4582 65.5344C55.447 64.5397 55.7731 63.1309 55.4364 61.8631L59.6667 57.6079L59.6667 64.0078C58.5375 64.6648 57.7778 65.8929 57.7778 67.2995C57.7778 69.3982 59.4691 71.0995 61.5556 71.0995C63.642 71.0995 65.3333 69.3982 65.3333 67.2995C65.3333 65.8929 64.5736 64.6648 63.4444 64.0078L63.4444 57.291C64.5736 56.6339 65.3333 55.4059 65.3333 53.9993C65.3333 52.5928 64.5736 51.3647 63.4444 50.7076L63.4444 46.4001L59.6671 46.4001L59.6671 44.5001Z" - android:fillColor="#FFFFFF" /> + android:fillColor="#FFFFFF" + android:fillType="evenOdd" + android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" /> </vector> diff --git a/app/src/main/res/drawable/ic_move_folder_24dp.xml b/app/src/main/res/drawable/ic_move_folder_24dp.xml index 3df87291..e12ddaee 100644 --- a/app/src/main/res/drawable/ic_move_folder_24dp.xml +++ b/app/src/main/res/drawable/ic_move_folder_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_open_in_new_24dp.xml b/app/src/main/res/drawable/ic_open_in_new_24dp.xml new file mode 100644 index 00000000..b82ebe62 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new_24dp.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:autoMirrored="true" + android:tint="?android:attr/colorControlNormal" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/white" + android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" /> +</vector> diff --git a/app/src/main/res/drawable/ic_push_pin_24dp.xml b/app/src/main/res/drawable/ic_push_pin_24dp.xml index c542d784..41f61e0a 100644 --- a/app/src/main/res/drawable/ic_push_pin_24dp.xml +++ b/app/src/main/res/drawable/ic_push_pin_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_round_import_export.xml b/app/src/main/res/drawable/ic_round_import_export.xml index 2f243b22..7ab2aa1c 100644 --- a/app/src/main/res/drawable/ic_round_import_export.xml +++ b/app/src/main/res/drawable/ic_round_import_export.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml deleted file mode 100644 index 8a4276c4..00000000 --- a/app/src/main/res/drawable/ic_save_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_save_copy_24dp.xml b/app/src/main/res/drawable/ic_save_copy_24dp.xml deleted file mode 100644 index 06419aac..00000000 --- a/app/src/main/res/drawable/ic_save_copy_24dp.xml +++ /dev/null @@ -1,15 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:tint="?attr/colorControlNormal" - android:viewportWidth="24" - android:viewportHeight="24"> - <path - android:fillColor="#FFFFFF" - android:pathData="M19,5L7,5A2,2 0,0 0,5 7L5,21a2,2 0,0 0,2 2L21,23a2,2 0,0 0,2 -2L23,9ZM21,21L7,21L7,7L18.17,7L21,9.83ZM14,14a3,3 0,1 0,3 3A3,3 0,0 0,14 14ZM8,8h9v4L8,12ZM15,1L3,1A2,2 0,0 0,1 3L1,17L3,17L3,3L15,3Z" /> -</vector> diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml index bb454f5b..666883b6 100644 --- a/app/src/main/res/drawable/ic_search_24dp.xml +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml index 2295122c..83d28c8d 100644 --- a/app/src/main/res/drawable/ic_share_24dp.xml +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/password_row_background.xml b/app/src/main/res/drawable/password_row_background.xml deleted file mode 100644 index 0f681cd8..00000000 --- a/app/src/main/res/drawable/password_row_background.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<!-- -Requires a layer-list since attributes cannot be resolved in selectors, see: -https://stackoverflow.com/a/36424426/297261 - --> -<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> - <item> - <selector> - <item - android:drawable="@color/list_multiselect_background" - android:state_selected="true" /> - <item android:drawable="@android:color/transparent" /> - </selector> - </item> - <item android:drawable="?android:attr/selectableItemBackground" /> -</layer-list> diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml deleted file mode 100644 index b2f699b1..00000000 --- a/app/src/main/res/layout/activity_git_clone.xml +++ /dev/null @@ -1,94 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/windowBackground" - android:padding="@dimen/activity_horizontal_margin" - tools:context="app.passwordstore.ui.git.config.GitServerConfigActivity"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/label_server_url" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/server_url" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/server_url" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textWebEmailAddress" - android:nextFocusForward="@id/auth_mode_group" /> - - </com.google.android.material.textfield.TextInputLayout> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label_auth_mode" - style="@style/TextAppearance.MaterialComponents.Headline6" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:layout_marginBottom="16dp" - android:text="@string/connection_mode" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_server_url" /> - - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/auth_mode_group" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/label_auth_mode" - app:singleSelection="true"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/auth_mode_ssh_key" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/connection_mode_ssh_key" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/auth_mode_password" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/connection_mode_basic_authentication" /> - - </com.google.android.material.button.MaterialButtonToggleGroup> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/auth_mode_group" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clear_host_key_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/clear_saved_host_key" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/auth_mode_group" - tools:visibility="visible" /> - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/activity_git_config.xml b/app/src/main/res/layout/activity_git_config.xml deleted file mode 100644 index b43ec6d6..00000000 --- a/app/src/main/res/layout/activity_git_config.xml +++ /dev/null @@ -1,113 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:padding="@dimen/activity_horizontal_margin" - tools:context="app.passwordstore.ui.git.config.GitConfigActivity" - tools:layout_editor_absoluteX="0dp" - tools:layout_editor_absoluteY="81dp"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/username_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/git_user_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:layout_editor_absoluteY="64dp"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_user_name" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPersonName" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/email_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:hint="@string/git_user_email" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/username_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_user_email" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textEmailAddress" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/email_input_layout" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_tools_title" - style="@style/TextAppearance.MaterialComponents.Headline5" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/git_tools" - android:textSize="24sp" - android:textStyle="bold" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/save_button" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_head_status" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/git_tools_title" - tools:text="HEAD status" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_log" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/git_log" - app:layout_constraintTop_toBottomOf="@+id/git_head_status" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_abort_rebase" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/abort_rebase" - app:layout_constraintTop_toBottomOf="@+id/git_log" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_reset_to_remote" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:text="@string/reset_to_remote" - app:layout_constraintTop_toBottomOf="@id/git_abort_rebase" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/git_gc" - android:layout_marginTop="8dp" - android:text="@string/git_run_gc_job" - app:layout_constraintTop_toBottomOf="@id/git_reset_to_remote" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_git_log.xml b/app/src/main/res/layout/activity_git_log.xml deleted file mode 100644 index e9884db0..00000000 --- a/app/src/main/res/layout/activity_git_log.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/git_log_recycler_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:scrollbars="vertical" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:context="app.passwordstore.ui.git.log.GitLogActivity" - tools:listitem="@layout/git_log_row_layout" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml deleted file mode 100644 index d1153f5d..00000000 --- a/app/src/main/res/layout/activity_onboarding.xml +++ /dev/null @@ -1,11 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/fragment_first_run" - android:name="app.passwordstore.ui.onboarding.fragments.WelcomeFragment" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:tag="welcome_fragment" /> diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml deleted file mode 100644 index 0641f5c6..00000000 --- a/app/src/main/res/layout/activity_oreo_autofill_filter.xml +++ /dev/null @@ -1,132 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:elevation="2dp" - tools:context="app.passwordstore.ui.autofill.AutofillFilterView"> - - <ImageView - android:id="@+id/cover" - android:layout_width="0dp" - android:layout_height="60dp" - android:background="@android:color/transparent" - android:contentDescription="@string/app_name" - android:src="@drawable/ic_launcher_foreground" - app:layout_constraintBottom_toTopOf="@id/origin" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.0" /> - - <TextView - android:id="@+id/origin" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:singleLine="false" - android:textAlignment="center" - app:layout_constraintBottom_toTopOf="@id/searchLayout" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/cover" - app:layout_constraintVertical_bias="0.0" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/searchLayout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - app:endIconMode="clear_text" - app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/origin"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/search" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/action_search" - android:imeOptions="actionDone" - android:inputType="text" - tools:text="example.com" /> - </com.google.android.material.textfield.TextInputLayout> - - <ViewSwitcher - android:id="@+id/rvPasswordSwitcher" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginTop="@dimen/activity_vertical_margin" - app:layout_constraintBottom_toTopOf="@id/strictDomainSearch" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/searchLayout"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/rvPassword" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="vertical" - tools:itemCount="5" - tools:listitem="@layout/oreo_autofill_filter_row" /> - - <TextView - android:id="@+id/rvPasswordEmpty" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_filter_no_results" - android:textSize="18sp" /> - - </ViewSwitcher> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/strictDomainSearch" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_strict_domain_search" - app:layout_constraintBottom_toTopOf="@id/shouldMatch" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher" - app:layout_constraintVertical_bias="1.0" - tools:text="Phishing-resistant search" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/shouldMatch" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:checked="true" - app:layout_constraintBottom_toTopOf="@id/shouldClear" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/strictDomainSearch" - app:layout_constraintVertical_bias="1.0" - tools:text="Match with example.org" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/shouldClear" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_matches_clear_existing" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/shouldMatch" - app:layout_constraintVertical_bias="1.0" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml b/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml deleted file mode 100644 index 89d64fe4..00000000 --- a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml +++ /dev/null @@ -1,174 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:animateLayoutChanges="true" - android:elevation="2dp" - android:paddingBottom="16dp" - android:scrollbars="vertical" - tools:context="app.passwordstore.ui.autofill.AutofillPublisherChangedActivity"> - - <ImageView - android:id="@+id/cover" - android:layout_width="60dp" - android:layout_height="60dp" - android:background="@android:color/transparent" - android:contentDescription="@string/app_name" - android:src="@drawable/ic_launcher_foreground" - app:layout_constraintBottom_toTopOf="@id/warningSign" - app:layout_constraintEnd_toStartOf="@id/app_name" - app:layout_constraintHorizontal_chainStyle="packed" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <TextView - android:id="@+id/app_name" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/app_name" - android:textSize="18sp" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/cover" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/cover" - app:layout_constraintTop_toTopOf="@id/cover" /> - - <ImageView - android:id="@+id/warningSign" - android:layout_width="40dp" - android:layout_height="40dp" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:contentDescription="@string/oreo_autofill_warning_publisher_warning_sign_description" - android:src="@drawable/ic_warning_red_24dp" - app:layout_constraintBottom_toBottomOf="@id/warningHeader" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="@id/warningHeader" /> - - <TextView - android:id="@+id/warningHeader" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_warning_publisher_header" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/warningSign" - app:layout_constraintTop_toBottomOf="@id/cover" /> - - <TextView - android:id="@+id/warningAppName" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:gravity="center_horizontal" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/warningAppInstallDate" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningHeader" - tools:text="Example Banking" /> - - <TextView - android:id="@+id/warningAppInstallDate" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:gravity="center_horizontal" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/warningAppFooter" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppName" - tools:text="Installed: 5 days ago" /> - - <TextView - android:id="@+id/warningAppFooter" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/oreo_autofill_warning_publisher_footer" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppInstallDate" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/okButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_changed_disable_autofill_button" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/warningAppFooter" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/advancedButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_advanced_info_button" - android:textSize="12sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/okButton" - tools:visibility="visible" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/resetButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:minWidth="240dp" - android:text="@string/oreo_autofill_warning_publisher_reenable_button" - android:textSize="12sp" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/advancedButton" - tools:visibility="visible" /> - - <TextView - android:id="@+id/warningAppAdvancedInfo" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="16dp" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:fontFamily="@font/jetbrainsmono_nl_regular" - android:gravity="center_horizontal" - android:textIsSelectable="true" - android:textSize="10sp" - android:visibility="gone" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/resetButton" - tools:ignore="SmallSp" - tools:text="Package: com.example.banking\n\nHash:\n8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M=" - tools:visibility="visible" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_preference_recyclerview.xml b/app/src/main/res/layout/activity_preference_recyclerview.xml deleted file mode 100644 index 805bc79a..00000000 --- a/app/src/main/res/layout/activity_preference_recyclerview.xml +++ /dev/null @@ -1,18 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/preference_recycler_view" - android:layout_width="match_parent" - android:layout_height="match_parent" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_proxy_selector.xml b/app/src/main/res/layout/activity_proxy_selector.xml deleted file mode 100644 index b83ca9f7..00000000 --- a/app/src/main/res/layout/activity_proxy_selector.xml +++ /dev/null @@ -1,106 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_host_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/proxy_hostname" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:layout_editor_absoluteY="64dp"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_host" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textUri" - android:nextFocusForward="@id/proxy_user" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_user_input_layout" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/normal_margin" - android:hint="@string/username" - app:layout_constraintEnd_toStartOf="@id/proxy_port_input_layout" - app:layout_constraintHorizontal_weight="0.65" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_user" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textWebEmailAddress" - android:nextFocusForward="@id/proxy_port" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_port_input_layout" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/normal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/port" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_weight="0.35" - app:layout_constraintStart_toEndOf="@id/proxy_user_input_layout" - app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_port" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="number" - android:nextFocusForward="@id/proxy_password" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/proxy_password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:hint="@string/password" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="@id/proxy_user_input_layout" - app:layout_constraintTop_toBottomOf="@id/proxy_user_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/proxy_password" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPassword" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/save" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/normal_margin" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/crypto_save" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/proxy_password_input_layout" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/activity_pwdstore.xml b/app/src/main/res/layout/activity_pwdstore.xml deleted file mode 100644 index 295efd9c..00000000 --- a/app/src/main/res/layout/activity_pwdstore.xml +++ /dev/null @@ -1,19 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - tools:context=".PasswordStore"> - - <LinearLayout - android:id="@+id/main_layout" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" /> - -</LinearLayout> diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml deleted file mode 100644 index 49548b36..00000000 --- a/app/src/main/res/layout/activity_ssh_keygen.xml +++ /dev/null @@ -1,69 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="@dimen/activity_horizontal_margin" - android:paddingTop="@dimen/activity_vertical_margin" - android:paddingRight="@dimen/activity_horizontal_margin"> - - <com.google.android.material.button.MaterialButtonToggleGroup - android:id="@+id/key_type_group" - style="@style/TextAppearance.MaterialComponents.Headline1" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - app:selectionRequired="true" - app:singleSelection="true"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_rsa" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_rsa" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_ecdsa" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_ecdsa" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/key_type_ed25519" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_label_ed25519" /> - </com.google.android.material.button.MaterialButtonToggleGroup> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/key_type_explanation" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="8dp" /> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/key_require_authentication" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/ssh_keygen_require_authentication" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/generate" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_gravity="end" - android:layout_marginTop="8dp" - android:text="@string/ssh_keygen_generate" /> - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/basic_bottom_sheet.xml b/app/src/main/res/layout/basic_bottom_sheet.xml deleted file mode 100644 index b45fc9c9..00000000 --- a/app/src/main/res/layout/basic_bottom_sheet.xml +++ /dev/null @@ -1,62 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:paddingBottom="24dp"> - - <com.google.android.material.textview.MaterialTextView - android:id="@+id/bottom_sheet_title" - style="?attr/textAppearanceTitleLarge" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Bottom sheet title" /> - - <com.google.android.material.textview.MaterialTextView - android:id="@+id/bottom_sheet_message" - style="?attr/textAppearanceBodyLarge" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="8dp" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_title" - tools:text="A long body of text that serves as the bottom sheet message" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/bottom_sheet_cancel_button" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:layout_marginEnd="8dp" - android:text="@string/dialog_cancel" - android:visibility="gone" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message" - tools:visibility="visible" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/bottom_sheet_ok_button" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:layout_marginEnd="8dp" - android:text="@string/dialog_ok" - android:visibility="gone" - app:layout_constraintEnd_toStartOf="@id/bottom_sheet_cancel_button" - app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message" - tools:visibility="visible" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml deleted file mode 100644 index 88c0fb1a..00000000 --- a/app/src/main/res/layout/decrypt_layout.xml +++ /dev/null @@ -1,65 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:paddingTop="16dp" - tools:context="app.passwordstore.ui.crypto.DecryptActivity"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_category" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_marginStart="32dp" - android:layout_marginEnd="16dp" - android:textIsSelectable="false" - android:textSize="18sp" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="CATEGORY HERE" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_file" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="32dp" - android:layout_marginEnd="16dp" - android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium" - android:textColor="?attr/colorSecondary" - android:textSize="24sp" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_category" - tools:text="PASSWORD FILE NAME HERE" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/divider" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="16dp" - android:src="@drawable/divider" - app:layout_constraintTop_toBottomOf="@id/password_file" - tools:ignore="ContentDescription" /> - - <androidx.recyclerview.widget.RecyclerView - android:id="@+id/recycler_view" - android:layout_width="match_parent" - android:layout_height="0dp" - android:paddingHorizontal="16dp" - app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/divider" - tools:listitem="@layout/item_field" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/dialog_password_entry.xml b/app/src/main/res/layout/dialog_password_entry.xml deleted file mode 100644 index bbfbc506..00000000 --- a/app/src/main/res/layout/dialog_password_entry.xml +++ /dev/null @@ -1,35 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_field" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/password" - app:endIconMode="password_toggle" - app:hintAnimationEnabled="true" - app:hintEnabled="true"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_edit_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textPassword" /> - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/auto_clear_cache" - android:layout_width="match_parent" - android:text="@string/clear_cached_password_on_screen_off" - android:layout_height="wrap_content" /> -</LinearLayout> diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_text_input.xml deleted file mode 100644 index c8539273..00000000 --- a/app/src/main/res/layout/dialog_text_input.xml +++ /dev/null @@ -1,25 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/text_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/edit_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> - - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/app/src/main/res/layout/folder_dialog_fragment.xml b/app/src/main/res/layout/folder_dialog_fragment.xml deleted file mode 100644 index 7860c9b4..00000000 --- a/app/src/main/res/layout/folder_dialog_fragment.xml +++ /dev/null @@ -1,38 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" - android:padding="16dp"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/folder_name_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/folder_name_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textNoSuggestions|textVisiblePassword" /> - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <!-- TODO(msfjarvis): Restore this functionality --> - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/set_gpg_key" - android:layout_width="0dp" - android:visibility="gone" - android:layout_height="wrap_content" - android:text="@string/new_folder_set_gpg_key" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/folder_name_container" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_clone.xml b/app/src/main/res/layout/fragment_clone.xml deleted file mode 100644 index 9cb34761..00000000 --- a/app/src/main/res/layout/fragment_clone.xml +++ /dev/null @@ -1,96 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/colorBackground"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="64dp" - android:layout_height="64dp" - android:layout_marginStart="32dp" - android:layout_marginTop="@dimen/onboarding_icon_margin_top" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginStart="@dimen/fab_compat_margin" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/app_icon" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/app_icon" - app:layout_constraintTop_toTopOf="@+id/app_icon" /> - - <TextView - android:id="@+id/repo_type" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="48dp" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_n_repository_type" - android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0" - app:layout_constraintStart_toStartOf="@id/app_icon" - app:layout_constraintTop_toBottomOf="@id/app_icon" /> - - <TextView - android:id="@+id/tv_repo_type_text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/onboarding_desc_margin_top" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_repo_type_text" - android:textAppearance="?attr/textAppearanceTitleSmall" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@id/repo_type" - app:layout_constraintTop_toBottomOf="@id/repo_type" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/clone_remote" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="@dimen/onboarding_button_margin_top" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/clone_remote_repo" - app:layout_constraintBottom_toTopOf="@id/create_local" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/tv_repo_type_text" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_local" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginBottom="128dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/create_local_repo" - app:layout_constraintEnd_toEndOf="@id/clone_remote" - app:layout_constraintStart_toStartOf="@id/clone_remote" - app:layout_constraintTop_toBottomOf="@id/clone_remote" /> - - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/fragment_key_selection.xml b/app/src/main/res/layout/fragment_key_selection.xml deleted file mode 100644 index b9141960..00000000 --- a/app/src/main/res/layout/fragment_key_selection.xml +++ /dev/null @@ -1,80 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/colorBackground" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="64dp" - android:layout_height="64dp" - android:layout_marginStart="32dp" - android:layout_marginTop="@dimen/onboarding_icon_margin_top" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginStart="@dimen/fab_compat_margin" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - app:layout_constraintBottom_toBottomOf="@id/app_icon" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toEndOf="@id/app_icon" - app:layout_constraintTop_toTopOf="@+id/app_icon" /> - - <TextView - android:id="@+id/gpg_key" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="48dp" - android:layout_marginEnd="@dimen/fab_compat_margin" - android:text="@string/select_gpg_key_title" - android:textAppearance="?attr/textAppearanceHeadlineSmall" - android:textStyle="bold" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0" - app:layout_constraintStart_toStartOf="@id/app_icon" - app:layout_constraintTop_toBottomOf="@id/app_icon" /> - - <TextView - android:id="@+id/gpg_key_text" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/onboarding_desc_margin_top" - android:layout_marginEnd="16dp" - android:text="@string/select_gpg_key_message" - android:textAppearance="?attr/textAppearanceTitleSmall" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="@id/gpg_key" - app:layout_constraintTop_toBottomOf="@id/gpg_key" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/select_key" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="48dp" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/gpg_key_select" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/gpg_key_text" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_manual_otp_entry.xml b/app/src/main/res/layout/fragment_manual_otp_entry.xml deleted file mode 100644 index 7d7ea832..00000000 --- a/app/src/main/res/layout/fragment_manual_otp_entry.xml +++ /dev/null @@ -1,53 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/secret_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/otp_import_manual_hint_secret" - android:paddingStart="16dp" - android:paddingTop="16dp" - android:paddingEnd="16dp" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/secret" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:inputType="textPassword" /> - - <requestFocus /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/account_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/otp_import_manual_hint_account" - android:paddingStart="16dp" - android:paddingTop="16dp" - android:paddingEnd="16dp" - android:paddingBottom="16dp" - app:hintEnabled="true" - app:layout_constraintTop_toBottomOf="@id/secret_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/account" - android:layout_width="match_parent" - android:layout_height="match_parent" /> - - </com.google.android.material.textfield.TextInputLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_pwgen.xml b/app/src/main/res/layout/fragment_pwgen.xml deleted file mode 100644 index 02eadff5..00000000 --- a/app/src/main/res/layout/fragment_pwgen.xml +++ /dev/null @@ -1,115 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp" - tools:context=".MainActivityFragment"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/passwordText" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:textAppearance="?android:attr/textAppearanceMedium" - android:textIsSelectable="true" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:baselineAligned="false" - android:orientation="horizontal" - android:weightSum="2"> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_weight="1" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:text="@string/pwgen_include" - android:textAppearance="?android:attr/textAppearanceSmall" /> - - <CheckBox - android:id="@+id/numerals" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_numerals" /> - - - <CheckBox - android:id="@+id/symbols" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_symbols" /> - - <CheckBox - android:id="@+id/uppercase" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_uppercase" /> - - <CheckBox - android:id="@+id/lowercase" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_lowercase" /> - - </LinearLayout> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_weight="1" - android:orientation="vertical"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/length" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:labelFor="@id/lengthNumber" - android:text="@string/pwgen_length" - android:textAppearance="?android:attr/textAppearanceSmall" /> - - <EditText - android:id="@+id/lengthNumber" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:autofillHints="notApplicable" - android:ems="10" - android:inputType="number" /> - - <CheckBox - android:id="@+id/pronounceable" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_pronounceable" /> - - <CheckBox - android:id="@+id/ambiguous" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/pwgen_ambiguous" /> - - </LinearLayout> - </LinearLayout> - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/fragment_pwgen_diceware.xml b/app/src/main/res/layout/fragment_pwgen_diceware.xml deleted file mode 100644 index b8627670..00000000 --- a/app/src/main/res/layout/fragment_pwgen_diceware.xml +++ /dev/null @@ -1,73 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/password_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:textAppearance="?android:attr/textAppearanceMedium" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_length" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:layout_marginEnd="4dp" - android:hint="@string/pwgen_length" - android:labelFor="@id/password_length_text" - app:hintAnimationEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toStartOf="@id/password_separator" - app:layout_constraintHorizontal_weight="0.5" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_text"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_length_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="number" - android:maxLength="2" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_separator" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginTop="8dp" - android:hint="@string/pwgen_separator" - android:labelFor="@id/password_separator_text" - app:hintAnimationEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_weight="0.5" - app:layout_constraintStart_toEndOf="@id/password_length" - app:layout_constraintTop_toBottomOf="@id/password_text"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password_separator_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:autofillHints="" - android:importantForAutofill="no" - android:inputType="text" - android:maxLength="1" /> - </com.google.android.material.textfield.TextInputLayout> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/fragment_welcome.xml b/app/src/main/res/layout/fragment_welcome.xml deleted file mode 100644 index c68cd56a..00000000 --- a/app/src/main/res/layout/fragment_welcome.xml +++ /dev/null @@ -1,67 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/colorBackground" - android:orientation="vertical"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/settings_button" - style="?attr/borderlessButtonStyle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:layout_alignParentEnd="true" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:text="@string/action_settings" - android:textAllCaps="true" - android:textColor="?attr/colorOnBackground" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/app_icon" - android:layout_width="100dp" - android:layout_height="100dp" - android:contentDescription="@string/app_icon_hint" - android:src="@mipmap/ic_launcher" - android:transitionName="transition_first_app_icon" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/app_name" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_centerHorizontal="true" - android:layout_marginTop="16dp" - android:text="@string/app_name" - android:textAppearance="?attr/textAppearanceTitleLarge" - android:textStyle="bold" - android:transitionName="transition_first_run_app_name" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/app_icon" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/lets_go" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginTop="48dp" - android:layout_marginEnd="16dp" - android:maxWidth="300dp" - android:minWidth="100dp" - android:text="@string/let_s_go" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/app_name" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml deleted file mode 100644 index 240c2d7a..00000000 --- a/app/src/main/res/layout/git_credential_layout.xml +++ /dev/null @@ -1,42 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:padding="16dp"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/git_auth_passphrase_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:endIconMode="password_toggle" - app:errorEnabled="true" - app:hintEnabled="true" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_auth_credential" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/ssh_keygen_passphrase" - android:inputType="textPassword" /> - - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/git_auth_remember_credential" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/git_operation_remember_passphrase" - app:checkedState="checked" - app:layout_constraintRight_toRightOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/git_auth_passphrase_layout" /> -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/git_log_row_layout.xml b/app/src/main/res/layout/git_log_row_layout.xml deleted file mode 100644 index e996f6af..00000000 --- a/app/src/main/res/layout/git_log_row_layout.xml +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/activity_vertical_margin" - android:paddingVertical="@dimen/activity_horizontal_margin"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_message" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/normal_margin" - android:ellipsize="end" - android:maxLines="2" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/git_log_row_hash" - tools:text="Commit message" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_hash" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="?attr/colorSecondary" - android:textStyle="bold" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Hash" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/git_log_row_time" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:alpha="0.5" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="Time" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/item_create_sheet.xml b/app/src/main/res/layout/item_create_sheet.xml deleted file mode 100644 index 10882a00..00000000 --- a/app/src/main/res/layout/item_create_sheet.xml +++ /dev/null @@ -1,42 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_folder" - style="@style/Widget.MaterialComponents.Button.TextButton" - android:layout_width="match_parent" - android:layout_height="@dimen/bottom_sheet_item_height" - android:layout_margin="@dimen/normal_margin" - android:text="@string/bottom_sheet_create_new_folder" - android:textAlignment="viewStart" - android:textColor="?attr/colorOnSurface" - app:icon="@drawable/ic_action_new_folder" - app:iconPadding="@dimen/normal_margin" - app:iconTint="?attr/colorSecondary" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:rippleColor="?attr/colorSecondary" /> - - <com.google.android.material.button.MaterialButton - android:id="@+id/create_password" - style="@style/Widget.MaterialComponents.Button.TextButton" - android:layout_width="match_parent" - android:layout_height="@dimen/bottom_sheet_item_height" - android:layout_margin="@dimen/normal_margin" - android:text="@string/bottom_sheet_create_new_password" - android:textAlignment="viewStart" - android:textColor="?attr/colorOnSurface" - app:icon="@drawable/ic_action_new_password" - app:iconPadding="@dimen/normal_margin" - app:iconTint="?attr/colorSecondary" - app:layout_constraintTop_toBottomOf="@id/create_folder" - app:rippleColor="?attr/colorSecondary" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/item_field.xml b/app/src/main/res/layout/item_field.xml deleted file mode 100644 index 79697a56..00000000 --- a/app/src/main/res/layout/item_field.xml +++ /dev/null @@ -1,30 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/item_text_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - tools:hint="@string/password" - tools:visibility="visible"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/item_text" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:editable="false" - android:textIsSelectable="true" - tools:text="p@55w0rd!" /> - - </com.google.android.material.textfield.TextInputLayout> - -</LinearLayout> diff --git a/app/src/main/res/layout/map_preference_widget_switch.xml b/app/src/main/res/layout/map_preference_widget_switch.xml deleted file mode 100644 index 566e72d4..00000000 --- a/app/src/main/res/layout/map_preference_widget_switch.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@android:id/checkbox" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:background="@null" - android:clickable="false" - android:focusable="false" /> diff --git a/app/src/main/res/layout/oreo_autofill_filter_row.xml b/app/src/main/res/layout/oreo_autofill_filter_row.xml deleted file mode 100644 index b4284966..00000000 --- a/app/src/main/res/layout/oreo_autofill_filter_row.xml +++ /dev/null @@ -1,69 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/password_row_background" - android:paddingTop="8dp" - android:paddingBottom="8dp"> - - <ImageView - android:id="@+id/credentialIcon" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginStart="8dp" - android:alpha="0.5" - android:src="@drawable/ic_person_black_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/title" - app:layout_constraintHorizontal_bias="0.0" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:tint="?attr/colorControlNormal" /> - - <TextView - android:id="@+id/title" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:ellipsize="middle" - android:singleLine="true" - android:textSize="12sp" - app:layout_constraintBottom_toTopOf="@id/subtitle" - app:layout_constraintEnd_toStartOf="@id/continueIcon" - app:layout_constraintStart_toEndOf="@id/credentialIcon" - app:layout_constraintTop_toTopOf="parent" - tools:text="/example.com/" /> - - <TextView - android:id="@+id/subtitle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:ellipsize="middle" - android:singleLine="true" - android:textSize="14sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@+id/continueIcon" - app:layout_constraintStart_toEndOf="@id/credentialIcon" - app:layout_constraintTop_toBottomOf="@id/title" - tools:text="johndoe@example.com" /> - - <ImageView - android:id="@+id/continueIcon" - android:layout_width="wrap_content" - android:layout_height="0dp" - android:layout_marginEnd="8dp" - android:src="@drawable/ic_keyboard_arrow_right_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="1.0" - app:layout_constraintStart_toEndOf="@id/title" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/oreo_autofill_instructions.xml b/app/src/main/res/layout/oreo_autofill_instructions.xml deleted file mode 100644 index 66881390..00000000 --- a/app/src/main/res/layout/oreo_autofill_instructions.xml +++ /dev/null @@ -1,51 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <LinearLayout - android:layout_width="fill_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingLeft="24dp" - android:paddingTop="20dp" - android:paddingRight="24dp" - android:paddingBottom="20dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/intro_text" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/oreo_autofill_enable_dialog_description" - android:textSize="16sp" /> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_enable_dialog_instructions" - android:textSize="16sp" /> - - <androidx.appcompat.widget.AppCompatTextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/activity_vertical_margin" - android:text="@string/oreo_autofill_enable_dialog_installed_browsers" - android:textSize="16sp" - android:textStyle="bold" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/supportedBrowsers" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="16sp" - tools:text="Firefox: Fill and save credentials" /> - - - </LinearLayout> -</ScrollView> diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml deleted file mode 100644 index c0692753..00000000 --- a/app/src/main/res/layout/password_creation_activity.xml +++ /dev/null @@ -1,147 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - - -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:fillViewport="false" - tools:context="app.passwordstore.ui.crypto.PasswordCreationActivity"> - - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:padding="@dimen/activity_horizontal_margin"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/directory_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:enabled="false" - android:hint="@string/directory_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/directory" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" - tools:text="CATEGORY HERE" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/name_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="center_vertical" - android:layout_margin="8dp" - android:hint="@string/crypto_name_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/filename" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/username" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:visibility="gone" - tools:visibility="visible" - android:id="@+id/username_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_username_hint" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/name_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionNext" - android:inputType="textNoSuggestions" - android:nextFocusForward="@id/password" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/password_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_pass_label" - app:endIconMode="password_toggle" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/username_input_layout"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/password" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:imeOptions="actionDone" - android:inputType="textVisiblePassword" /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/generate_password" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/pwd_generate_button" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/password_input_layout" /> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/extra_input_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:hint="@string/crypto_extra_label" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/generate_password"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/extra_content" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:inputType="textMultiLine|textVisiblePassword" /> - - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.button.MaterialButton - android:id="@+id/otp_import_button" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/add_otp" - app:icon="@drawable/ic_qr_code_scanner" - app:iconTint="?attr/colorOnSecondary" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toBottomOf="@id/extra_input_layout" /> - - <com.google.android.material.switchmaterial.SwitchMaterial - android:id="@+id/encrypt_username" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="8dp" - android:text="@string/crypto_encrypt_username_label" - android:visibility="gone" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/otp_import_button" - tools:visibility="visible" /> - </androidx.constraintlayout.widget.ConstraintLayout> -</ScrollView> diff --git a/app/src/main/res/layout/password_recycler_view.xml b/app/src/main/res/layout/password_recycler_view.xml deleted file mode 100644 index 49b7ff82..00000000 --- a/app/src/main/res/layout/password_recycler_view.xml +++ /dev/null @@ -1,39 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipChildren="false" - android:clipToPadding="false" - android:orientation="vertical" - tools:context="app.passwordstore.ui.passwords.PasswordFragment"> - - <androidx.swiperefreshlayout.widget.SwipeRefreshLayout - android:id="@+id/swipe_refresher" - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <me.zhanghai.android.fastscroll.FixOnItemTouchListenerRecyclerView - android:id="@+id/pass_recycler" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="none" - tools:itemCount="20" - tools:listitem="@layout/password_row_layout" /> - </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> - - <com.google.android.material.floatingactionbutton.FloatingActionButton - android:id="@+id/fab" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentEnd="true" - android:layout_alignParentBottom="true" - android:layout_gravity="bottom|end" - android:layout_margin="@dimen/fab_compat_margin" - android:contentDescription="@string/password_list_fab_content_description" - android:src="@drawable/ic_add_48dp" /> -</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml deleted file mode 100644 index 5c042839..00000000 --- a/app/src/main/res/layout/password_row_layout.xml +++ /dev/null @@ -1,50 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/password_row_background" - android:paddingTop="12dp" - android:paddingBottom="12dp"> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/label" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_gravity="bottom" - android:layout_marginStart="@dimen/activity_horizontal_margin" - android:textSize="18sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/child_count" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tools:text="FILE_NAME" /> - - <androidx.appcompat.widget.AppCompatTextView - android:id="@+id/child_count" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="8dp" - android:textSize="18sp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toStartOf="@id/folder_indicator" - app:layout_constraintTop_toTopOf="parent" - tools:text="12" /> - - <androidx.appcompat.widget.AppCompatImageView - android:id="@+id/folder_indicator" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginEnd="@dimen/activity_horizontal_margin" - android:contentDescription="@null" - android:src="@drawable/ic_keyboard_arrow_right_24dp" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintTop_toTopOf="parent" /> - -</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/app/src/main/res/layout/select_folder_layout.xml b/app/src/main/res/layout/select_folder_layout.xml deleted file mode 100644 index 78b03579..00000000 --- a/app/src/main/res/layout/select_folder_layout.xml +++ /dev/null @@ -1,16 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - - <LinearLayout - android:id="@+id/pgp_handler_linearlayout" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical" /> -</LinearLayout> diff --git a/app/src/main/res/menu/context_pass.xml b/app/src/main/res/menu/context_pass.xml deleted file mode 100644 index 608d7eef..00000000 --- a/app/src/main/res/menu/context_pass.xml +++ /dev/null @@ -1,34 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".pwdstore"> - - <item - android:id="@+id/menu_move_password" - android:icon="@drawable/ic_move_folder_24dp" - android:title="@string/move" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_delete_password" - android:icon="@drawable/ic_delete_24dp" - android:title="@string/delete" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_edit_password" - android:icon="@drawable/ic_edit_24dp" - android:title="@string/edit" - app:showAsAction="ifRoom" /> - - <item - android:id="@+id/menu_pin_password" - android:icon="@drawable/ic_push_pin_24dp" - android:title="@string/place_shortcut_on_home_screen" - app:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_git.xml b/app/src/main/res/menu/main_menu_git.xml deleted file mode 100644 index 1b64c083..00000000 --- a/app/src/main/res/menu/main_menu_git.xml +++ /dev/null @@ -1,36 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/git_sync" - android:title="@string/git_sync" /> - - <item - android:id="@+id/git_pull" - android:title="@string/git_pull" /> - - <item - android:id="@+id/git_push" - android:title="@string/git_push" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_no_auth.xml b/app/src/main/res/menu/main_menu_no_auth.xml deleted file mode 100644 index 46f19e49..00000000 --- a/app/src/main/res/menu/main_menu_no_auth.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/git_pull" - android:title="@string/git_pull" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/main_menu_non_git.xml b/app/src/main/res/menu/main_menu_non_git.xml deleted file mode 100644 index 5a7cf00b..00000000 --- a/app/src/main/res/menu/main_menu_non_git.xml +++ /dev/null @@ -1,24 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/action_search" - android:icon="@drawable/ic_search_24dp" - android:title="@string/action_search" - app:actionViewClass="androidx.appcompat.widget.SearchView" - app:showAsAction="always|collapseActionView" /> - - <item - android:id="@+id/refresh" - android:title="@string/refresh_list" /> - - <item - android:id="@+id/user_pref" - android:orderInCategory="100" - android:title="@string/action_settings" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler.xml b/app/src/main/res/menu/pgp_handler.xml deleted file mode 100644 index a8b6a524..00000000 --- a/app/src/main/res/menu/pgp_handler.xml +++ /dev/null @@ -1,28 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".ui.crypto.PgpActivity"> - <item - android:id="@+id/share_password_as_plaintext" - android:icon="@drawable/ic_share_24dp" - android:title="@string/share_as_plaintext" - android:visible="false" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/copy_password" - android:icon="@drawable/ic_content_copy" - android:title="@string/copy_password" - android:visible="false" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/edit_password" - android:icon="@drawable/ic_edit_24dp" - android:title="@string/edit_password" - android:visible="false" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler_new_password.xml b/app/src/main/res/menu/pgp_handler_new_password.xml deleted file mode 100644 index 6c61fb55..00000000 --- a/app/src/main/res/menu/pgp_handler_new_password.xml +++ /dev/null @@ -1,20 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context=".ui.crypto.PgpActivity"> - <item - android:id="@+id/save_password" - android:icon="@drawable/ic_save_24dp" - android:title="@string/crypto_save" - pwstore:showAsAction="ifRoom" /> - <item - android:id="@+id/save_and_copy_password" - android:icon="@drawable/ic_save_copy_24dp" - android:title="@string/crypto_save_and_copy" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/menu/pgp_handler_select_folder.xml b/app/src/main/res/menu/pgp_handler_select_folder.xml deleted file mode 100644 index d9505814..00000000 --- a/app/src/main/res/menu/pgp_handler_select_folder.xml +++ /dev/null @@ -1,15 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:pwstore="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - tools:context="app.passwordstore.SelectFolderActivity"> - <item - android:id="@+id/crypto_select" - android:icon="@drawable/ic_done_24dp" - android:title="@string/crypto_select" - pwstore:showAsAction="ifRoom" /> -</menu> diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differdeleted file mode 100644 index a1133420..00000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differdeleted file mode 100644 index b9e28b18..00000000 --- a/app/src/main/res/mipmap-mdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 353fe9c4..00000000 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 148db12b..00000000 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 2c1e2b05..00000000 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7499fe5e..7baf4113 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Wird gelöscht</string> <string name="move">Verschieben</string> - <string name="edit">Bearbeiten</string> <string name="delete">Löschen</string> <string name="password_exists_title">Passwort existiert bereits!</string> <string name="password_exists_message">Dies wird %1$s mit %2$s überschreiben.</string> @@ -38,9 +37,7 @@ <string name="git_commit_move_multiple_text">Verschiebe mehrere Passwörter nach %1$s.</string> <string name="git_commit_gpg_id">Initialisiere PGP-IDs in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">In die Zwischenablage kopiert</string> <string name="file_toast_text">Bitte setzen Sie einen Dateinamen.</string> - <string name="path_toast_text">Bitte setzen Sie einen Pfad.</string> <string name="empty_toast_text">Sie können kein leeres Passwort oder leere Extra-Angaben setzen.</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Es ist ein Fehler während der Git-Operation aufgetreten.</string> @@ -64,7 +61,6 @@ <string name="crypto_name_hint">Name</string> <string name="crypto_pass_label">Passwort</string> <string name="crypto_extra_label">Zusätzliche Angaben</string> - <string name="crypto_encrypt_username_label">Benutzername verschlüsseln</string> <string name="crypto_select">Auswählen</string> <string name="crypto_save">Speichern</string> <string name="crypto_save_and_copy">Speichern und kopieren</string> @@ -99,8 +95,6 @@ <string name="pref_recursive_filter_summary">Findet Passwörter auch in Unterordnern.</string> <string name="pref_sort_order_title">Passwortsortierung</string> <string name="pref_folder_first_sort_order">Ordner zuerst</string> - <string name="pref_file_first_sort_order">Dateien zuerst</string> - <string name="pref_type_independent_sort_order">typunabhängig</string> <string name="pref_recently_used_sort_order">Zuletzt verwendet</string> <string name="pref_category_autofill_title">Automatisch ausfüllen</string> <string name="pref_autofill_enable_title">Autofill aktivieren</string> @@ -126,7 +120,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Passwort erstellen</string> <string name="pwgen_generate">Erstellen</string> - <string name="pwgen_include">Einschließen</string> <string name="pwgen_length">Länge</string> <string name="pwgen_numerals">Nummern</string> <string name="pwgen_symbols">Symbole</string> @@ -167,7 +160,6 @@ <string name="dialog_yes">Ja</string> <string name="dialog_no">Nein</string> <string name="dialog_cancel">Abbruch</string> - <string name="dialog_skip">Überspringen</string> <string name="git_sync">Synchronisiere Repository</string> <string name="git_pull">Git Pull</string> <string name="git_push">Git Push</string> @@ -175,10 +167,7 @@ <string name="git_log">Commit-Log anzeigen</string> <string name="show_password_pref_title">Zeige das Passwort.</string> <string name="show_password_pref_summary">Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren.</string> - <string name="pwd_generate_button">Erstellen</string> - <string name="refresh_list">Liste aktualisieren</string> <string name="send_plaintext_password_to">Passwort unverschlüsselt senden an…</string> - <string name="app_icon_hint">App-Icon</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Eintrag zum Ausfüllen auswählen</string> <string name="oreo_autofill_strict_domain_search">Phishing-resistente Suche</string> @@ -205,12 +194,11 @@ <string name="oreo_autofill_password_fill_support">Passwörter ausfüllen</string> <string name="oreo_autofill_flaky_fill_support">Passwörter ausfüllen (manchmal kann ein Neustart des Browsers erforderlich sein)</string> <string name="oreo_autofill_no_support">Kein Support</string> - <string name="oreo_autofill_enable_dialog_description">Password Store kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string> - <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"Password Store\" aus der Liste und bestätigen mit \"OK\".</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string> + <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"HardenedPass\" aus der Liste und bestätigen mit \"OK\".</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Autofill-Unterstützung mit installierten Browsern:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Der private SSH-Schlüssel konnte nicht geöffnet werden. Bitte überprüfen Sie, ob die Datei existiert.</string> - <string name="new_password_title">Neues Passwort</string> <string name="clear_saved_passphrase_ssh">Gespeicherte Passphrase für lokalen SSH-Schlüssel löschen</string> <string name="clear_saved_passphrase_https">Gespeichertes HTTPS-Passwort löschen</string> <string name="git_operation_remember_passphrase">Schlüsselpasswort merken</string> @@ -226,7 +214,6 @@ <string name="pref_biometric_auth_title">Biometrische Authentifizierung</string> <string name="pref_biometric_auth_summary">Falls aktiviert, werden Sie beim Starten der App nach Ihrem Fingerabdruck gefragt.</string> <string name="pref_biometric_auth_summary_error">Fingerabdrucksensor fehlt oder ist nicht ansprechbar.</string> - <string name="pref_title_openkeystore_clear_keyid">Lösche gespeicherte OpenKeystore SSH-Schlüssel-ID.</string> <string name="your_public_key">Ihr öffentlicher Schlüssel</string> <string name="error_generate_ssh_key">Fehler beim Erstellen des SSH-Schlüssels</string> <string name="pref_show_hidden_title">Alle Dateien und Ordner anzeigen</string> @@ -234,7 +221,6 @@ <string name="title_create_folder">Ordner erstellen</string> <string name="title_rename_folder">Ordner umbenennen</string> <string name="message_category_error_empty_field">Der Name der Kategorie darf nicht leer sein.</string> - <string name="message_category_error_category_exists">Der Kategoriename existiert bereits.</string> <string name="message_error_destination_outside_repo">Ziel muss innerhalb des Repositorys sein.</string> <string name="message_rename_folder">Ziel für %1$s angeben</string> <string name="button_create">Erstellen</string> @@ -245,7 +231,6 @@ <string name="pref_app_theme_title">App-Farbthema</string> <string name="pref_app_theme_value_light">Hell</string> <string name="pref_app_theme_value_dark">Dunkel</string> - <string name="pref_app_theme_value_battery_saver">Durch Energiesparmodus gesetzt</string> <string name="pref_app_theme_value_follow_system">Systemstandard</string> <string name="connection_mode_ssh_key">SSH-Schlüssel</string> <string name="connection_mode_basic_authentication">Passwort</string> @@ -269,8 +254,8 @@ <string name="preference_custom_public_suffixes_hint">company.com\npersonal.com</string> <!-- Password creation failure --> <string name="password_creation_file_fail_title">Fehler</string> - <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Password Store, bitte versuchen Sie es erneut.</string> - <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Password Store, bitte löschen Sie die Datei von Hand.</string> + <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Repository, bitte versuchen Sie es erneut.</string> + <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Repository, bitte löschen Sie die Datei von Hand.</string> <string name="password_creation_duplicate_error">Datei existiert bereits, bitte benutzen Sie einen anderen Dateinamen.</string> <string name="add_otp">OTP hinzufügen</string> <string name="otp_import_success">TOTP-Konfiguration erfolgreich importiert</string> @@ -280,11 +265,9 @@ <string name="invalid_gpg_id">.gpg-id wurde gefunden, enthält jedoch ungültige Schlüssel-ID, Fingerabdruck oder Benutzer-ID.</string> <string name="short_gpg_id">.gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird.</string> <string name="invalid_filename_text">Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen</string> - <string name="directory_hint">Ordner</string> - <string name="new_folder_set_gpg_key">PGP-Schlüssel für Ordner festlegen</string> <!-- GitException messages --> <string name="git_unknown_error">Unbekannter Fehler</string> - <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen > Lokale Git-Konfiguration & Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string> + <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen > Repository > Lokale Git-Konfiguration & Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string> <string name="git_pull_merge_fail_error">Merge fehlgeschlagen, Sie befinden sich in einem Konflikt.</string> <string name="git_push_nff_error">Push wurde vom Remote abgelehnt. Führen Sie den Pull vor dem Push aus. Sie können \"Synchronisieren\" statt Pull/Push nutzen, da es beides implementiert</string> <string name="git_push_generic_error">Push wurde von Remote abgelehnt, Grund: %1$s</string> @@ -294,16 +277,12 @@ <string name="git_break_out_of_detached_success">Es gab einen Konflikt während des Rebase-Prozesses. Ihr lokaler Branch %1$s wurde auf einen anderen Branch namens %2$s gepusht.\nNutzen Sie diesen Branch, um den Konflikt auf dem Server zu beheben</string> <string name="git_break_out_of_detached_unneeded">Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen.</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Um fortzufahren, muss ein PGP-Schlüssel ausgewählt werden.</string> <string name="folder_creation_err_file_exists">Dateiname bereits vergeben</string> <string name="folder_creation_err_folder_exists">Ein Ordner mit diesem Namen existiert bereits.</string> <!-- Onboarding flow --> - <string name="let_s_go">Los geht\'s</string> - <string name="select_n_repository_type">Wählen Sie einen \nRepository-Typ.</string> <string name="select_repo_type_text">Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten.</string> <string name="clone_remote_repo">Remote-Repository klonen</string> <string name="create_local_repo">Lokales Repository anlegen</string> - <string name="select_gpg_key_title">Wählen Sie einen\nPGP-Schlüssel.</string> <string name="select_gpg_key_message">Wählen einen PGP-Schlüssel, mit dem der Store initialisiert werden soll.</string> <string name="gpg_key_select">Schlüssel auswählen</string> <!-- SSH port validation --> @@ -324,11 +303,9 @@ <string name="clear_saved_host_key_success">Host-Schlüssel erfolgreich gelöscht!</string> <string name="otp_import_qr_code">QR-Code scannen</string> <string name="otp_import_from_file">Wählen Sie ein Bild.</string> - <string name="otp_import_manual_entry">Manuell eingeben</string> <string name="otp_import_manual_hint_secret">Schlüssel</string> <string name="otp_import_manual_hint_account">Benutzer-Konto</string> <string name="place_shortcut_on_home_screen">Verknüpfung auf dem Home-Screen erstellen</string> - <string name="password_list_fab_content_description">Neues Passwort oder neuen Ordner erstellen</string> <string name="pgp_key_import_failed">Fehler beim Import des PGP-Schlüssels</string> <string name="pgp_key_import_failed_replace_message">Ein bestehender Schlüssel mit dieser ID wurde gefunden. Möchten Sie ihn ersetzen?</string> <string name="pgp_key_import_succeeded">PGP-Schlüssel erfolgreich importiert</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6c8ac709..57be2b30 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Suppression…</string> <string name="move">Déplacer</string> - <string name="edit">Éditer</string> <string name="delete">Supprimer</string> <string name="password_exists_title">Le mot de passe existe!</string> <string name="password_exists_message">Cela écrasera %1$s avec %2$s.</string> @@ -37,9 +36,7 @@ <string name="git_commit_move_text">Renommer %1$sà %2$s. </string> <string name="git_commit_move_multiple_text">Déplacement de mots de passe vers %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copié dans le presse-papiers</string> <string name="file_toast_text">Veuillez fournir un nom de fichier</string> - <string name="path_toast_text">Veuillez fournir un chemin d\'accès au fichier</string> <string name="empty_toast_text">Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Une erreur c\'est produite lors d\'une opération Git</string> @@ -63,7 +60,6 @@ <string name="crypto_name_hint">Nom</string> <string name="crypto_pass_label">Mot de passe</string> <string name="crypto_extra_label">Extra</string> - <string name="crypto_encrypt_username_label">Chiffrer le nom d\'utilisateur</string> <string name="crypto_select">Sélectionner</string> <string name="crypto_save">Sauvegarder</string> <string name="crypto_save_and_copy">Enregistrer et copier</string> @@ -97,8 +93,6 @@ <string name="pref_recursive_filter_summary">Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel.</string> <string name="pref_sort_order_title">Ordre de tri des mots de passe</string> <string name="pref_folder_first_sort_order">Dossiers en premier</string> - <string name="pref_file_first_sort_order">Fichiers en premier</string> - <string name="pref_type_independent_sort_order">Indifférent au type d\'entrée</string> <string name="pref_recently_used_sort_order">Récemment utilisé</string> <string name="pref_category_autofill_title">Saisie automatique</string> <string name="pref_autofill_enable_title">Saisie automatique</string> @@ -114,7 +108,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Générer un mot de passe</string> <string name="pwgen_generate">Générer</string> - <string name="pwgen_include">Inclure</string> <string name="pwgen_length">Longueur</string> <string name="pwgen_numerals">Chiffres</string> <string name="pwgen_symbols">Symboles</string> @@ -155,7 +148,6 @@ <string name="dialog_yes">Oui</string> <string name="dialog_no">Non</string> <string name="dialog_cancel">Annuler</string> - <string name="dialog_skip">Ignorer</string> <string name="git_sync">Synchronisation du dépôt</string> <string name="git_pull">Importer du serveur</string> <string name="git_push">Pousser sur le serveur</string> @@ -163,10 +155,7 @@ <string name="git_log">Afficher l\'historique des commits</string> <string name="show_password_pref_title">Montrer le mot de passe</string> <string name="show_password_pref_summary">Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe</string> - <string name="pwd_generate_button">Générer</string> - <string name="refresh_list">Rafraichir la liste</string> <string name="send_plaintext_password_to">Envoyer le mot de passe en clair via…</string> - <string name="app_icon_hint">Icône de l\'application</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Sélectionnez l\'entrée à remplir</string> <string name="oreo_autofill_strict_domain_search">Recherche protégée contre le hameçonnage</string> @@ -193,12 +182,11 @@ <string name="oreo_autofill_password_fill_support">Remplir le mot de passe</string> <string name="oreo_autofill_flaky_fill_support">Remplir les mots de passe (peut nécessiter un redémarrage du navigateur de temps à autre)</string> <string name="oreo_autofill_no_support">Pas de support</string> - <string name="oreo_autofill_enable_dialog_description">Password Store vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string> - <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez Password Store dans la liste puis confirmez avec OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string> + <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez HardenedPass dans la liste puis confirmez avec OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Prise en charge du remplissage automatique avec les navigateurs installés:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Impossible d\'ouvrir la clef ssh, merci de vérifier que le ficher existe</string> - <string name="new_password_title">Nouveau mot de passe</string> <string name="clear_saved_passphrase_ssh">Effacer le mot de passe enregistrée pour la clé SSH locale</string> <string name="clear_saved_passphrase_https">Effacer le mot de passe HTTPS enregistré</string> <string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string> @@ -212,9 +200,8 @@ <string name="biometric_prompt_title">Identification biométrique</string> <string name="biometric_auth_error_reason">Échec de l\'authentification : %s</string> <string name="pref_biometric_auth_title">Activer l\'authentification biométrique</string> - <string name="pref_biometric_auth_summary">Lorsque cette option est activée, Password Store vous demandera votre empreinte digitale au lancement</string> + <string name="pref_biometric_auth_summary">Lorsque cette option est activée, HardenedPass vous demandera votre empreinte digitale au lancement</string> <string name="pref_biometric_auth_summary_error">Lecteur d\'empreinte digitale non accessible ou manquant</string> - <string name="pref_title_openkeystore_clear_keyid">Effacer la clé d\'identification SSH d\'OpenKeystore mémorisée</string> <string name="your_public_key">Votre clé publique</string> <string name="error_generate_ssh_key">Une erreur est survenue pendant la génération de la clé ssh</string> <string name="pref_show_hidden_title">Afficher tous les fichiers et dossiers</string> @@ -222,7 +209,6 @@ <string name="title_create_folder">Créer un dossier</string> <string name="title_rename_folder">Renommer le dossier</string> <string name="message_category_error_empty_field">Le nom de la catégorie ne peut pas être vide</string> - <string name="message_category_error_category_exists">Une catégorie du même nom existe déjà</string> <string name="message_error_destination_outside_repo">La destination doit être à l\'intérieur du dépôt</string> <string name="message_rename_folder">Entrez le chemin pour %1$s</string> <string name="button_create">Créer</string> @@ -233,7 +219,6 @@ <string name="pref_app_theme_title">Thème de l\'application</string> <string name="pref_app_theme_value_light">Clair</string> <string name="pref_app_theme_value_dark">Sombre</string> - <string name="pref_app_theme_value_battery_saver">Défini par l\'économiseur de batterie</string> <string name="pref_app_theme_value_follow_system">Valeur par défaut du système</string> <string name="connection_mode_ssh_key">Clé SSH</string> <string name="connection_mode_basic_authentication">Mot de passe</string> @@ -265,11 +250,9 @@ <string name="otp_import_failure_generic">Impossible d\'importer la configuration TOTP</string> <string name="exporting_passwords">Exportation des mots de passe…</string> <string name="invalid_filename_text">Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent</string> - <string name="directory_hint">Dossier</string> - <string name="new_folder_set_gpg_key">Définir la clé GPG pour le dossier</string> <!-- GitException messages --> <string name="git_unknown_error">Erreur inconnue</string> - <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"settings > git utils\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string> + <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"Paramètres > Dépôt > Configuration locale de Git & utilitaires\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string> <string name="git_pull_merge_fail_error">La fusion a échoué, vous êtes dans un état de conflit.</string> <string name="git_push_nff_error">La poussée a été rejetée par le dépôt distant, exécutez une tirée avant de pousser à nouveau. Vous pouvez utiliser Synchroniser plutôt que de tirer/pousser car il implémente les deux.</string> <string name="git_push_generic_error">L\'envoi a été rejeté par le dépôt distant, raison : %1$s</string> @@ -282,12 +265,9 @@ <string name="folder_creation_err_file_exists">Un fichier portant ce nom existe déjà</string> <string name="folder_creation_err_folder_exists">Un dossier portant ce nom existe déjà</string> <!-- Onboarding flow --> - <string name="let_s_go">Allons-y</string> - <string name="select_n_repository_type">Sélectionnez le type de dépôt \n</string> <string name="select_repo_type_text">Sélectionnez si vous voulez créer un dépôt local ou cloner un dépôt distant.</string> <string name="clone_remote_repo">Cloner le dépôt distant</string> <string name="create_local_repo">Créer un dépôt local</string> - <string name="select_gpg_key_title">Sélectionnez\nla clé GPG</string> <string name="select_gpg_key_message">Sélectionnez une clé GPG pour initialiser votre magasin</string> <string name="gpg_key_select">Sélectionner la clé</string> <!-- SSH port validation --> @@ -305,10 +285,8 @@ <string name="clear_saved_host_key_success">La clé d\'hôte a été effacée avec succès!</string> <string name="otp_import_qr_code">Scanner le code QR</string> <string name="otp_import_from_file">Choisissez une image</string> - <string name="otp_import_manual_entry">Saisir manuellement</string> <string name="otp_import_manual_hint_secret">Secret</string> <string name="otp_import_manual_hint_account">Compte</string> - <string name="password_list_fab_content_description">Créer un nouveau mot de passe ou dossier</string> <string name="pgp_key_import_failed">Impossible d\'importer la clé PGP</string> <string name="pgp_key_import_succeeded">Clé PGP importée avec succès</string> <string name="pgp_key_import_succeeded_message">La clé d\'identification de la clé importée est donné ci-dessous, veuillez la vérifier pour qu\'elle soit correcte :\n%1$s</string> diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 4ca39712..b7a59428 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Eliminando…</string> <string name="move">Mover</string> - <string name="edit">Editar</string> <string name="delete">Eliminar</string> <string name="password_exists_title">O contrasinal xa existe!</string> <string name="password_exists_message">Vas sobrescribir %1$s con %2$s.</string> @@ -38,9 +37,7 @@ <string name="git_commit_move_multiple_text">Mover varios contrasinais a %1$s.</string> <string name="git_commit_gpg_id">Iniciar cos IDs GPG en %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiado ao portapapeis</string> <string name="file_toast_text">Debes proporcionar un nome de ficheiro</string> - <string name="path_toast_text">Por favor indica a ruta ao ficheiro</string> <string name="empty_toast_text">Non podes deixar baleiro o contrasinal ou o contido extra</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Algo fallou na operación de Git</string> @@ -64,7 +61,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Contrasinal</string> <string name="crypto_extra_label">Contido extra</string> - <string name="crypto_encrypt_username_label">Cifrar identificador</string> <string name="crypto_select">Seleccionar</string> <string name="crypto_save">Gardar</string> <string name="crypto_save_and_copy">Gardar e copiar</string> @@ -100,8 +96,6 @@ <string name="pref_directory_structure">Organización dos contrasinais</string> <string name="pref_sort_order_title">Orde para mostrar contrasinais</string> <string name="pref_folder_first_sort_order">Primeiro cartafoles</string> - <string name="pref_file_first_sort_order">Primeiro ficheiros</string> - <string name="pref_type_independent_sort_order">Independentemente do tipo</string> <string name="pref_recently_used_sort_order">Usadas recentemente</string> <string name="pref_category_autofill_title">Completado automático</string> <string name="pref_autofill_enable_title">Activar completado automático</string> @@ -127,7 +121,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Crear contrasinal</string> <string name="pwgen_generate">Crear</string> - <string name="pwgen_include">Incluír</string> <string name="pwgen_length">Lonxitude</string> <string name="pwgen_numerals">Números</string> <string name="pwgen_symbols">Símbolos</string> @@ -168,7 +161,6 @@ <string name="dialog_yes">Si</string> <string name="dialog_no">Non</string> <string name="dialog_cancel">Cancelar</string> - <string name="dialog_skip">Omitir</string> <string name="git_sync">Sincronizar repositorio</string> <string name="git_pull">Traer desde remoto</string> <string name="git_push">Enviar a remoto</string> @@ -176,10 +168,7 @@ <string name="git_log">Mostrar rexistro dos cambios</string> <string name="show_password_pref_title">Mostrar contrasinal</string> <string name="show_password_pref_summary">Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal</string> - <string name="pwd_generate_button">Crear</string> - <string name="refresh_list">Actualizar lista</string> <string name="send_plaintext_password_to">Enviar contrasinal como texto plano usando…</string> - <string name="app_icon_hint">Icona da app</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Elixe o elemento a completar</string> <string name="oreo_autofill_strict_domain_search">Busca resistente ao phishing</string> @@ -192,7 +181,7 @@ <string name="oreo_autofill_save_passwords_dont_match">Non concordan os contrasinais</string> <string name="oreo_autofill_generate_password">Crear elemento</string> <string name="oreo_autofill_max_matches_reached">Acadouse o máximo número de coincidencias (%1$d); eliminar as coincidencias antes de engadir máis.</string> - <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada Password Store con ela:</string> + <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada HardenedPass con ela:</string> <string name="oreo_autofill_warning_publisher_footer"><b>A aplicación actualmente instalada podería intentar roubar as túas credenciais pretendendo ser unha app de confianza.</b>\n\nIntenta desinstalar e reinstalar a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid ou a tenda do fabricante do teu móbil.</string> <string name="oreo_autofill_warning_publisher_install_time">Instalada: %1$s</string> @@ -207,12 +196,11 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="oreo_autofill_password_fill_support">Completar contrasinais</string> <string name="oreo_autofill_flaky_fill_support">Completa as credenciais (poderías ter que reiniciar o navegador de cando en vez)</string> <string name="oreo_autofill_no_support">Sen soporte</string> - <string name="oreo_autofill_enable_dialog_description">Password Store pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string> - <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle Password Store da lista e confirma a solicitude premendo no OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string> + <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle HardenedPass da lista e confirma a solicitude premendo no OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Soporte de autocompletado cos navegadores:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Non se pode abrir a chave privada ssh, comproba que existe tal ficheiro</string> - <string name="new_password_title">Novo contrasinal</string> <string name="clear_saved_passphrase_ssh">Baleirar a frase de paso gardada para a chave SSH local</string> <string name="clear_saved_passphrase_https">Baleirar contrasinal HTTPS gardado</string> <string name="git_operation_remember_passphrase">Lembrar frase de paso da chave</string> @@ -226,9 +214,8 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="biometric_prompt_title">Petición biométrica</string> <string name="biometric_auth_error_reason">Fallou a autenticación: %s</string> <string name="pref_biometric_auth_title">Activar autenticación biométrica</string> - <string name="pref_biometric_auth_summary">Ao activala, Password Store vaiche pedir a túa pegada dactilar ao iniciar a app</string> + <string name="pref_biometric_auth_summary">Ao activala, HardenedPass vaiche pedir a túa pegada dactilar ao iniciar a app</string> <string name="pref_biometric_auth_summary_error">O hardware de pegada dixital non é accesible ou existente</string> - <string name="pref_title_openkeystore_clear_keyid">Eliminar o ID lembrado da chave SSH en OpenKeystore</string> <string name="your_public_key">A túa chave pública</string> <string name="error_generate_ssh_key">Algo fallou ao intentar crear a chave-ssh</string> <string name="pref_show_hidden_title">Mostar cartafoles ocultos</string> @@ -236,7 +223,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="title_create_folder">Crear cartafol</string> <string name="title_rename_folder">Renomear cartafol</string> <string name="message_category_error_empty_field">O nome da categoría non pode estar baleiro</string> - <string name="message_category_error_category_exists">Xa existe o nome da categoría</string> <string name="message_error_destination_outside_repo">O destino debe estar dentro do repositorio</string> <string name="message_rename_folder">Escribe o destino para %1$s</string> <string name="button_create">Crear</string> @@ -247,7 +233,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="pref_app_theme_title">Decorado da App</string> <string name="pref_app_theme_value_light">Claro</string> <string name="pref_app_theme_value_dark">Escuro</string> - <string name="pref_app_theme_value_battery_saver">Establecido polo Aforrador de enerxía</string> <string name="pref_app_theme_value_follow_system">Por omisión do sistema</string> <string name="connection_mode_ssh_key">Chave SSH</string> <string name="connection_mode_basic_authentication">Contrasinal</string> @@ -282,11 +267,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="invalid_gpg_id">Atopouse .gpg-id, pero contén un ID de chave, impresión dixital ou ID de usuaria non válidos</string> <string name="short_gpg_id">Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte</string> <string name="invalid_filename_text">O nome do ficheiro non pode conter \'/\', establece un directorio superior</string> - <string name="directory_hint">Directorio</string> - <string name="new_folder_set_gpg_key">Establece chave GPG para o directorio</string> <!-- GitException messages --> <string name="git_unknown_error">Fallo descoñecido</string> - <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"axustes > utilidades git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string> + <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"Axustes > Repositorio > Configuración e Utilidades locais Git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string> <string name="git_pull_merge_fail_error">Fallou o merge, hai un conflito nos datos. TAREFA: engadir método de recuperación.</string> <string name="git_push_nff_error">O push foi rexeitado polo remoto, executa pull antes de voltar a subilos de novo. Podes usar Sincronizar mellor que push/pull xa que inclú ambos.</string> <string name="git_push_generic_error">O push foi rexeitado polo remoto, razón: %1$s</string> @@ -296,16 +279,12 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="git_break_out_of_detached_success">Hai un conflito ó realizar rebase. A túa póla local %1$s fixo push a outra póla chamada %2$s\nUsa esta póla para resolver o conflito na túa computadora</string> <string name="git_break_out_of_detached_unneeded">O repositorio non cambiou de base, non é preciso cambiar a outra póla</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Hai que elexir unha chave GPG para continuar</string> <string name="folder_creation_err_file_exists">Xa existe un ficheiro con ese nome</string> <string name="folder_creation_err_folder_exists">Xa existe un cartafol con ese nome</string> <!-- Onboarding flow --> - <string name="let_s_go">Imos alá</string> - <string name="select_n_repository_type">Escolle \nTipo de repositorio</string> <string name="select_repo_type_text">Escolle se queres crear un repositorio local ou clonar un remoto.</string> <string name="clone_remote_repo">Clonar repositorio remoto</string> <string name="create_local_repo">Crear repositorio local</string> - <string name="select_gpg_key_title">Elixe\nChave\nGPG</string> <string name="select_gpg_key_message">Elixe a chave GPG coa que queres iniciar a almacenaxe</string> <string name="gpg_key_select">Elixe chave</string> <!-- SSH port validation --> @@ -326,11 +305,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="clear_saved_host_key_success">Eliminouse correctamente a chave gardada do host!</string> <string name="otp_import_qr_code">Escanear código QR</string> <string name="otp_import_from_file">Elixe unha imaxe</string> - <string name="otp_import_manual_entry">Escribir manualmente</string> <string name="otp_import_manual_hint_secret">Segredo</string> <string name="otp_import_manual_hint_account">Conta</string> <string name="place_shortcut_on_home_screen">Crear atallo na pantalla de inicio</string> - <string name="password_list_fab_content_description">Crear novo contrasinal ou cartafol</string> <string name="pgp_key_import_failed">Fallou a importación da chave PGP</string> <string name="pgp_key_import_failed_replace_message">Atopouse unha chave existente con este ID, queres substituíla?</string> <string name="pgp_key_import_succeeded">Chave PGP importada correctamente</string> diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml deleted file mode 100644 index f9a452aa..00000000 --- a/app/src/main/res/values-hdpi/dimens.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <dimen name="onboarding_icon_margin_top">50dp</dimen> - <dimen name="onboarding_button_margin_top">70dp</dimen> - <dimen name="onboarding_desc_margin_top">48dp</dimen> -</resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dfb18d52..07f63cd9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Eliminazione…</string> <string name="move">Sposta</string> - <string name="edit">Modifica</string> <string name="delete">Elimina</string> <string name="password_exists_title">La password esiste già!</string> <string name="password_exists_message">Questo sovrascriverà %1$s con %2$s.</string> @@ -37,9 +36,7 @@ <string name="git_commit_move_text">Rinomina %1$s in %2$s.</string> <string name="git_commit_move_multiple_text">Sposta più password in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiato negli appunti</string> <string name="file_toast_text">Inserisci un nome per il file</string> - <string name="path_toast_text">Inserisci il percorso del file</string> <string name="empty_toast_text">Non puoi usare una password vuota o dei contenuti extra vuoti</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Si è verificato un errore durante un\'operazione Git</string> @@ -63,7 +60,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Password</string> <string name="crypto_extra_label">Contenuto extra</string> - <string name="crypto_encrypt_username_label">Crittografa nome utente</string> <string name="crypto_select">Seleziona</string> <string name="crypto_save">Salva</string> <string name="crypto_save_and_copy">Salva e Copia</string> @@ -97,8 +93,6 @@ <string name="pref_recursive_filter_summary">Trova ricorsivamente le password della directory corrente.</string> <string name="pref_sort_order_title">Ordine password</string> <string name="pref_folder_first_sort_order">Prima le cartelle</string> - <string name="pref_file_first_sort_order">Prima i file</string> - <string name="pref_type_independent_sort_order">Indipendente dal tipo</string> <string name="pref_recently_used_sort_order">Usato di recente</string> <string name="pref_category_autofill_title">Compilazione automatica</string> <string name="pref_autofill_enable_title">Attiva la compilazione automatica</string> @@ -109,7 +103,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Genera Password</string> <string name="pwgen_generate">Genera</string> - <string name="pwgen_include">Includi</string> <string name="pwgen_length">Lunghezza</string> <string name="pwgen_numerals">Numeri</string> <string name="pwgen_symbols">Simboli</string> @@ -150,7 +143,6 @@ <string name="dialog_yes">Sì</string> <string name="dialog_no">No</string> <string name="dialog_cancel">Annulla</string> - <string name="dialog_skip">Salta</string> <string name="git_sync">Sincronizza repository</string> <string name="git_pull">Pull dal remote</string> <string name="git_push">Push sul remote</string> @@ -158,10 +150,7 @@ <string name="git_log">Mostra registro dei commit</string> <string name="show_password_pref_title">Mostra la password</string> <string name="show_password_pref_summary">Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti.</string> - <string name="pwd_generate_button">Genera</string> - <string name="refresh_list">Aggiorna elenco</string> <string name="send_plaintext_password_to">Invia password come testo semplice usando…</string> - <string name="app_icon_hint">Icona app</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Seleziona la voce da compilare</string> <string name="oreo_autofill_strict_domain_search">Ricerca resistente al phishing</string> @@ -193,7 +182,6 @@ <string name="oreo_autofill_enable_dialog_installed_browsers">Supporto alla compilazione automatica nei browser installati:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Impossibile aprire la chiave privata SSH, controlla che il file esista</string> - <string name="new_password_title">Nuova password</string> <string name="clear_saved_passphrase_ssh">Cancella la frase segreta salvata per la chiave SSH locale</string> <string name="clear_saved_passphrase_https">Cancella la password HTTPS salvata</string> <string name="git_operation_remember_passphrase">Ricorda la frase segreta della chiave</string> @@ -209,7 +197,6 @@ <string name="pref_biometric_auth_title">Abilita autenticazione biometrica</string> <string name="pref_biometric_auth_summary">Se attiva, HardenedPass chiede l\'impronta digitale all\'avvio dell\'app</string> <string name="pref_biometric_auth_summary_error">L\'hardware delle impronte digitali non è accessibile o mancante</string> - <string name="pref_title_openkeystore_clear_keyid">Elimina l\'ID della Chiave SSH di OpenKeystore memorizzato</string> <string name="your_public_key">La tua chiave pubblica</string> <string name="error_generate_ssh_key">Errore durante la generazione della chiave SSH</string> <string name="pref_show_hidden_title">Mostra tutti i file e cartelle</string> @@ -217,7 +204,6 @@ <string name="title_create_folder">Crea cartella</string> <string name="title_rename_folder">Rinomina cartella</string> <string name="message_category_error_empty_field">Il nome della categoria non può essere vuoto</string> - <string name="message_category_error_category_exists">Il nome della categoria esiste già</string> <string name="message_error_destination_outside_repo">La destinazione deve essere nel repository</string> <string name="message_rename_folder">Inserisci la destinazione per %1$s</string> <string name="button_create">Crea</string> @@ -228,7 +214,6 @@ <string name="pref_app_theme_title">Tema dell\'App</string> <string name="pref_app_theme_value_light">Chiaro</string> <string name="pref_app_theme_value_dark">Scuro</string> - <string name="pref_app_theme_value_battery_saver">Impostato dal Risparmio Energetico</string> <string name="pref_app_theme_value_follow_system">Predefinito del sistema</string> <string name="connection_mode_ssh_key">Chiave SSH</string> <string name="connection_mode_basic_authentication">Password</string> @@ -260,11 +245,9 @@ <string name="otp_import_failure_generic">Impossibile importare la configurazione di TOTP</string> <string name="exporting_passwords">Esportazione delle password…</string> <string name="invalid_filename_text">Il nome del file non deve contenere \'/\', imposta la directory qui sopra</string> - <string name="directory_hint">Directory</string> - <string name="new_folder_set_gpg_key">Imposta la chiave GPG per la directory</string> <!-- GitException messages --> <string name="git_unknown_error">Errore sconosciuto</string> - <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Utilità Git\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string> + <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Repository > Configurazione Git locale e utilità\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string> <string name="git_push_nff_error">Push rifiutato dal remote: esegui un pull prima di riprovare. Puoi usare Sincronizza, che esegue sia il pull sia il push</string> <string name="git_push_generic_error">Push rifiutato dal remote, motivo: %1$s</string> <string name="git_push_other_error">Il remote ha rifiutato un push non fast-forward. Controlla la variabile receive.denyNonFastForwards nella configurazione del repository di destinazione.</string> @@ -276,12 +259,9 @@ <string name="folder_creation_err_file_exists">Un file con quel nome esiste già</string> <string name="folder_creation_err_folder_exists">Una cartella con quel nome esiste già</string> <!-- Onboarding flow --> - <string name="let_s_go">Andiamo</string> - <string name="select_n_repository_type">Seleziona \nTipo di repository</string> <string name="select_repo_type_text">Seleziona se vuoi creare un repo locale o clonarne uno remoto.</string> <string name="clone_remote_repo">Clona repo remoto</string> <string name="create_local_repo">Crea repo locale</string> - <string name="select_gpg_key_title">Seleziona\nChiave\nGPG</string> <string name="select_gpg_key_message">Seleziona una chiave GPG con cui inizializzare il tuo archivio</string> <string name="gpg_key_select">Seleziona chiave</string> <!-- SSH port validation --> @@ -298,7 +278,6 @@ <string name="clear_saved_host_key">Cancella la chiave host salvata</string> <string name="clear_saved_host_key_success">Chiave host cancellata con successo!</string> <string name="otp_import_from_file">Scegli un\'immagine</string> - <string name="password_list_fab_content_description">Crea nuova password o cartella</string> <string name="git_commit_gpg_id">Inizializza gli ID GPG in %1$s.</string> <string name="crypto_username_hint">Nome utente</string> <string name="otp_label_format">OTP (scade tra %ds)</string> @@ -321,18 +300,15 @@ <string name="pref_passphrase_cache_auto_clear_authenticate_disable">Autenticati per disattivare la cancellazione automatica</string> <string name="pref_passphrase_cache_auto_clear_title">Cancella automaticamente le frasi segrete memorizzate</string> <string name="pref_passphrase_cache_auto_clear_summary">Cancella le frasi segrete memorizzate allo spegnimento dello schermo</string> - <string name="pref_biometric_auth_summary_disabled_platform">Funzione non disponibile a causa di un bug della piattaforma</string> <string name="password_creation_encrypt_fail_message">Impossibile cifrare la password, non è stato salvato nulla: %1$s</string> <string name="otp_import_failure_no_selection">Nessuna immagine selezionata</string> <string name="invalid_gpg_id">Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido</string> <string name="short_gpg_id">Trovato .gpg-id, ma contiene un ID esadecimale corto, che non è supportato</string> <string name="git_pull_merge_fail_error">Merge non riuscito, il repository è in conflitto.</string> - <string name="gpg_key_select_mandatory">Per proseguire devi selezionare una chiave GPG</string> <string name="git_scheme_disallowed_title">Il protocollo git:// è sconsigliato</string> <string name="git_scheme_disallowed_message">Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure.</string> <string name="activity_proxy_label">Impostazioni proxy</string> <string name="otp_import_qr_code">Scansiona codice QR</string> - <string name="otp_import_manual_entry">Inserisci manualmente</string> <string name="otp_import_manual_hint_secret">Segreto</string> <string name="otp_import_manual_hint_account">Account</string> <string name="place_shortcut_on_home_screen">Aggiungi scorciatoia alla schermata Home</string> @@ -351,4 +327,72 @@ <string name="no_keys_imported_dialog_message">Nell\'app non è ancora stata importata nessuna chiave PGP: premi il pulsante qui sotto per scegliere un file di chiave</string> <string name="biometric_prompt_title_gpg_passphrase_cache">Sblocca le frasi segrete memorizzate</string> <string name="clear_cached_password_on_screen_off">Cancella la password memorizzata allo spegnimento dello schermo</string> + + <!-- Compose password list --> + <string name="list_new_entry">Nuova voce</string> + <string name="list_store_root">Archivio password</string> + <string name="list_more_options">Altre opzioni</string> + <string name="list_clear_search">Cancella ricerca</string> + + <!-- Compose entry view --> + <string name="entry_back">Indietro</string> + <string name="entry_show_password">Mostra password</string> + <string name="entry_hide_password">Nascondi password</string> + <string name="entry_copy_name">Copia nome</string> + + <!-- Compose folder picker --> + <string name="folder_picker_title">Scegli una cartella</string> + <string name="folder_creation_err_failed">Impossibile creare la cartella</string> + <string name="folder_picker_new">Nuova cartella</string> + + <!-- Compose editor --> + <string name="editor_new_title">Nuova voce</string> + <string name="editor_edit_title">Modifica voce</string> + <string name="editor_close">Chiudi</string> + <string name="editor_folder">Cartella</string> + <string name="editor_change_folder">Cambia</string> + <string name="editor_otp_qr">OTP da QR</string> + <string name="editor_otp_manual">OTP manuale</string> + <string name="generator_random">Casuale</string> + <string name="generator_use">Usa</string> + + <!-- Compose setup --> + <string name="ssh_key_state_none">Ancora nessuna chiave SSH</string> + <string name="ssh_key_state_imported">Chiave privata importata</string> + <string name="ssh_key_state_generated">Generata su questo dispositivo</string> + <string name="git_clone_progress">Clonazione…</string> + <string name="connection_mode_none">Nessuna</string> + <string name="ssh_key_manage">Gestisci</string> + <string name="ssh_key_public_hint">Fornisci questa chiave pubblica al tuo server Git.</string> + <string name="pgp_key_needed_message">Importa la chiave privata per cui è cifrato questo archivio, così le sue voci si possono leggere e salvare.</string> + <string name="pgp_key_needed_recipients">Destinatari dell\'archivio</string> + <string name="store_create_failed">Impossibile creare l\'archivio: %1$s</string> + <string name="store_not_empty">la cartella contiene già un archivio</string> + <string name="git_clone_delete_failed">Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita.</string> + <string name="pgp_key_import_too_large">Il file supera 1 MiB: non è una chiave PGP.</string> + <string name="pgp_key_import_downgrade">Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo.</string> + + <!-- Compose settings --> + <string name="store_delete_failed">Impossibile eliminare tutto il contenuto della cartella dell\'archivio.</string> + <string name="clear_saved_secret_message">Verrà chiesto di nuovo alla prossima operazione Git.</string> + <string name="invalid_proxy_port">Inserisci una porta da 1 a 65535, oppure niente</string> + <string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string> + <string name="git_tool_done">%1$s: completato</string> + <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string> + <string name="about">Informazioni</string> + <string name="about_summary">Un client Android per pass, il gestore di password standard di Unix. Un fork mantenuto di Android Password Store.</string> + <string name="about_version">Versione</string> + <string name="about_docs">Documentazione</string> + <string name="about_changelog">Novità</string> + <string name="about_source">Codice sorgente</string> + <string name="about_issues">Segnala un problema</string> + <string name="about_support">Sostieni su Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string> + <string name="about_certificate">Certificato di firma (SHA-256)</string> + <string name="about_license">Licenza</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright degli autori di Android Password Store e dei contributori di HardenedPass.</string> + <string name="about_libraries">Licenze open source</string> + <string name="about_opens_browser">Si apre nel browser</string> + <string name="about_no_browser">Nessun browser trovato per aprire questo link</string> </resources> diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 432b6fbe..f6535e05 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -22,7 +22,6 @@ </plurals> <string name="delete_directory_progress_text">삭제중…</string> <string name="move">이동</string> - <string name="edit">수정</string> <string name="delete">삭제</string> <string name="password_exists_title">이미 추가된 비밀번호입니다!</string> <string name="password_exists_message">%1$s 내용이 %2$s 로 덮어씌워집니다.</string> @@ -36,9 +35,7 @@ <string name="git_commit_move_multiple_text">여러개의 비밀번호를 %1$s 로 옮겼습니다.</string> <string name="git_commit_gpg_id">%1$s의 GPG ID를 초기화 합니다.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">클립보드로 복사됨</string> <string name="file_toast_text">파일 이름이 필요합니다</string> - <string name="path_toast_text">파일 경로가 필요합니다</string> <string name="empty_toast_text">비어있는 비밀번호나 추가 정보를 사용할 수 없습니다</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Git 동작 중 문제가 발생하였습니다</string> @@ -62,7 +59,6 @@ <string name="crypto_name_hint">이름</string> <string name="crypto_pass_label">비밀번호</string> <string name="crypto_extra_label">추가 내용</string> - <string name="crypto_encrypt_username_label">암호화 계정 이름</string> <string name="crypto_select">선택</string> <string name="crypto_save">저장</string> <string name="crypto_save_and_copy">저장 및 복사</string> @@ -98,8 +94,6 @@ <string name="pref_directory_structure">암허 파일 정리</string> <string name="pref_sort_order_title">암호 정렬 순서</string> <string name="pref_folder_first_sort_order">폴더 우선</string> - <string name="pref_file_first_sort_order">파일 우선</string> - <string name="pref_type_independent_sort_order">타입순</string> <string name="pref_recently_used_sort_order">최신순</string> <string name="pref_category_autofill_title">자동완성</string> <string name="pref_autofill_enable_title">자동완성 활성화</string> @@ -122,7 +116,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">암호 생성</string> <string name="pwgen_generate">생성</string> - <string name="pwgen_include">포함</string> <string name="pwgen_length">길이</string> <string name="pwgen_numerals">숫자만</string> <string name="pwgen_symbols">특수문자</string> @@ -163,7 +156,6 @@ <string name="dialog_yes">예</string> <string name="dialog_no">아니오</string> <string name="dialog_cancel">취소</string> - <string name="dialog_skip">건너뛰기</string> <string name="git_sync">저장소 동기화</string> <string name="git_pull">원격에서 pull</string> <string name="git_push">원격으로 푸시</string> @@ -171,10 +163,7 @@ <string name="git_log">커밋 로그 보기</string> <string name="show_password_pref_title">비밀번호 표시하기</string> <string name="show_password_pref_summary">암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다.</string> - <string name="pwd_generate_button">생성</string> - <string name="refresh_list">목록 새로 고침</string> <string name="send_plaintext_password_to">비밀번호를 일반 텍스트로 보내는 중…</string> - <string name="app_icon_hint">앱 아이콘</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">입력할 항목 선택</string> <string name="oreo_autofill_strict_domain_search">피싱 방지 검색</string> @@ -201,12 +190,11 @@ <string name="oreo_autofill_password_fill_support">암호 입력</string> <string name="oreo_autofill_flaky_fill_support">비밀번호 채우기 (때때로 브라우저를 다시 시작해야 할 수 있음)</string> <string name="oreo_autofill_no_support">지원하지 않음</string> - <string name="oreo_autofill_enable_dialog_description">Password Store는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string> <string name="oreo_autofill_enable_dialog_instructions">이 기능을 사용하려면 확인을 탭하여 자동 완성 설정으로 이동합니다. 목록에서 암호 저장을 선택하고 확인으로 확인 메시지를 확인합니다.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">자동 완성 지원 하는 설치된 브라우저:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">SSH 개인 키를 열 수 없습니다. 파일이 존재하는지 확인하세요</string> - <string name="new_password_title">새 암호</string> <string name="clear_saved_passphrase_ssh">로컬 SSH 키의 저장된 비밀번호 지우기</string> <string name="clear_saved_passphrase_https">저장된 HTTPS 비밀번호 지우기</string> <string name="git_operation_remember_passphrase">키 암호 기억하기</string> @@ -222,7 +210,6 @@ <string name="pref_biometric_auth_title">생체 인증 사용</string> <string name="pref_biometric_auth_summary">비밀번호 저장소를 활성화하면 앱을 시작할 때 지문을 입력하라는 메시지가 표시됩니다</string> <string name="pref_biometric_auth_summary_error">지문 하드웨어에 액세스할 수 없거나 누락된 경우</string> - <string name="pref_title_openkeystore_clear_keyid">저장된 OpenKeyStore SSH 키 ID 지우기</string> <string name="your_public_key">공개 키</string> <string name="error_generate_ssh_key">Ssh 키를 생성하는 동안 오류가 발생했습니다.</string> <string name="pref_show_hidden_title">모든 파일 및 폴더 표시</string> @@ -230,7 +217,6 @@ <string name="title_create_folder">폴더 생성</string> <string name="title_rename_folder">폴더 이름 변경</string> <string name="message_category_error_empty_field">카테고리 이름은 비워 둘 수 없습니다</string> - <string name="message_category_error_category_exists">카테고리가 이미 있습니다</string> <string name="message_error_destination_outside_repo">대상은 리포지토리 내에 있어야 합니다</string> <string name="message_rename_folder">%1$s의 목적지 입력</string> <string name="button_create">생성</string> @@ -241,7 +227,6 @@ <string name="pref_app_theme_title">앱 테마</string> <string name="pref_app_theme_value_light">밝은 테마</string> <string name="pref_app_theme_value_dark">어두운 테마</string> - <string name="pref_app_theme_value_battery_saver">배터리 절약모드 설정</string> <string name="pref_app_theme_value_follow_system">시스템 기본값</string> <string name="connection_mode_ssh_key">SSH 키</string> <string name="connection_mode_basic_authentication">비밀번호</string> @@ -278,11 +263,9 @@ username@example.com:…</string> <string name="invalid_gpg_id">.gpg-id를 찾았지만 잘못된 키 ID, 지문 또는 사용자 ID가 포함되어 있습니다</string> <string name="short_gpg_id">.gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다.</string> <string name="invalid_filename_text">파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요</string> - <string name="directory_hint">디렉토리</string> - <string name="new_folder_set_gpg_key">디렉토리의 GPG 키 설정</string> <!-- GitException messages --> <string name="git_unknown_error">알 수 없는 오류</string> - <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string> + <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 > 저장소 > 로컬 Git 설정 & 툴\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string> <string name="git_pull_merge_fail_error">병합에 실패하여 충돌 상태 입니다. 할 일: 복구 방법을 추가하세요.</string> <string name="git_push_nff_error">원격에서 푸시가 거부된 경우 다시 푸시하기 전에 풀을 실행합니다. 풀/푸시 대신 동기화를 사용하면 두 가지를 모두 구현할 수 있습니다</string> <string name="git_push_generic_error">원격에서 푸시를 거부했습니다. 원인: %1$s</string> @@ -292,16 +275,12 @@ username@example.com:…</string> <string name="git_break_out_of_detached_success">리베이스하는 동안 충돌이 발생했습니다. 로컬 %1$s 브랜치가 %2$s이라는 다른 브랜치로 푸시되었습니다.\n이 브랜치를 사용하여 컴퓨터에서 충돌을 해결하세요.</string> <string name="git_break_out_of_detached_unneeded">이 저장소는 리베이스가 아니므로 다른 브랜치로 푸시할 필요가 없습니다.</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">계속하려면 GPG 키를 선택해야 합니다</string> <string name="folder_creation_err_file_exists">동일한 이름의 파일이 이미 있습니다</string> <string name="folder_creation_err_folder_exists">이 이름의 폴더가 이미 존재합니다</string> <!-- Onboarding flow --> - <string name="let_s_go">시작합니다!</string> - <string name="select_n_repository_type">저장소 유형 \n선택</string> <string name="select_repo_type_text">로컬 리포지토리를 만들 것인지, 원격 리포지토리를 복제할 것인지 선택합니다.</string> <string name="clone_remote_repo">원격 저장소 복제</string> <string name="create_local_repo">로컬 저장소 생성</string> - <string name="select_gpg_key_title">GPG 키\n선택</string> <string name="select_gpg_key_message">스토어를 초기화할 GPG 키를 선택합니다</string> <string name="gpg_key_select">키 선택</string> <!-- SSH port validation --> @@ -322,11 +301,9 @@ username@example.com:…</string> <string name="clear_saved_host_key_success">저장된 호스트 키를 성공적으로 지웠습니다!</string> <string name="otp_import_qr_code">QR코드 스캔</string> <string name="otp_import_from_file">이미지 선택</string> - <string name="otp_import_manual_entry">직접 입력</string> <string name="otp_import_manual_hint_secret">비밀</string> <string name="otp_import_manual_hint_account">계정</string> <string name="place_shortcut_on_home_screen">홈 화면에 추가</string> - <string name="password_list_fab_content_description">새 비밀번호 또는 폴더 만들기</string> <string name="pgp_key_import_failed">PGP 키를 가져오지 못했습니다</string> <string name="pgp_key_import_failed_replace_message">이 ID를 가진 기존 키가 발견되었습니다. 교체하시겠습니까?</string> <string name="pgp_key_import_succeeded">PGP 키를 성공적으로 가져왔습니다</string> diff --git a/app/src/main/res/values-night/colors_material3.xml b/app/src/main/res/values-night/colors_material3.xml index 22e4e8f0..c26d7ec6 100644 --- a/app/src/main/res/values-night/colors_material3.xml +++ b/app/src/main/res/values-night/colors_material3.xml @@ -4,5 +4,4 @@ --> <resources> - <color name="list_multiselect_background">#66eeeeee</color> </resources> diff --git a/app/src/main/res/values-night/themes_material3.xml b/app/src/main/res/values-night/themes_material3.xml index 548afa5f..22b62088 100644 --- a/app/src/main/res/values-night/themes_material3.xml +++ b/app/src/main/res/values-night/themes_material3.xml @@ -5,32 +5,7 @@ <resources> - <style name="APSThemeM3" parent="Theme.Material3.DayNight"> - <item name="colorPrimary">@color/md_theme_dark_primary</item> - <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item> - <item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item> - <item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item> - <item name="colorSecondary">@color/md_theme_dark_secondary</item> - <item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item> - <item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item> - <item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item> - <item name="colorTertiary">@color/md_theme_dark_tertiary</item> - <item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item> - <item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item> - <item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item> - <item name="colorError">@color/md_theme_dark_error</item> - <item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item> - <item name="colorOnError">@color/md_theme_dark_onError</item> - <item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item> - <item name="android:colorBackground">@color/md_theme_dark_background</item> - <item name="colorOnBackground">@color/md_theme_dark_onBackground</item> - <item name="colorSurface">@color/md_theme_dark_surface</item> - <item name="colorOnSurface">@color/md_theme_dark_onSurface</item> - <item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item> - <item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item> - <item name="colorOutline">@color/md_theme_dark_outline</item> - <item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item> - <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item> - <item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item> + <style name="APSThemeM3" parent="android:Theme.Material.NoActionBar"> + <item name="android:colorBackground">@android:color/system_neutral2_900</item> </style> </resources> diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a915f67d..d130bc70 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -23,7 +23,6 @@ <string name="creation_dialog_text">Zanim dodasz hasło lub rozpoczniesz synchronizację, stwórz albo sklonuj poniżej nowe repozytorium.</string> <string name="delete_directory_progress_text">Usuwanie…</string> <string name="move">Przenieś</string> - <string name="edit">Edytuj</string> <string name="delete">Usuń</string> <string name="password_exists_title">Hasło już istnieje!</string> <string name="password_exists_message">%1$s zostanie nadpisane przez %2$s.</string> @@ -36,9 +35,7 @@ <string name="git_commit_move_text">Zmień nazwę %1$s na %2$s.</string> <string name="git_commit_move_multiple_text">Przenieś hasła do %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Skopiowano do schowka</string> <string name="file_toast_text">Podaj nazwę pliku</string> - <string name="path_toast_text">Podaj ścieżkę do pliku</string> <string name="empty_toast_text">Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Wystąpił błąd podczas operacji Git</string> @@ -62,7 +59,6 @@ <string name="crypto_name_hint">Nazwa</string> <string name="crypto_pass_label">Hasło</string> <string name="crypto_extra_label">Treść dodatkowa</string> - <string name="crypto_encrypt_username_label">Szyfruj nazwę użytkownika</string> <string name="crypto_select">Wybierz</string> <string name="crypto_save">Zapisz</string> <string name="crypto_save_and_copy">Zapisz i skopiuj</string> @@ -97,8 +93,6 @@ <string name="pref_directory_structure">Organizacja plików haseł</string> <string name="pref_sort_order_title">Kolejność sortowania haseł</string> <string name="pref_folder_first_sort_order">Najpierw foldery</string> - <string name="pref_file_first_sort_order">Najpierw pliki</string> - <string name="pref_type_independent_sort_order">Niezależnie od typu</string> <string name="pref_recently_used_sort_order">Ostatnio używane</string> <string name="pref_category_autofill_title">Autouzupełnianie</string> <string name="pref_autofill_enable_title">Włącz autouzupełnianie</string> @@ -123,7 +117,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Generuj hasło</string> <string name="pwgen_generate">Wygeneruj</string> - <string name="pwgen_include">Załącz</string> <string name="pwgen_length">Długość</string> <string name="pwgen_numerals">Cyfry</string> <string name="pwgen_symbols">Symbole</string> @@ -164,7 +157,6 @@ <string name="dialog_yes">Tak</string> <string name="dialog_no">Nie</string> <string name="dialog_cancel">Anuluj</string> - <string name="dialog_skip">Pomiń</string> <string name="git_sync">Synchronizuj repozytorium</string> <string name="git_pull">Pobierz ze zdalnego repozytorium</string> <string name="git_push">Wypchnij do zdalnego repozytorium</string> @@ -172,10 +164,7 @@ <string name="git_log">Pokaż dziennik commitów</string> <string name="show_password_pref_title">Pokaż hasło</string> <string name="show_password_pref_summary">Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka.</string> - <string name="pwd_generate_button">Generuj</string> - <string name="refresh_list">Odśwież listę</string> <string name="send_plaintext_password_to">Wyślij niezaszyfrowane hasło, używając…</string> - <string name="app_icon_hint">Ikona aplikacji</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Wybierz wpis do wypełnienia</string> <string name="oreo_autofill_strict_domain_search">Wyszukiwanie odporne na wyłudzenie</string> @@ -200,12 +189,11 @@ <string name="oreo_autofill_password_fill_support">Wypełnij hasła</string> <string name="oreo_autofill_flaky_fill_support">Wypełnij hasła (może wymagać ponownego uruchomienia przeglądarki od czasu do czasu)</string> <string name="oreo_autofill_no_support">Brak wsparcia</string> - <string name="oreo_autofill_enable_dialog_description">Password Store może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string> - <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz Password Store z listy i potwierdź przyciskiem OK.</string> + <string name="oreo_autofill_enable_dialog_description">HardenedPass może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string> + <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz HardenedPass z listy i potwierdź przyciskiem OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Obsługa autouzupełniania w zainstalowanych przeglądarkach:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Nie można otworzyć klucza prywatnego ssh, sprawdź czy plik istnieje</string> - <string name="new_password_title">Nowe hasło</string> <string name="clear_saved_passphrase_ssh">Wyczyść zapisane hasło dla lokalnego klucza SSH</string> <string name="clear_saved_passphrase_https">Wyczyść zapisane hasło HTTPS</string> <string name="git_operation_remember_passphrase">Zapamiętaj hasło klucza</string> @@ -219,9 +207,8 @@ <string name="biometric_prompt_title">Weryfikacja biometryczna</string> <string name="biometric_auth_error_reason">Błąd uwierzytelnienia: %s</string> <string name="pref_biometric_auth_title">Włącz uwierzytelnianie biometryczne</string> - <string name="pref_biometric_auth_summary">Gdy włączone, Password Store zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string> + <string name="pref_biometric_auth_summary">Gdy włączone, HardenedPass zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string> <string name="pref_biometric_auth_summary_error">Czytnik linii papilarnych jest niedostępny</string> - <string name="pref_title_openkeystore_clear_keyid">Wyczyść zapamiętany identyfikator klucza SSH OpenKeystore</string> <string name="your_public_key">Twój klucz publiczny</string> <string name="error_generate_ssh_key">Błąd podczas generowania klucza SSH</string> <string name="pref_show_hidden_title">Pokaż ukryte pliki i foldery</string> @@ -229,7 +216,6 @@ <string name="title_create_folder">Utwórz katalog</string> <string name="title_rename_folder">Zmień nazwę katalogu</string> <string name="message_category_error_empty_field">Nazwa kategorii nie może być pusta</string> - <string name="message_category_error_category_exists">Kategoria już istnieje</string> <string name="message_error_destination_outside_repo">Miejsce docelowe musi znajdować się w repozytorium</string> <string name="message_rename_folder">Wprowadź miejsce docelowe dla %1$s</string> <string name="button_create">Utwórz</string> @@ -240,7 +226,6 @@ <string name="pref_app_theme_title">Motyw aplikacji</string> <string name="pref_app_theme_value_light">Jasny</string> <string name="pref_app_theme_value_dark">Ciemny</string> - <string name="pref_app_theme_value_battery_saver">Ustawiany przez funkcję oszczędzania energii</string> <string name="pref_app_theme_value_follow_system">Domyślny</string> <string name="connection_mode_ssh_key">Klucz SSH</string> <string name="connection_mode_basic_authentication">Hasło</string> @@ -272,11 +257,9 @@ <string name="invalid_gpg_id">Znaleziono .gpg-id, ale zawiera nieprawidłowy identyfikator klucza, odcisk palca lub identyfikator użytkownika</string> <string name="short_gpg_id">Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany</string> <string name="invalid_filename_text">Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej</string> - <string name="directory_hint">Katalog</string> - <string name="new_folder_set_gpg_key">Ustaw klucz GPG dla katalogu</string> <!-- GitException messages --> <string name="git_unknown_error">Nieznany błąd</string> - <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia > narzędzia Git\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string> + <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia > Repozytorium > Konfiguracja lokalnego repo Git & narzędzia\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string> <string name="git_pull_merge_fail_error">Scalanie nie powiodło się, jesteś w stanie konfliktu.</string> <string name="git_push_nff_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium. Zaciągnij zmiany przed ponownym wysłaniem. Możesz użyć synchronizacji zamiast pull/push ponieważ zawiera obu te kroki</string> <string name="git_push_generic_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium, powód: %1$s</string> @@ -289,12 +272,9 @@ <string name="folder_creation_err_file_exists">Plik o tej nazwie już istnieje</string> <string name="folder_creation_err_folder_exists">Folder o tej nazwie już istnieje</string> <!-- Onboarding flow --> - <string name="let_s_go">Zaczynajmy</string> - <string name="select_n_repository_type">Wybierz \ntyp repozytorium</string> <string name="select_repo_type_text">Zaznacz, czy chcesz utworzyć lokalne repozytorium, czy sklonować zdalne repozytorium.</string> <string name="clone_remote_repo">Klonuj zdalne repozytorium</string> <string name="create_local_repo">Utwórz lokalne repozytorium</string> - <string name="select_gpg_key_title">Wybierz\nklucz GPG</string> <string name="select_gpg_key_message">Wybierz klucz GPG, którym zainicjować swoje repozytorium</string> <string name="gpg_key_select">Wybierz klucz</string> <!-- SSH port validation --> @@ -314,11 +294,9 @@ <string name="clear_saved_host_key_success">Pomyślnie wyczyszczono zapisany klucz hosta!</string> <string name="otp_import_qr_code">Skanuj kod QR</string> <string name="otp_import_from_file">Wybierz obraz</string> - <string name="otp_import_manual_entry">Wprowadź ręcznie</string> <string name="otp_import_manual_hint_secret">Sekret</string> <string name="otp_import_manual_hint_account">Konto</string> <string name="place_shortcut_on_home_screen">Utwórz skrót na pulpicie</string> - <string name="password_list_fab_content_description">Utwórz nowe hasło lub folder</string> <string name="pgp_key_import_failed">Nie udało się zaimportować klucza PGP</string> <string name="pgp_key_import_failed_replace_message">Znaleziono istniejący klucz z tym identyfikatorem, czy chcesz go zastąpić?</string> <string name="pgp_key_import_succeeded">Pomyślnie zaimportowano klucz PGP</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1d95286d..4ee08b50 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -24,7 +24,6 @@ </plurals> <string name="delete_directory_progress_text">Excluindo…</string> <string name="move">Mover</string> - <string name="edit">Editar</string> <string name="delete">Excluir</string> <string name="password_exists_title">Senha já existe!</string> <string name="password_exists_message">Isso irá sobrescrever %1$s com %2$s.</string> @@ -38,9 +37,7 @@ <string name="git_commit_move_multiple_text">Mova múltiplas senhas para %1$s.</string> <string name="git_commit_gpg_id">Inicializar IDs GPG em %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiado para a área de transferência</string> <string name="file_toast_text">Por favor, informe um nome de arquivo</string> - <string name="path_toast_text">Por favor, forneça o caminho do arquivo</string> <string name="empty_toast_text">Você não pode usar uma senha vazia ou conteúdo extra vazio</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Ocorreu um erro durante uma operação do Git</string> @@ -64,7 +61,6 @@ <string name="crypto_name_hint">Nome</string> <string name="crypto_pass_label">Senha</string> <string name="crypto_extra_label">Conteúdo extra</string> - <string name="crypto_encrypt_username_label">Criptografar nome de usuário</string> <string name="crypto_select">Selecionar</string> <string name="crypto_save">Salvar</string> <string name="crypto_save_and_copy">Salvar e Copiar</string> @@ -99,8 +95,6 @@ <string name="pref_recursive_filter_summary">Encontrar senhas do diretório corrente recursivamente.</string> <string name="pref_sort_order_title">Ordenação da Senha</string> <string name="pref_folder_first_sort_order">Pastas primeiro</string> - <string name="pref_file_first_sort_order">Arquivos primeiro</string> - <string name="pref_type_independent_sort_order">Tipo independente</string> <string name="pref_recently_used_sort_order">Usado recentemente</string> <string name="pref_category_autofill_title">Preenchimento Automático</string> <string name="pref_autofill_enable_title">Ativar preenchimento automático</string> @@ -126,7 +120,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Gerar Senha</string> <string name="pwgen_generate">Gerar</string> - <string name="pwgen_include">Incluir</string> <string name="pwgen_length">Tamanho</string> <string name="pwgen_numerals">Numerais</string> <string name="pwgen_symbols">Símbolos</string> @@ -167,7 +160,6 @@ <string name="dialog_yes">Sim</string> <string name="dialog_no">Não</string> <string name="dialog_cancel">Cancelar</string> - <string name="dialog_skip">Pular</string> <string name="git_sync">Sincronizar repositório</string> <string name="git_pull">Pull do remoto</string> <string name="git_push">Push do remoto</string> @@ -175,10 +167,7 @@ <string name="git_log">Mostrar log de commit</string> <string name="show_password_pref_title">Mostrar a senha</string> <string name="show_password_pref_summary">Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência.</string> - <string name="pwd_generate_button">Gerar</string> - <string name="refresh_list">Atualizar lista</string> <string name="send_plaintext_password_to">Enviar senha como texto simples usando…</string> - <string name="app_icon_hint">Ícone do aplicativo</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Selecione a entrada para preencher</string> <string name="oreo_autofill_strict_domain_search">Busca resistente a phishing</string> @@ -191,7 +180,7 @@ <string name="oreo_autofill_save_passwords_dont_match">As senhas não coincidem</string> <string name="oreo_autofill_generate_password">Gerar senha…</string> <string name="oreo_autofill_max_matches_reached">Número máximo de correspondências (%1$d) atingidas; limpar correspondências antes de adicionar novas.</string> - <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no Password Store:</string> + <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no HardenedPass:</string> <string name="oreo_autofill_warning_publisher_footer"><b>O aplicativo atualmente instalado pode estar tentando roubar suas credenciais fingindo ser um aplicativo confiável.</b>\n\nTente desinstalar e reinstalar o aplicativo de uma fonte confiável, como a Play Store, Amazon Appstore, F-Droid ou a loja do fabricante do seu telefone.</string> <string name="oreo_autofill_warning_publisher_install_time">Instalado: %1$s</string> <string name="oreo_autofill_warning_publisher_advanced_info_button">Informações avançadas</string> @@ -205,12 +194,11 @@ <string name="oreo_autofill_password_fill_support">Preencher as senhas</string> <string name="oreo_autofill_flaky_fill_support">Preencher senhas (pode ser necessário reiniciar o navegador de vez em quando)</string> <string name="oreo_autofill_no_support">Sem suporte</string> - <string name="oreo_autofill_enable_dialog_description">O Password Store pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string> - <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione Password Store na lista e confirme na tela confirmação com OK.</string> + <string name="oreo_autofill_enable_dialog_description">O HardenedPass pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string> + <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione HardenedPass na lista e confirme na tela confirmação com OK.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Suporte ao preenchimento automático com navegadores instalados:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Não foi possível abrir a chave privada ssh, por favor verifique se o arquivo existe</string> - <string name="new_password_title">Nova senha</string> <string name="clear_saved_passphrase_ssh">Limpar a frase secreta salva para chave SSH local</string> <string name="clear_saved_passphrase_https">Limpar senha HTTPS salva</string> <string name="git_operation_remember_passphrase">Lembrar senha da chave</string> @@ -224,9 +212,8 @@ <string name="biometric_prompt_title">Confirmação Biométrica</string> <string name="biometric_auth_error_reason">Falha de autenticação: %s</string> <string name="pref_biometric_auth_title">Ativar autenticação biométrica</string> - <string name="pref_biometric_auth_summary">Quando ativado, o Password Store irá pedir a sua impressão digital ao iniciar o aplicativo</string> + <string name="pref_biometric_auth_summary">Quando ativado, o HardenedPass irá pedir a sua impressão digital ao iniciar o aplicativo</string> <string name="pref_biometric_auth_summary_error">Hardware de impressão digital não acessível ou ausente</string> - <string name="pref_title_openkeystore_clear_keyid">Limpar ID de chave SSH lembrada do OpenKeystore</string> <string name="your_public_key">Sua chave pública</string> <string name="error_generate_ssh_key">Erro ao tentar gerar a chave SSH</string> <string name="pref_show_hidden_title">Mostrar todos os arquivos e pastas</string> @@ -234,7 +221,6 @@ <string name="title_create_folder">Criar pasta</string> <string name="title_rename_folder">Renomear pasta</string> <string name="message_category_error_empty_field">O nome da categoria não pode ser vazio</string> - <string name="message_category_error_category_exists">Nome da categoria já existe</string> <string name="message_error_destination_outside_repo">O destino deve estar dentro do repositório</string> <string name="message_rename_folder">Insira o destino para %1$s</string> <string name="button_create">Criar</string> @@ -245,7 +231,6 @@ <string name="pref_app_theme_title">Tema do aplicativo</string> <string name="pref_app_theme_value_light">Claro</string> <string name="pref_app_theme_value_dark">Escuro</string> - <string name="pref_app_theme_value_battery_saver">Configurado pela Economia de bateria</string> <string name="pref_app_theme_value_follow_system">Padrão do sistema</string> <string name="connection_mode_ssh_key">Chave SSH</string> <string name="connection_mode_basic_authentication">Senha</string> @@ -280,11 +265,9 @@ <string name="invalid_gpg_id">Encontrado .gpg-id, mas contém um ID de chave, fingerprint ou ID de usuário inválidos</string> <string name="short_gpg_id">Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada</string> <string name="invalid_filename_text">Nome do arquivo não deve conter \'/\', defina o diretório acima</string> - <string name="directory_hint">Diretório</string> - <string name="new_folder_set_gpg_key">Definir chave GPG para diretório</string> <!-- GitException messages --> <string name="git_unknown_error">Erro desconhecido</string> - <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"configurações > utils\" do git, salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string> + <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"Configurações > Repositório > Configuração local do Git & utilitários\", salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string> <string name="git_pull_merge_fail_error">Falha ao realizar o merge, você está em estado de conflito.</string> <string name="git_push_nff_error">Push rejeitado pelo remoto, execute o pull antes de fazer push novamente. Você pode usar Sincronização em vez de pull/push conforme implementa ambos</string> <string name="git_push_generic_error">Push rejeitado pelo remoto, razão: %1$s</string> @@ -294,16 +277,12 @@ <string name="git_break_out_of_detached_success">Houve um conflito ao executar o rebase. Houve um push de branch %1$s local para outra branch chamada %2$s\n Use esta branch para resolver o conflito no seu computador</string> <string name="git_break_out_of_detached_unneeded">O repositório não está sendo rebased, não há necessidade de fazer push para outro branch</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">A seleção de uma chave GPG é necessária para prosseguir</string> <string name="folder_creation_err_file_exists">Já existe um arquivo com esse nome</string> <string name="folder_creation_err_folder_exists">Já existe uma pasta com esse nome</string> <!-- Onboarding flow --> - <string name="let_s_go">Vamos lá</string> - <string name="select_n_repository_type">Selecione \nTipo de Repositório</string> <string name="select_repo_type_text">Selecione se você quer criar um repositório local ou clonar um repositório remoto.</string> <string name="clone_remote_repo">Clonar repositório remoto</string> <string name="create_local_repo">Criar repositório local</string> - <string name="select_gpg_key_title">Selecione\nChave GPG</string> <string name="select_gpg_key_message">Selecione uma chave GPG para inicializar o armazenamento com</string> <string name="gpg_key_select">Selecione a chave</string> <!-- SSH port validation --> @@ -324,11 +303,9 @@ <string name="clear_saved_host_key_success">Chave de host limpa com sucesso!</string> <string name="otp_import_qr_code">Escanear QR code</string> <string name="otp_import_from_file">Selecione uma imagem</string> - <string name="otp_import_manual_entry">Inserir manualmente</string> <string name="otp_import_manual_hint_secret">Segredo</string> <string name="otp_import_manual_hint_account">Conta</string> <string name="place_shortcut_on_home_screen">Criar atalho na tela inicial</string> - <string name="password_list_fab_content_description">Criar nova senha ou pasta</string> <string name="pgp_key_import_failed">Falha ao importar a chave PGP</string> <string name="pgp_key_import_failed_replace_message">Uma chave existente com esse ID foi encontrada, você deseja substituí-lo?</string> <string name="pgp_key_import_succeeded">Chave PGP importada com sucesso</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c10bdcd8..a966640d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -28,7 +28,6 @@ </plurals> <string name="delete_directory_progress_text">Удаляется…</string> <string name="move">Переместить</string> - <string name="edit">Редактировать</string> <string name="delete">Удалить</string> <string name="password_exists_title">Пароль уже существует!</string> <string name="password_exists_message">Это перезапишет %1$s на %2$s</string> @@ -42,9 +41,7 @@ <string name="git_commit_move_multiple_text">Переместить несколько паролей в %1$s.</string> <string name="git_commit_gpg_id">Инициализация GPG ID через %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Скопировано в буфер обмена</string> <string name="file_toast_text">Пожалуйста, укажите имя файла</string> - <string name="path_toast_text">Пожалуйста, задайте путь к файлу</string> <string name="empty_toast_text">Вы не можете использовать пустой пароль или пустое поле информации</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Произошла ошибка выполнения операции Git</string> @@ -68,7 +65,6 @@ <string name="crypto_name_hint">название</string> <string name="crypto_pass_label">Пароль</string> <string name="crypto_extra_label">Дополнительно</string> - <string name="crypto_encrypt_username_label">Зашифровать имя пользователя</string> <string name="crypto_select">Выбрать</string> <string name="crypto_save">Сохранить</string> <string name="crypto_save_and_copy">Сохранить и скопировать</string> @@ -102,8 +98,6 @@ <string name="pref_recursive_filter_summary">Рекурсивный поиск паролей в текущей директории</string> <string name="pref_sort_order_title">Порядок сортировки паролей</string> <string name="pref_folder_first_sort_order">Сначала папки</string> - <string name="pref_file_first_sort_order">Сначала файлы</string> - <string name="pref_type_independent_sort_order">Типонезависимый</string> <string name="pref_recently_used_sort_order">Недавно использованные</string> <string name="pref_category_autofill_title">Автозаполнение</string> <string name="pref_autofill_enable_title">Включить автозаполнение</string> @@ -119,7 +113,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Сгенерировать пароль</string> <string name="pwgen_generate">Сгенерировать</string> - <string name="pwgen_include">Включая</string> <string name="pwgen_length">Длина</string> <string name="pwgen_numerals">Цифры</string> <string name="pwgen_symbols">Символы</string> @@ -160,7 +153,6 @@ <string name="dialog_yes">Да</string> <string name="dialog_no">Нет</string> <string name="dialog_cancel">Отмена</string> - <string name="dialog_skip">Пропустить</string> <string name="git_sync">Синхронизировать репозиторий</string> <string name="git_pull">Пулл с удаленного сервера</string> <string name="git_push">Пуш на удаленный сервер</string> @@ -168,10 +160,7 @@ <string name="git_log">Показать журнал изменений</string> <string name="show_password_pref_title">Показывать пароли</string> <string name="show_password_pref_summary">Видимость расшифрованных паролей, не влияет на копирование в буфер.</string> - <string name="pwd_generate_button">Сгенерировать</string> - <string name="refresh_list">Обновить список</string> <string name="send_plaintext_password_to">Поделиться паролем в открытом виде с помощью</string> - <string name="app_icon_hint">Иконка приложения</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Выберите запись для заполнения</string> <string name="oreo_autofill_strict_domain_search">Устойчивый к фишингу поиск</string> @@ -199,11 +188,10 @@ <string name="oreo_autofill_flaky_fill_support">Заполнить учетные данные (время от времени может требоваться перезапуск браузера)</string> <string name="oreo_autofill_no_support">Не поддерживается</string> <string name="oreo_autofill_enable_dialog_description">Хранилище паролей может предложить заполнить формы входа и даже сохранить учетные данные в приложениях или на веб-сайтах.</string> - <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите Password Store из списка и подтвердите запрос подтверждения, нажав ОК.</string> + <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите HardenedPass из списка и подтвердите запрос подтверждения, нажав ОК.</string> <string name="oreo_autofill_enable_dialog_installed_browsers">Поддержка автозаполнения установленными браузерами:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">Невозможно открыть приватный ключ ssh, пожалуйста проверьте, что файл существует</string> - <string name="new_password_title">Новый пароль</string> <string name="clear_saved_passphrase_ssh">Очистить сохраненную кодовую фразу для локального SSH ключа</string> <string name="clear_saved_passphrase_https">Удалить сохраненный пароль HTTPS</string> <string name="git_operation_remember_passphrase">Запомнить кодовую фразу</string> @@ -217,9 +205,8 @@ <string name="biometric_prompt_title">Запрос биометрии</string> <string name="biometric_auth_error_reason">Ошибка аутентификации: %s</string> <string name="pref_biometric_auth_title">Включить биометрическую аутентификацию</string> - <string name="pref_biometric_auth_summary">Когда ключено, Password Store будет запрашивать ваш опечаток пальца при каждом запуске приложения</string> + <string name="pref_biometric_auth_summary">Когда ключено, HardenedPass будет запрашивать ваш опечаток пальца при каждом запуске приложения</string> <string name="pref_biometric_auth_summary_error">Сенсор отпечатка пальца не доступен или отсутствует</string> - <string name="pref_title_openkeystore_clear_keyid">Очистить сохраненный SSH Key идентификатор OpenKystortore</string> <string name="your_public_key">Ваш публичный ключ</string> <string name="error_generate_ssh_key">Возникла ошибка при попытке генерации ssh ключа</string> <string name="pref_show_hidden_title">Показать все файлы и директории</string> @@ -227,7 +214,6 @@ <string name="title_create_folder">Создать папку</string> <string name="title_rename_folder">Переименовать папку</string> <string name="message_category_error_empty_field">Название категории не может быть пустым</string> - <string name="message_category_error_category_exists">Название категории уже существует</string> <string name="message_error_destination_outside_repo">Путь должен указывать на область внутри репозитория</string> <string name="message_rename_folder">Введите адрес назначения для %1$s</string> <string name="button_create">Создать</string> @@ -238,7 +224,6 @@ <string name="pref_app_theme_title">Тема приложения</string> <string name="pref_app_theme_value_light">Светлая</string> <string name="pref_app_theme_value_dark">Темная</string> - <string name="pref_app_theme_value_battery_saver">Задается экономией батареи</string> <string name="pref_app_theme_value_follow_system">Системная</string> <string name="connection_mode_ssh_key">SSH ключ</string> <string name="connection_mode_basic_authentication">Пароль</string> @@ -272,11 +257,9 @@ <string name="exporting_passwords">Экспорт паролей…</string> <string name="invalid_gpg_id">Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя</string> <string name="invalid_filename_text">Имя файла не должно содержать \'/\', укажите директорию выше</string> - <string name="directory_hint">Директория</string> - <string name="new_folder_set_gpg_key">Установить GPG ключ для каталога</string> <!-- GitException messages --> <string name="git_unknown_error">Неизвестная ошибка</string> - <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string> + <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"Настройки > Репозиторий > Конфигурация локального Git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string> <string name="git_pull_merge_fail_error">Слияние не удалось, вы находитесь в конфликтующем состоянии. ВТО: Добавьте метод восстановления.</string> <string name="git_push_nff_error">Запись изменений была отклонена удаленным репозиторием, сначала пполучите изменения перед повторной записью. Вы можете использовать Синхронизацию вместо получения/записи изменений, т.к. она реализует оба подхда.</string> <string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина: %1$s</string> @@ -286,16 +269,12 @@ <string name="git_break_out_of_detached_success">При попытке rebase\'а возник конфликт. Ваша локальная ветка %1$s была запушена %2$s\n Используйте эту ветку для разрешения конфликтов с помощью компьютера</string> <string name="git_break_out_of_detached_unneeded">Не нужно пушить в другую ветку - rebase не был запущен</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Для продолжения необходимо выбрать GPG-ключ</string> <string name="folder_creation_err_file_exists">Файл с таким именем уже существует</string> <string name="folder_creation_err_folder_exists">Папка с таким именем уже существует</string> <!-- Onboarding flow --> - <string name="let_s_go">Поехали</string> - <string name="select_n_repository_type">Выберите \nтип репозитория</string> <string name="select_repo_type_text">Создать локальное хранилище, или клонировать удаленный репозиторий.</string> <string name="clone_remote_repo">Клонировать удаленный репозиторий</string> <string name="create_local_repo">Создать локальное хранилище</string> - <string name="select_gpg_key_title">Выберите\nGPG ключ</string> <string name="select_gpg_key_message">Выберите GPG ключ для инициализации хранилища</string> <string name="gpg_key_select">Выберите ключ</string> <!-- SSH port validation --> @@ -316,10 +295,8 @@ <string name="clear_saved_host_key_success">Сохраненный ключ хоста был успешно удален!</string> <string name="otp_import_qr_code">Отсканировать QR-код</string> <string name="otp_import_from_file">Выберите изображение</string> - <string name="otp_import_manual_entry">Ввести вручную</string> <string name="otp_import_manual_hint_account">Учетная запись</string> <string name="place_shortcut_on_home_screen">Разместить ярлык на рабочем столе</string> - <string name="password_list_fab_content_description">Создать новый пароль или папку</string> <string name="pgp_key_import_failed">Не удалось импортировать PGP-ключ</string> <string name="pgp_key_import_succeeded">PGP ключ успешно импортирован</string> </resources> diff --git a/app/src/main/res/values-v29/arrays.xml b/app/src/main/res/values-v29/arrays.xml deleted file mode 100644 index aa78032c..00000000 --- a/app/src/main/res/values-v29/arrays.xml +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <string-array name="app_theme_options"> - <item>@string/pref_app_theme_value_light</item> - <item>@string/pref_app_theme_value_dark</item> - <item>@string/pref_app_theme_value_follow_system</item> - </string-array> - <string-array name="app_theme_values"> - <item>light</item> - <item>dark</item> - <item>follow_system</item> - </string-array> -</resources> diff --git a/app/src/main/res/values-v29/prefs.xml b/app/src/main/res/values-v29/prefs.xml deleted file mode 100644 index 383b5f8b..00000000 --- a/app/src/main/res/values-v29/prefs.xml +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <string name="app_theme_def" translatable="false">follow_system</string> -</resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b20749b3..dae54d05 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,7 +23,6 @@ </plurals> <string name="delete_directory_progress_text">正在删除…</string> <string name="move">更多</string> - <string name="edit">编辑</string> <string name="delete">删除</string> <string name="password_exists_title">密码已经存在!</string> <string name="password_exists_message">这将用%2$s覆盖%1$s</string> @@ -37,9 +36,7 @@ <string name="git_commit_move_multiple_text">将多个密码移至%1$s</string> <string name="git_commit_gpg_id">初始化 %1$s 中的 GPG ID</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">复制到剪切板</string> <string name="file_toast_text">请提供文件名</string> - <string name="path_toast_text">请提供文件路径</string> <string name="empty_toast_text">您不能使用空密码或空额外内容</string> <!-- Git Async Task --> <string name="jgit_error_dialog_title">Git操作过程中发生错误</string> @@ -63,7 +60,6 @@ <string name="crypto_name_hint">名称</string> <string name="crypto_pass_label">密码</string> <string name="crypto_extra_label">附加内容</string> - <string name="crypto_encrypt_username_label">加密用户名</string> <string name="crypto_select">选择</string> <string name="crypto_save">保存</string> <string name="crypto_save_and_copy">保存并复制</string> @@ -98,8 +94,6 @@ <string name="pref_recursive_filter_summary">递归式寻找当前目录的密码</string> <string name="pref_sort_order_title">密码排序顺序</string> <string name="pref_folder_first_sort_order">目录优先</string> - <string name="pref_file_first_sort_order">文件优先</string> - <string name="pref_type_independent_sort_order">类型无关</string> <string name="pref_recently_used_sort_order">最近使用</string> <string name="pref_category_autofill_title">自动填充</string> <string name="pref_autofill_enable_title">启用自动填充</string> @@ -122,7 +116,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">生成密码</string> <string name="pwgen_generate">生成</string> - <string name="pwgen_include">包含</string> <string name="pwgen_length">长度</string> <string name="pwgen_numerals">数字</string> <string name="pwgen_symbols">标识</string> @@ -168,7 +161,6 @@ <string name="dialog_yes">同意</string> <string name="dialog_no">否定</string> <string name="dialog_cancel">取消</string> - <string name="dialog_skip">跳过</string> <string name="git_sync">同步存储库</string> <string name="git_pull">拉取远程数据</string> <string name="git_push">推送数据到远程</string> @@ -176,10 +168,7 @@ <string name="git_log">显示提交日志</string> <string name="show_password_pref_title">显示密码</string> <string name="show_password_pref_summary">控制密码解密后的可见性,这不会禁用复制到剪贴板</string> - <string name="pwd_generate_button">生成</string> - <string name="refresh_list">刷新列表</string> <string name="send_plaintext_password_to">使用明文发送密码…</string> - <string name="app_icon_hint">应用图标</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">选择要填写的条目</string> <string name="oreo_autofill_strict_domain_search">防钓鱼搜索</string> @@ -212,7 +201,6 @@ <string name="oreo_autofill_enable_dialog_installed_browsers">已安装支持自动填充的浏览器:</string> <!-- Autofill --> <string name="ssh_key_does_not_exist">无法打开SSH私钥,请检查文件是否存在</string> - <string name="new_password_title">新密码</string> <string name="clear_saved_passphrase_ssh">清除本地已保存的SSH密钥密码</string> <string name="clear_saved_passphrase_https">清除保存的HTTPS密码</string> <string name="git_operation_remember_passphrase">记住密钥密码</string> @@ -225,9 +213,8 @@ <string name="biometric_prompt_title">生物识别提示</string> <string name="biometric_auth_error_reason">验证失败: %s</string> <string name="pref_biometric_auth_title">启用生物识别认证</string> - <string name="pref_biometric_auth_summary">如果启用,Password Store 将在启动应用时提示您使用指纹进行身份验证</string> + <string name="pref_biometric_auth_summary">如果启用,HardenedPass 将在启动应用时提示您使用指纹进行身份验证</string> <string name="pref_biometric_auth_summary_error">指纹硬件无法访问或丢失</string> - <string name="pref_title_openkeystore_clear_keyid">清除记住的 OpenKeystore SSH 密钥ID</string> <string name="your_public_key">你的公开密钥</string> <string name="error_generate_ssh_key">尝试生成SSH密钥时出错</string> <string name="pref_show_hidden_title">显示所有文件和目录</string> @@ -235,7 +222,6 @@ <string name="title_create_folder">清除目录</string> <string name="title_rename_folder">重命名目录</string> <string name="message_category_error_empty_field">类别名称不能为空</string> - <string name="message_category_error_category_exists">类别名称已存在</string> <string name="message_error_destination_outside_repo">目标必须在存储库中</string> <string name="message_rename_folder">输入%1$s的目标</string> <string name="button_create">创建</string> @@ -246,7 +232,6 @@ <string name="pref_app_theme_title">应用主题</string> <string name="pref_app_theme_value_light">浅色</string> <string name="pref_app_theme_value_dark">深色</string> - <string name="pref_app_theme_value_battery_saver">由省电模式设定</string> <string name="pref_app_theme_value_follow_system">系统默认</string> <string name="connection_mode_ssh_key">SSH密钥</string> <string name="connection_mode_basic_authentication">密码</string> @@ -282,8 +267,6 @@ personal.com</string> <string name="invalid_gpg_id">找到了 .gpg-id,但它包含无效的密钥ID、指纹或用户ID</string> <string name="short_gpg_id">找到 .gpg-id 但它包含一个不受支持的短十六进制ID。</string> <string name="invalid_filename_text">文件名不能包含\'/\'</string> - <string name="directory_hint">目录</string> - <string name="new_folder_set_gpg_key">为目录设置GPG密钥</string> <!-- GitException messages --> <string name="git_unknown_error">未知错误</string> <string name="git_pull_merge_fail_error">合并失败,你处于冲突状态。</string> @@ -296,18 +279,12 @@ personal.com</string> 使用此分支解决计算机上的冲突</string> <string name="git_break_out_of_detached_unneeded">存储库没有变更,无需推送到另一个分支</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">选择一个 GPG 密钥才能继续</string> <string name="folder_creation_err_file_exists">该名称的文件已存在</string> <string name="folder_creation_err_folder_exists">该名称的文件夹已存在</string> <!-- Onboarding flow --> - <string name="let_s_go">开始配置</string> - <string name="select_n_repository_type">选择仓库模式</string> <string name="select_repo_type_text">你是要同步仓库还是现在创建一个仓库呢?</string> <string name="clone_remote_repo">同步远程仓库</string> <string name="create_local_repo">创建本地仓库</string> - <string name="select_gpg_key_title">选择 -GPG -密钥</string> <string name="select_gpg_key_message">选择一个GPG密钥来初始化您的仓库</string> <string name="gpg_key_select">选择密钥</string> <!-- SSH port validation --> @@ -329,11 +306,9 @@ GPG <string name="clear_saved_host_key_success">已成功清除已保存的主机密钥!</string> <string name="otp_import_qr_code">扫描二维码</string> <string name="otp_import_from_file">选择图像</string> - <string name="otp_import_manual_entry">手动输入</string> <string name="otp_import_manual_hint_secret">安全码 (Secret)</string> <string name="otp_import_manual_hint_account">账户</string> <string name="place_shortcut_on_home_screen">在主屏幕上放置快捷方式</string> - <string name="password_list_fab_content_description">创建新密码或文件夹</string> <string name="pgp_key_import_failed">导入 PGP 密钥失败</string> <string name="pgp_key_import_failed_replace_message">发现了 ID 相同的现有密钥,您要替换它吗?</string> <string name="pgp_key_import_succeeded">成功导入 PGP 密钥</string> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9f44710b..dde1a99f 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,14 +6,10 @@ <resources> <string-array name="sort_order_entries"> <item>@string/pref_folder_first_sort_order</item> - <item>@string/pref_file_first_sort_order</item> - <item>@string/pref_type_independent_sort_order</item> <item>@string/pref_recently_used_sort_order</item> </string-array> <string-array name="sort_order_values"> <item>FOLDER_FIRST</item> - <item>FILE_FIRST</item> - <item>INDEPENDENT</item> <item>RECENTLY_USED</item> </string-array> <string-array name="pwgen_provider_labels"> @@ -37,12 +33,12 @@ <string-array name="app_theme_options"> <item>@string/pref_app_theme_value_light</item> <item>@string/pref_app_theme_value_dark</item> - <item>@string/pref_app_theme_value_battery_saver</item> + <item>@string/pref_app_theme_value_follow_system</item> </string-array> <string-array name="app_theme_values"> <item>light</item> <item>dark</item> - <item>battery_saver</item> + <item>follow_system</item> </string-array> </resources> diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml deleted file mode 100644 index a1d3c974..00000000 --- a/app/src/main/res/values/attrs.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <declare-styleable name="Multiselected"> - <attr name="state_multiselected" format="boolean" /> - </declare-styleable> -</resources> diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml index 3a7341b5..47ffbc6a 100644 --- a/app/src/main/res/values/colors_material3.xml +++ b/app/src/main/res/values/colors_material3.xml @@ -5,60 +5,5 @@ <resources> <color name="md_theme_light_primary">#006591</color> - <color name="md_theme_light_onPrimary">#FFFFFF</color> - <color name="md_theme_light_primaryContainer">#C7E6FF</color> - <color name="md_theme_light_onPrimaryContainer">#001E30</color> - <color name="md_theme_light_secondary">#4F606E</color> - <color name="md_theme_light_onSecondary">#FFFFFF</color> - <color name="md_theme_light_secondaryContainer">#D3E5F5</color> - <color name="md_theme_light_onSecondaryContainer">#0B1D29</color> - <color name="md_theme_light_tertiary">#006494</color> - <color name="md_theme_light_onTertiary">#FFFFFF</color> - <color name="md_theme_light_tertiaryContainer">#C8E6FF</color> - <color name="md_theme_light_onTertiaryContainer">#001E31</color> - <color name="md_theme_light_error">#BA1B1B</color> - <color name="md_theme_light_errorContainer">#FFDAD4</color> - <color name="md_theme_light_onError">#FFFFFF</color> - <color name="md_theme_light_onErrorContainer">#410001</color> - <color name="md_theme_light_background">#FCFCFF</color> - <color name="md_theme_light_onBackground">#1A1C1E</color> - <color name="md_theme_light_surface">#FCFCFF</color> - <color name="md_theme_light_onSurface">#1A1C1E</color> - <color name="md_theme_light_surfaceVariant">#DDE3EA</color> - <color name="md_theme_light_onSurfaceVariant">#41474D</color> - <color name="md_theme_light_outline">#72787E</color> - <color name="md_theme_light_inverseOnSurface">#F0F0F3</color> - <color name="md_theme_light_inverseSurface">#2E3133</color> - <color name="md_theme_light_primaryInverse">#85CEFF</color> - <color name="md_theme_dark_primary">#85CEFF</color> - <color name="md_theme_dark_onPrimary">#00344E</color> - <color name="md_theme_dark_primaryContainer">#004C6F</color> - <color name="md_theme_dark_onPrimaryContainer">#C7E6FF</color> - <color name="md_theme_dark_secondary">#B7C9D9</color> - <color name="md_theme_dark_onSecondary">#21323E</color> - <color name="md_theme_dark_secondaryContainer">#384956</color> - <color name="md_theme_dark_onSecondaryContainer">#D3E5F5</color> - <color name="md_theme_dark_tertiary">#8ACEFF</color> - <color name="md_theme_dark_onTertiary">#003450</color> - <color name="md_theme_dark_tertiaryContainer">#004B70</color> - <color name="md_theme_dark_onTertiaryContainer">#C8E6FF</color> - <color name="md_theme_dark_error">#FFB4A9</color> - <color name="md_theme_dark_errorContainer">#930006</color> - <color name="md_theme_dark_onError">#680003</color> - <color name="md_theme_dark_onErrorContainer">#FFDAD4</color> - <color name="md_theme_dark_background">#1A1C1E</color> - <color name="md_theme_dark_onBackground">#E1E2E5</color> - <color name="md_theme_dark_surface">#1A1C1E</color> - <color name="md_theme_dark_onSurface">#E1E2E5</color> - <color name="md_theme_dark_surfaceVariant">#41474D</color> - <color name="md_theme_dark_onSurfaceVariant">#C1C7CE</color> - <color name="md_theme_dark_outline">#8B9197</color> - <color name="md_theme_dark_inverseOnSurface">#1A1C1E</color> - <color name="md_theme_dark_inverseSurface">#E1E2E5</color> - <color name="md_theme_dark_primaryInverse">#006591</color> - <color name="seed">#003E5B</color> - <color name="error">#BA1B1B</color> - <!-- Multiselect color --> - <color name="list_multiselect_background">#668eacbb</color> </resources> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index d6939e63..00000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,16 +0,0 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <!-- Default screen margins, per the Android Design guidelines. --> - <dimen name="activity_horizontal_margin">16dp</dimen> - <dimen name="activity_vertical_margin">16dp</dimen> - <dimen name="fab_compat_margin">16dp</dimen> - <dimen name="normal_margin">8dp</dimen> - <dimen name="bottom_sheet_item_height">56dp</dimen> - <dimen name="onboarding_icon_margin_top">100dp</dimen> - <dimen name="onboarding_button_margin_top">140dp</dimen> - <dimen name="onboarding_desc_margin_top">48dp</dimen> -</resources> diff --git a/app/src/main/res/values/prefs.xml b/app/src/main/res/values/prefs.xml index a5977d9d..383b5f8b 100644 --- a/app/src/main/res/values/prefs.xml +++ b/app/src/main/res/values/prefs.xml @@ -4,5 +4,5 @@ --> <resources> - <string name="app_theme_def" translatable="false">battery_saver</string> + <string name="app_theme_def" translatable="false">follow_system</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07f24aa5..c8782fad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,6 @@ <string name="dialog_delete">Delete directory</string> <string name="dialog_do_not_delete">Cancel</string> <string name="title_activity_git_clone">Repository information</string> - <string name="title_activity_git_config" translatable="false">Git configuration</string> <string name="title_activity_git_log">Commit log</string> <!-- Password Store --> @@ -30,7 +29,6 @@ </plurals> <string name="delete_directory_progress_text">Deleting…</string> <string name="move">Move</string> - <string name="edit">Edit</string> <string name="delete">Delete</string> <string name="password_exists_title">Password already exists!</string> <string name="password_exists_message">This will overwrite %1$s with %2$s.</string> @@ -46,9 +44,7 @@ <string name="git_commit_gpg_id">Initialize GPG IDs in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copied to clipboard</string> <string name="file_toast_text">Please provide a file name</string> - <string name="path_toast_text">Please provide a file path</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string> <!-- Git Async Task --> @@ -80,7 +76,6 @@ <string name="crypto_username_hint">Username</string> <string name="crypto_pass_label">Password</string> <string name="crypto_extra_label">Extra content</string> - <string name="crypto_encrypt_username_label">Encrypt username</string> <string name="crypto_select">Select</string> <string name="crypto_save">Save</string> <string name="crypto_save_and_copy">Save and Copy</string> @@ -119,8 +114,6 @@ <string name="pref_directory_structure">Password file organization</string> <string name="pref_sort_order_title">Password sort order</string> <string name="pref_folder_first_sort_order">Folders first</string> - <string name="pref_file_first_sort_order">Files first</string> - <string name="pref_type_independent_sort_order">Type independent</string> <string name="pref_recently_used_sort_order">Recently used</string> <string name="pref_category_autofill_title">Autofill</string> <string name="pref_autofill_enable_title">Enable Autofill</string> @@ -149,7 +142,6 @@ <!-- PasswordGenerator fragment --> <string name="pwgen_title">Generate Password</string> <string name="pwgen_generate">Generate</string> - <string name="pwgen_include">Include</string> <string name="pwgen_length">Length</string> <string name="pwgen_numerals">Numerals</string> <string name="pwgen_symbols">Symbols</string> @@ -195,7 +187,6 @@ <string name="dialog_yes">Yes</string> <string name="dialog_no">No</string> <string name="dialog_cancel">Cancel</string> - <string name="dialog_skip">Skip</string> <string name="git_sync">Synchronize repository</string> <string name="git_pull">Pull from remote</string> <string name="git_push">Push to remote</string> @@ -203,10 +194,7 @@ <string name="git_log">Show commit log</string> <string name="show_password_pref_title">Show the password</string> <string name="show_password_pref_summary">Control the visibility of the passwords once decrypted. This does not disable copying to clipboard.</string> - <string name="pwd_generate_button">Generate</string> - <string name="refresh_list">Refresh list</string> <string name="send_plaintext_password_to">Send password as plaintext using…</string> - <string name="app_icon_hint">App icon</string> <!-- Oreo Autofill --> <string name="oreo_autofill_select_and_fill_into">Select entry to fill into</string> @@ -223,7 +211,7 @@ <string name="oreo_autofill_warning_publisher_header">This app\'s publisher has changed since you first associated a HardenedPass entry with it:</string> <string name="oreo_autofill_warning_publisher_footer"><b>The currently installed app may be trying to steal your credentials by pretending to be a trusted app.</b>\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store.</string> <string name="oreo_autofill_warning_publisher_install_time">Installed: %1$s</string> - <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s“</string> + <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s”</string> <string name="oreo_autofill_warning_publisher_advanced_info_template" translatable="false">Package:\n%1$s\n\nHash:\n%2$s</string> <string name="oreo_autofill_warning_publisher_advanced_info_button">Advanced information</string> <string name="oreo_autofill_warning_publisher_changed_disable_autofill_button">Keep Autofill disabled</string> @@ -242,7 +230,6 @@ <!-- Autofill --> <string name="ssh_key_does_not_exist">Unable to open the ssh private key, please check that the file exists</string> - <string name="new_password_title">New password</string> <string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string> <string name="clear_saved_passphrase_https">Clear saved HTTPS password</string> <string name="git_operation_remember_passphrase">Remember key passphrase</string> @@ -258,8 +245,6 @@ <string name="pref_biometric_auth_title">Enable biometric authentication</string> <string name="pref_biometric_auth_summary">When enabled, HardenedPass will prompt you for your fingerprint when launching the app</string> <string name="pref_biometric_auth_summary_error">Fingerprint hardware not accessible or missing</string> - <string name="pref_biometric_auth_summary_disabled_platform">Feature unavailable due to a platform bug</string> - <string name="pref_title_openkeystore_clear_keyid">Clear remembered OpenKeystore SSH Key ID</string> <string name="your_public_key">Your public key</string> <string name="error_generate_ssh_key">Error while trying to generate the ssh-key</string> <string name="pref_show_hidden_title">Show all files and folders</string> @@ -267,7 +252,6 @@ <string name="title_create_folder">Create folder</string> <string name="title_rename_folder">Rename folder</string> <string name="message_category_error_empty_field">Category name can\'t be empty</string> - <string name="message_category_error_category_exists">Category name already exists</string> <string name="message_error_destination_outside_repo">Destination must be within the repository</string> <string name="message_rename_folder">Enter destination for %1$s</string> <string name="button_create">Create</string> @@ -278,7 +262,6 @@ <string name="pref_app_theme_title">App theme</string> <string name="pref_app_theme_value_light">Light</string> <string name="pref_app_theme_value_dark">Dark</string> - <string name="pref_app_theme_value_battery_saver">Set by Battery Saver</string> <string name="pref_app_theme_value_follow_system">System default</string> <string name="connection_mode_ssh_key">SSH key</string> <string name="connection_mode_basic_authentication">Password</string> @@ -315,12 +298,10 @@ <string name="invalid_gpg_id">Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID</string> <string name="short_gpg_id">Found .gpg-id, but it contains a short hex ID, which is not supported</string> <string name="invalid_filename_text">File name must not contain \'/\', set directory above</string> - <string name="directory_hint">Directory</string> - <string name="new_folder_set_gpg_key">Set GPG key for directory</string> <!-- GitException messages --> <string name="git_unknown_error">Unknown error</string> - <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using "settings > git utils", save your changes to the remote in a new branch and resolve the conflict on your computer.</string> + <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using \"Settings > Repository > Local Git config & utilities\", save your changes to the remote in a new branch and resolve the conflict on your computer.</string> <string name="git_pull_merge_fail_error">Merge has failed, you\'re in a conflicting state.</string> <string name="git_push_nff_error">Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both</string> @@ -332,17 +313,13 @@ <string name="git_break_out_of_detached_unneeded">The repository is not rebasing, no need to push to another branch</string> <!-- GPG key selection in folder creation --> - <string name="gpg_key_select_mandatory">Selecting a GPG key is necessary to proceed</string> <string name="folder_creation_err_file_exists">A file by that name already exists</string> <string name="folder_creation_err_folder_exists">A folder by that name already exists</string> <!-- Onboarding flow --> - <string name="let_s_go">Let\'s Go</string> - <string name="select_n_repository_type">Select \nRepository Type</string> <string name="select_repo_type_text">Select if you want to create a local repo or clone a remote repo.</string> <string name="clone_remote_repo">Clone Remote Repo</string> <string name="create_local_repo">Create Local Repo</string> - <string name="select_gpg_key_title">Select\nGPG Key</string> <string name="select_gpg_key_message">Select a GPG key to initialize your store with</string> <string name="gpg_key_select">Select key</string> @@ -365,11 +342,9 @@ <string name="clear_saved_host_key_success">Successfully cleared saved host key!</string> <string name="otp_import_qr_code">Scan QR code</string> <string name="otp_import_from_file">Choose an image</string> - <string name="otp_import_manual_entry">Enter manually</string> <string name="otp_import_manual_hint_secret">Secret</string> <string name="otp_import_manual_hint_account">Account</string> <string name="place_shortcut_on_home_screen">Place shortcut on home screen</string> - <string name="password_list_fab_content_description">Create new password or folder</string> <string name="pgp_key_import_failed">Failed to import PGP key</string> <string name="pgp_key_import_failed_replace_message">An existing key with this ID was found, do you want to replace it?</string> <string name="pgp_key_import_succeeded">Successfully imported PGP key</string> @@ -385,4 +360,73 @@ <string name="no_keys_imported_dialog_message">There are no PGP keys imported in the app yet, press the button below to pick a key file</string> <string name="biometric_prompt_title_gpg_passphrase_cache">Unlock passphrase cache</string> <string name="clear_cached_password_on_screen_off">Clear cached password on screen-off</string> + + <!-- Compose password list --> + <string name="list_new_entry">New entry</string> + <string name="list_store_root">Password store</string> + <string name="list_more_options">More options</string> + <string name="list_clear_search">Clear search</string> + + <!-- Compose entry view --> + <string name="entry_back">Back</string> + <string name="entry_show_password">Show password</string> + <string name="entry_hide_password">Hide password</string> + <string name="entry_copy_name">Copy name</string> + + <!-- Compose folder picker --> + <string name="folder_picker_title">Choose a folder</string> + <string name="folder_creation_err_failed">Could not create the folder</string> + <string name="folder_picker_new">New folder</string> + + <!-- Compose editor --> + <string name="editor_new_title">New entry</string> + <string name="editor_edit_title">Edit entry</string> + <string name="editor_close">Close</string> + <string name="editor_folder">Folder</string> + <string name="editor_change_folder">Change</string> + <string name="editor_otp_qr">OTP from QR</string> + <string name="editor_otp_manual">Manual OTP</string> + <string name="generator_random">Random</string> + <string name="generator_diceware" translatable="false">Diceware</string> + <string name="generator_use">Use</string> + + <!-- Compose setup --> + <string name="ssh_key_state_none">No SSH key yet</string> + <string name="ssh_key_state_imported">Imported private key</string> + <string name="ssh_key_state_generated">Generated on this device</string> + <string name="git_clone_progress">Cloning…</string> + <string name="connection_mode_none">None</string> + <string name="ssh_key_manage">Manage</string> + <string name="ssh_key_public_hint">Provide this public key to your Git server.</string> + <string name="pgp_key_needed_message">Import the private key this store is encrypted for, so its entries can be read and saved.</string> + <string name="pgp_key_needed_recipients">Store recipients</string> + <string name="store_create_failed">Could not create the store: %1$s</string> + <string name="store_not_empty">the folder already holds a store</string> + <string name="git_clone_delete_failed">Could not empty the store folder, so the clone did not start.</string> + <string name="pgp_key_import_too_large">The file is larger than 1 MiB: not a PGP key.</string> + <string name="pgp_key_import_downgrade">A private key with this ID is already imported. Its public key alone cannot replace it: delete the private key first if you really want to.</string> + + <!-- Compose settings --> + <string name="store_delete_failed">Could not delete everything in the store folder.</string> + <string name="clear_saved_secret_message">It will be asked again at the next Git operation.</string> + <string name="invalid_proxy_port">Enter a port from 1 to 65535, or nothing</string> + <string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string> + <string name="git_tool_done">%1$s: done</string> + <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string> + <string name="about">About</string> + <string name="about_summary">An Android client for pass, the standard Unix password manager. A maintained fork of Android Password Store.</string> + <string name="about_version">Version</string> + <string name="about_docs">Documentation</string> + <string name="about_changelog">What\'s new</string> + <string name="about_source">Source code</string> + <string name="about_issues">Report a problem</string> + <string name="about_support">Support on Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string> + <string name="about_certificate">Signing certificate (SHA-256)</string> + <string name="about_license">License</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright the Android Password Store authors and HardenedPass contributors.</string> + <string name="about_libraries">Open source licenses</string> + <string name="about_opens_browser">Opens in the browser</string> + <string name="about_no_browser">No browser found to open this link</string> </resources> diff --git a/app/src/main/res/values/styles_material3.xml b/app/src/main/res/values/styles_material3.xml deleted file mode 100644 index 329905e1..00000000 --- a/app/src/main/res/values/styles_material3.xml +++ /dev/null @@ -1,12 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - - <style name="APSThemeM3.Toolbar" parent="Widget.Material3.Toolbar"> - <item name="android:background">?android:colorBackground</item> - </style> - -</resources> diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 7e34b472..7db01be0 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -6,12 +6,9 @@ <resources> <style name="AppThemeM3" parent="APSThemeM3"> - <item name="fontFamily">@font/manrope</item> <item name="android:windowLightStatusBar">@bool/light_status_bar</item> <item name="android:statusBarColor">?android:colorBackground</item> <item name="android:navigationBarColor">?android:colorBackground</item> - <item name="toolbarStyle">@style/APSThemeM3.Toolbar</item> - <item name="bottomSheetDialogTheme">@style/APSThemeM3.BottomSheetDialog</item> </style> <style name="NoBackgroundThemeM3" parent="@style/AppThemeM3"> @@ -21,54 +18,17 @@ <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowContentOverlay">@null</item> - <item name="android:windowActionBar">false</item> <item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowEnterAnimation">@android:anim/fade_in</item> <item name="android:windowExitAnimation">@android:anim/fade_out</item> - <item name="colorPrimaryDark">@android:color/transparent</item> - <item name="windowNoTitle">true</item> </style> <style name="DialogLikeThemeM3" parent="AppThemeM3"> - <item name="android:windowActionBar">false</item> <item name="android:windowIsFloating">true</item> <item name="android:backgroundDimEnabled">true</item> - <!-- Needs to be set without android: prefix as per https://stackoverflow.com/a/33417456 --> - <item name="windowNoTitle">true</item> </style> - <style name="APSThemeM3.BottomSheetDialog" parent="ThemeOverlay.Material3.BottomSheetDialog"> - <item name="android:windowIsFloating">false</item> - <item name="android:statusBarColor">@android:color/transparent</item> - <item name="android:navigationBarColor">@android:color/transparent</item> - </style> - - <style name="APSThemeM3" parent="Theme.Material3.DayNight"> - <item name="colorPrimary">@color/md_theme_light_primary</item> - <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item> - <item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item> - <item name="colorOnPrimaryContainer">@color/md_theme_light_onPrimaryContainer</item> - <item name="colorSecondary">@color/md_theme_light_secondary</item> - <item name="colorOnSecondary">@color/md_theme_light_onSecondary</item> - <item name="colorSecondaryContainer">@color/md_theme_light_secondaryContainer</item> - <item name="colorOnSecondaryContainer">@color/md_theme_light_onSecondaryContainer</item> - <item name="colorTertiary">@color/md_theme_light_tertiary</item> - <item name="colorOnTertiary">@color/md_theme_light_onTertiary</item> - <item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item> - <item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item> - <item name="colorError">@color/md_theme_light_error</item> - <item name="colorErrorContainer">@color/md_theme_light_errorContainer</item> - <item name="colorOnError">@color/md_theme_light_onError</item> - <item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item> - <item name="android:colorBackground">@color/md_theme_light_background</item> - <item name="colorOnBackground">@color/md_theme_light_onBackground</item> - <item name="colorSurface">@color/md_theme_light_surface</item> - <item name="colorOnSurface">@color/md_theme_light_onSurface</item> - <item name="colorSurfaceVariant">@color/md_theme_light_surfaceVariant</item> - <item name="colorOnSurfaceVariant">@color/md_theme_light_onSurfaceVariant</item> - <item name="colorOutline">@color/md_theme_light_outline</item> - <item name="colorOnSurfaceInverse">@color/md_theme_light_inverseOnSurface</item> - <item name="colorSurfaceInverse">@color/md_theme_light_inverseSurface</item> - <item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item> + <style name="APSThemeM3" parent="android:Theme.Material.Light.NoActionBar"> + <item name="android:colorBackground">@android:color/system_neutral2_10</item> </style> </resources> |
