From 6c2ee8112c2ea0d1eb40d07698813900b78af36b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:27:28 +0100 Subject: feat(app): add the Compose password list screen Co-Authored-By: Claude Opus 5.5 --- app/src/main/res/values-it/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index dfb18d52..95893b17 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -351,4 +351,10 @@ Nell\'app non è ancora stata importata nessuna chiave PGP: premi il pulsante qui sotto per scegliere un file di chiave Sblocca le frasi segrete memorizzate Cancella la password memorizzata allo spegnimento dello schermo + + + Nuova voce + Archivio password + Altre opzioni + Cancella ricerca -- cgit v1.2.3 From bcd2d061288004020cc437694dbf4218abc52c89 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:39:54 +0100 Subject: refactor(app): remove the legacy PasswordStore list The Compose list replaces it. The autofill inline suggestion's attribution intent now opens LaunchActivity, behind the biometric gate. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 5 - .../ui/dialogs/FolderCreationDialogFragment.kt | 77 --- .../ui/dialogs/ItemCreationBottomSheet.kt | 77 --- .../ui/folderselect/SelectFolderActivity.kt | 18 +- .../ui/folderselect/SelectFolderFragment.kt | 12 +- .../passwordstore/ui/passwords/PasswordFragment.kt | 388 ------------- .../passwordstore/ui/passwords/PasswordStore.kt | 621 --------------------- .../app/passwordstore/ui/util/OnOffItemAnimator.kt | 71 --- .../util/autofill/AutofillViewUtils.kt | 4 +- app/src/main/res/anim/scale_down.xml | 16 - app/src/main/res/anim/scale_up.xml | 16 - app/src/main/res/layout/activity_pwdstore.xml | 19 - app/src/main/res/layout/folder_dialog_fragment.xml | 38 -- app/src/main/res/layout/item_create_sheet.xml | 42 -- app/src/main/res/layout/password_recycler_view.xml | 3 +- app/src/main/res/menu/context_pass.xml | 34 -- app/src/main/res/menu/main_menu_git.xml | 36 -- app/src/main/res/menu/main_menu_no_auth.xml | 28 - app/src/main/res/menu/main_menu_non_git.xml | 24 - app/src/main/res/values-de/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 5 - app/src/main/res/values-gl/strings.xml | 5 - app/src/main/res/values-it/strings.xml | 5 - app/src/main/res/values-ko-rKR/strings.xml | 5 - app/src/main/res/values-pl-rPL/strings.xml | 5 - app/src/main/res/values-pt-rBR/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 5 - app/src/main/res/values-zh-rCN/strings.xml | 5 - app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/strings.xml | 5 - 30 files changed, 10 insertions(+), 1570 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt delete mode 100644 app/src/main/res/anim/scale_down.xml delete mode 100644 app/src/main/res/anim/scale_up.xml delete mode 100644 app/src/main/res/layout/activity_pwdstore.xml delete mode 100644 app/src/main/res/layout/folder_dialog_fragment.xml delete mode 100644 app/src/main/res/layout/item_create_sheet.xml delete mode 100644 app/src/main/res/menu/context_pass.xml delete mode 100644 app/src/main/res/menu/main_menu_git.xml delete mode 100644 app/src/main/res/menu/main_menu_no_auth.xml delete mode 100644 app/src/main/res/menu/main_menu_non_git.xml (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4840ca1f..9bfb3b6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,11 +33,6 @@ tools:ignore="GoogleAppIndexingWarning" tools:targetApi="s"> - - 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(R.id.folder_name_text) - val folderNameViewContainer = dialog.findViewById(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(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? = 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(R.id.create_folder)?.setOnClickListener { - setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) - dismiss() - } - dialog.findViewById(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/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt index cfeb6206..2139d7d2 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt @@ -11,9 +11,6 @@ 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 @@ -23,23 +20,10 @@ class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { 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) - } + supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt index 3cf12fdd..acf4b56d 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt @@ -15,9 +15,9 @@ import androidx.lifecycle.lifecycleScope 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.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 @@ -56,11 +56,11 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { 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) + model.navigateTo( + PasswordRepository.getRepositoryDirectory(), + listMode = ListMode.DirectoriesOnly, + pushPreviousLocation = false, + ) lifecycleScope.launch { model.searchResult.flowWithLifecycle(lifecycle).collect { result -> recyclerAdapter.submitList(result.passwordItems) 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) { - 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() - 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) { - 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(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) { - 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/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/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index ebe4bc7d..59fd5cca 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -20,7 +20,7 @@ import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.passwords.PasswordStore +import app.passwordstore.ui.main.LaunchActivity import java.io.File data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) @@ -55,7 +55,7 @@ fun makeInlinePresentation( PendingIntent.getActivity( context, 0, - Intent(context, PasswordStore::class.java), + Intent(context, LaunchActivity::class.java), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE } else { diff --git a/app/src/main/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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - - - 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 @@ - - - - - - - - - - - - - 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 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/password_recycler_view.xml b/app/src/main/res/layout/password_recycler_view.xml index 49b7ff82..c2b5114f 100644 --- a/app/src/main/res/layout/password_recycler_view.xml +++ b/app/src/main/res/layout/password_recycler_view.xml @@ -9,8 +9,7 @@ android:layout_height="match_parent" android:clipChildren="false" android:clipToPadding="false" - android:orientation="vertical" - tools:context="app.passwordstore.ui.passwords.PasswordFragment"> + android:orientation="vertical"> - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - 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 @@ - - - - - - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7499fe5e..d13ab19f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -24,7 +24,6 @@ Wird gelöscht Verschieben - Bearbeiten Löschen Passwort existiert bereits! Dies wird %1$s mit %2$s überschreiben. @@ -167,7 +166,6 @@ Ja Nein Abbruch - Überspringen Synchronisiere Repository Git Pull Git Push @@ -176,7 +174,6 @@ Zeige das Passwort. Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren. Erstellen - Liste aktualisieren Passwort unverschlüsselt senden an… App-Icon @@ -234,7 +231,6 @@ Ordner erstellen Ordner umbenennen Der Name der Kategorie darf nicht leer sein. - Der Kategoriename existiert bereits. Ziel muss innerhalb des Repositorys sein. Ziel für %1$s angeben Erstellen @@ -281,7 +277,6 @@ .gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird. Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen Ordner - PGP-Schlüssel für Ordner festlegen Unbekannter Fehler 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. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6c8ac709..0cfb345c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,6 @@ Suppression… Déplacer - Éditer Supprimer Le mot de passe existe! Cela écrasera %1$s avec %2$s. @@ -155,7 +154,6 @@ Oui Non Annuler - Ignorer Synchronisation du dépôt Importer du serveur Pousser sur le serveur @@ -164,7 +162,6 @@ Montrer le mot de passe Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe Générer - Rafraichir la liste Envoyer le mot de passe en clair via… Icône de l\'application @@ -222,7 +219,6 @@ Créer un dossier Renommer le dossier Le nom de la catégorie ne peut pas être vide - Une catégorie du même nom existe déjà La destination doit être à l\'intérieur du dépôt Entrez le chemin pour %1$s Créer @@ -266,7 +262,6 @@ Exportation des mots de passe… Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent Dossier - Définir la clé GPG pour le dossier Erreur inconnue 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. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 4ca39712..589208ab 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -24,7 +24,6 @@ Eliminando… Mover - Editar Eliminar O contrasinal xa existe! Vas sobrescribir %1$s con %2$s. @@ -168,7 +167,6 @@ Si Non Cancelar - Omitir Sincronizar repositorio Traer desde remoto Enviar a remoto @@ -177,7 +175,6 @@ Mostrar contrasinal Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal Crear - Actualizar lista Enviar contrasinal como texto plano usando… Icona da app @@ -236,7 +233,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Crear cartafol Renomear cartafol O nome da categoría non pode estar baleiro - Xa existe o nome da categoría O destino debe estar dentro do repositorio Escribe o destino para %1$s Crear @@ -283,7 +279,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte O nome do ficheiro non pode conter \'/\', establece un directorio superior Directorio - Establece chave GPG para o directorio Fallo descoñecido 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. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 95893b17..5ed24efb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -24,7 +24,6 @@ Eliminazione… Sposta - Modifica Elimina La password esiste già! Questo sovrascriverà %1$s con %2$s. @@ -150,7 +149,6 @@ Sì No Annulla - Salta Sincronizza repository Pull dal remote Push sul remote @@ -159,7 +157,6 @@ Mostra la password Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti. Genera - Aggiorna elenco Invia password come testo semplice usando… Icona app @@ -217,7 +214,6 @@ Crea cartella Rinomina cartella Il nome della categoria non può essere vuoto - Il nome della categoria esiste già La destinazione deve essere nel repository Inserisci la destinazione per %1$s Crea @@ -261,7 +257,6 @@ Esportazione delle password… Il nome del file non deve contenere \'/\', imposta la directory qui sopra Directory - Imposta la chiave GPG per la directory Errore sconosciuto 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. diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 432b6fbe..7f63ad8b 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 @@ 삭제중… 이동 - 수정 삭제 이미 추가된 비밀번호입니다! %1$s 내용이 %2$s 로 덮어씌워집니다. @@ -163,7 +162,6 @@ 예 아니오 취소 - 건너뛰기 저장소 동기화 원격에서 pull 원격으로 푸시 @@ -172,7 +170,6 @@ 비밀번호 표시하기 암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다. 생성 - 목록 새로 고침 비밀번호를 일반 텍스트로 보내는 중… 앱 아이콘 @@ -230,7 +227,6 @@ 폴더 생성 폴더 이름 변경 카테고리 이름은 비워 둘 수 없습니다 - 카테고리가 이미 있습니다 대상은 리포지토리 내에 있어야 합니다 %1$s의 목적지 입력 생성 @@ -279,7 +275,6 @@ username@example.com:… .gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다. 파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요 디렉토리 - 디렉토리의 GPG 키 설정 알 수 없는 오류 풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요. diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a915f67d..a2068e65 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 @@ Zanim dodasz hasło lub rozpoczniesz synchronizację, stwórz albo sklonuj poniżej nowe repozytorium. Usuwanie… Przenieś - Edytuj Usuń Hasło już istnieje! %1$s zostanie nadpisane przez %2$s. @@ -164,7 +163,6 @@ Tak Nie Anuluj - Pomiń Synchronizuj repozytorium Pobierz ze zdalnego repozytorium Wypchnij do zdalnego repozytorium @@ -173,7 +171,6 @@ Pokaż hasło Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka. Generuj - Odśwież listę Wyślij niezaszyfrowane hasło, używając… Ikona aplikacji @@ -229,7 +226,6 @@ Utwórz katalog Zmień nazwę katalogu Nazwa kategorii nie może być pusta - Kategoria już istnieje Miejsce docelowe musi znajdować się w repozytorium Wprowadź miejsce docelowe dla %1$s Utwórz @@ -273,7 +269,6 @@ Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej Katalog - Ustaw klucz GPG dla katalogu Nieznany błąd 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. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1d95286d..909fd33e 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 @@ Excluindo… Mover - Editar Excluir Senha já existe! Isso irá sobrescrever %1$s com %2$s. @@ -167,7 +166,6 @@ Sim Não Cancelar - Pular Sincronizar repositório Pull do remoto Push do remoto @@ -176,7 +174,6 @@ Mostrar a senha Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência. Gerar - Atualizar lista Enviar senha como texto simples usando… Ícone do aplicativo @@ -234,7 +231,6 @@ Criar pasta Renomear pasta O nome da categoria não pode ser vazio - Nome da categoria já existe O destino deve estar dentro do repositório Insira o destino para %1$s Criar @@ -281,7 +277,6 @@ Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada Nome do arquivo não deve conter \'/\', defina o diretório acima Diretório - Definir chave GPG para diretório Erro desconhecido 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. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c10bdcd8..2ef59d70 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -28,7 +28,6 @@ Удаляется… Переместить - Редактировать Удалить Пароль уже существует! Это перезапишет %1$s на %2$s @@ -160,7 +159,6 @@ Да Нет Отмена - Пропустить Синхронизировать репозиторий Пулл с удаленного сервера Пуш на удаленный сервер @@ -169,7 +167,6 @@ Показывать пароли Видимость расшифрованных паролей, не влияет на копирование в буфер. Сгенерировать - Обновить список Поделиться паролем в открытом виде с помощью Иконка приложения @@ -227,7 +224,6 @@ Создать папку Переименовать папку Название категории не может быть пустым - Название категории уже существует Путь должен указывать на область внутри репозитория Введите адрес назначения для %1$s Создать @@ -273,7 +269,6 @@ Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя Имя файла не должно содержать \'/\', укажите директорию выше Директория - Установить GPG ключ для каталога Неизвестная ошибка Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b20749b3..55fbeefa 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 @@ 正在删除… 更多 - 编辑 删除 密码已经存在! 这将用%2$s覆盖%1$s @@ -168,7 +167,6 @@ 同意 否定 取消 - 跳过 同步存储库 拉取远程数据 推送数据到远程 @@ -177,7 +175,6 @@ 显示密码 控制密码解密后的可见性,这不会禁用复制到剪贴板 生成 - 刷新列表 使用明文发送密码… 应用图标 @@ -235,7 +232,6 @@ 清除目录 重命名目录 类别名称不能为空 - 类别名称已存在 目标必须在存储库中 输入%1$s的目标 创建 @@ -283,7 +279,6 @@ personal.com 找到 .gpg-id 但它包含一个不受支持的短十六进制ID。 文件名不能包含\'/\' 目录 - 为目录设置GPG密钥 未知错误 合并失败,你处于冲突状态。 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index d6939e63..e5d7ff94 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,7 +9,6 @@ 16dp 16dp 8dp - 56dp 100dp 140dp 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b280e9f2..9e402f6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,7 +30,6 @@ Deleting… Move - Edit Delete Password already exists! This will overwrite %1$s with %2$s. @@ -195,7 +194,6 @@ Yes No Cancel - Skip Synchronize repository Pull from remote Push to remote @@ -204,7 +202,6 @@ Show the password Control the visibility of the passwords once decrypted. This does not disable copying to clipboard. Generate - Refresh list Send password as plaintext using… App icon @@ -267,7 +264,6 @@ Create folder Rename folder Category name can\'t be empty - Category name already exists Destination must be within the repository Enter destination for %1$s Create @@ -316,7 +312,6 @@ Found .gpg-id, but it contains a short hex ID, which is not supported File name must not contain \'/\', set directory above Directory - Set GPG key for directory Unknown error -- cgit v1.2.3 From 22bb0aae958e8458274be994f3e679cb226d2241 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 23:30:37 +0100 Subject: feat(app): add the Compose entry screen Header with avatar, name and folder; password, username and one-time code with a countdown bar in the first group; extra fields in the second. FieldRow can start revealed (the "Show the password" setting) and shows masked values in JetBrains Mono, as the old screen did. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/entry/EntryScreen.kt | 183 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../app/passwordstore/ui/compose/FieldRow.kt | 13 +- 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt (limited to 'app/src/main/res/values-it') 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. + * 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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5ed24efb..31c34b4d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -352,4 +352,10 @@ Archivio password Altre opzioni Cancella ricerca + + + Indietro + Mostra password + Nascondi password + Copia nome diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e402f6b..2e208158 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -386,4 +386,10 @@ Password store More options Clear search + + + Back + Show password + Hide password + Copy name diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt index c49ec2bc..e632acc1 100644 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt +++ b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/FieldRow.kt @@ -21,6 +21,8 @@ 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.text.font.Font +import androidx.compose.ui.text.font.FontFamily import app.passwordstore.ui.compose.preview.ThemePreviews import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.compose.theme.SpacingLarge @@ -29,9 +31,12 @@ import app.passwordstore.ui.compose.theme.SpacingMedium /** Content descriptions for [FieldRow]'s buttons, supplied by the app's translated strings. */ public data class FieldRowLabels(val copy: String, val show: String, val hide: String) +/** Masked values (passwords) use a monospace face, so 0 and O, l and 1 stay apart. */ +private val Monospace = FontFamily(Font(R.font.jetbrainsmono_nl_regular)) + /** * A labelled value with an optional copy button. When [masked] is true the value is hidden behind - * dots until the reveal toggle is pressed. + * dots until the reveal toggle is pressed, unless [initiallyRevealed] shows it from the start. */ @Composable public fun FieldRow( @@ -40,9 +45,10 @@ public fun FieldRow( labels: FieldRowLabels, modifier: Modifier = Modifier, masked: Boolean = false, + initiallyRevealed: Boolean = false, onCopy: (() -> Unit)? = null, ) { - var revealed by rememberSaveable { mutableStateOf(false) } + var revealed by rememberSaveable { mutableStateOf(initiallyRevealed) } Row( modifier .fillMaxWidth() @@ -60,9 +66,10 @@ public fun FieldRow( style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + val style = MaterialTheme.typography.bodyLarge Text( if (masked && !revealed) "•".repeat(10) else value, - style = MaterialTheme.typography.bodyLarge, + style = if (masked) style.copy(fontFamily = Monospace) else style, ) } if (masked) -- cgit v1.2.3 From addce8a80202b5fc263ec87fd02485d8377ec54e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:38:28 +0100 Subject: feat(app): add the Compose folder picker for moves A dialog with breadcrumbs, subfolder rows and New folder replaces the legacy folder picker activity for the list's move action; the editor will share it. New folder reuses the list's name dialog and checks. A second tap on Select no longer moves twice. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/FolderPicker.kt | 126 +++++++++++++++++++++ .../app/passwordstore/ui/list/ListDestination.kt | 56 +++++---- app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 4 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt (limited to 'app/src/main/res/values-it') 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..5b610ff7 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +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>(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 + folder.mkdirs() + naming = false + path = folder.relativeTo(root.canonicalFile).path + }, + 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 index 98c820b1..147e77b7 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -5,11 +5,7 @@ package app.passwordstore.ui.list -import android.app.Activity -import android.content.Intent import androidx.activity.compose.BackHandler -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.padding @@ -37,7 +33,6 @@ 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.folderselect.SelectFolderActivity import app.passwordstore.ui.git.base.BaseGitActivity.GitOp import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.main.MainActivity @@ -55,14 +50,15 @@ private enum class ListDialog { NewFolder, Rename, Delete, + Move, Clone, NotInitialized, } /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the - * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root, - * [onOpenEntry] an entry file. + * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path + * relative to the root, [onOpenEntry] an entry file. */ @Composable fun ListDestination( @@ -106,20 +102,6 @@ fun ListDestination( BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() } BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" } - val moveLauncher = - rememberLauncherForActivityResult(StartActivityForResult()) { result -> - val data = result.data - val target = data?.getStringExtra("SELECTED_FOLDER_PATH")?.let(::File) - val files = data?.getStringArrayExtra("Files")?.map(::File) - if (result.resultCode == Activity.RESULT_OK && target?.isDirectory == true && files != null) { - selected = emptyList() - activity.lifecycleScope.launch { - activity.moveItems(files, target) - refresh() - } - } - } - fun ifInitialized(action: () -> Unit) { if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized } @@ -166,12 +148,7 @@ fun ListDestination( onGitOp = { op -> ifInitialized { runGit(op) } }, onSettings = activity::openSettings, onNew = { dialog = ListDialog.Create }, - onMove = { - moveLauncher.launch( - Intent(activity, SelectFolderActivity::class.java) - .putExtra("Files", selected.toTypedArray()) - ) - }, + onMove = { dialog = ListDialog.Move }, onDelete = { val files = selected.map(::File) val count = countFiles(files) @@ -254,6 +231,24 @@ fun ListDestination( 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. + if (dialog == ListDialog.Move) { + val files = selected.map(::File) + dialog = ListDialog.None + selected = emptyList() + activity.lifecycleScope.launch { + activity.moveItems(files, File(root, target)) + refresh() + } + } + }, + onDismiss = { dialog = ListDialog.None }, + ) ListDialog.Clone -> Prompt( text = stringResource(R.string.clone_git_repo), @@ -276,9 +271,12 @@ fun ListDestination( } } -/** Asks for a folder name; [validate] returns an error string resource or null. */ +/** + * Asks for a folder name; [validate] returns an error string resource or null. Shared with the + * folder picker's "New folder". + */ @Composable -private fun FolderNameDialog( +internal fun FolderNameDialog( title: String, message: String?, confirm: String, diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 31c34b4d..17e149af 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -358,4 +358,8 @@ Mostra password Nascondi password Copia nome + + + Scegli una cartella + Nuova cartella diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e208158..5370751e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -392,4 +392,8 @@ Show password Hide password Copy name + + + Choose a folder + New folder -- cgit v1.2.3 From 84d89854ce02a5833e88bfd9c150b8e90b51b4a5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:43:49 +0100 Subject: fix(app): harden the editor save path and the picker - Run the save's write, original delete, autofill and history updates under NonCancellable so a recreated activity cannot leave a half done rename. - fsync the temporary file before the atomic move. - Move dialog: refuse targets outside the store (symlinks) and moving a folder into itself or a descendant, with a toast. - Folder picker: do not navigate into a new folder whose mkdirs failed; show a toast (new string, en and it). - Normalize entryFile so "./" and "a/../b" give the same keys as the list; covered in EditorModelTest. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/editor/EditorModel.kt | 8 ++++++-- .../app/passwordstore/ui/editor/EditorViewModel.kt | 5 ++++- .../java/app/passwordstore/ui/list/FolderPicker.kt | 8 ++++++-- .../app/passwordstore/ui/list/ListDestination.kt | 20 +++++++++++++++++--- app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../app/passwordstore/ui/editor/EditorModelTest.kt | 4 ++++ 7 files changed, 39 insertions(+), 8 deletions(-) (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt index cd57400d..d7437df3 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt @@ -18,6 +18,7 @@ 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 @@ -64,7 +65,7 @@ fun formError(form: EditorForm): Int? = /** 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") + 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 = @@ -79,7 +80,10 @@ fun writeEntry(target: File, bytes: ByteArray) { dir.mkdirs() val temp = File(dir, ".${target.name}.tmp") try { - temp.writeBytes(bytes) + FileOutputStream(temp).use { + it.write(bytes) + it.fd.sync() + } Files.move( temp.toPath(), target.toPath(), diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt index b67b435d..0c8b4676 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -42,6 +42,7 @@ 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 @@ -216,7 +217,9 @@ constructor( encrypted.error.message.orEmpty(), ) } - return withContext(dispatcherProvider.io()) { + // 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) { diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt index 5b610ff7..bd5b66f3 100644 --- a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -5,6 +5,7 @@ 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 @@ -79,9 +80,12 @@ fun FolderPicker( validate = { folderNameError(it, File(dir, it)) }, onConfirm = { name -> val folder = File(dir, name).canonicalFile - folder.mkdirs() naming = false - path = folder.relativeTo(root.canonicalFile).path + 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 }, ) diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index 147e77b7..5c248dcb 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -5,6 +5,7 @@ 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 @@ -36,6 +37,7 @@ import app.passwordstore.ui.compose.theme.SpacingMedium import app.passwordstore.ui.git.base.BaseGitActivity.GitOp import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.isInsideRepository import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys @@ -237,12 +239,24 @@ fun ListDestination( start = path, onPick = { target -> // One move per pick: a double tap on Select must not move twice. - if (dialog == ListDialog.Move) { - val files = selected.map(::File) + 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, File(root, target)) + activity.moveItems(files, destination) refresh() } } diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 17e149af..100b0b7c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -361,5 +361,6 @@ Scegli una cartella + Impossibile creare la cartella Nuova cartella diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5370751e..a6d9dbba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -395,5 +395,6 @@ Choose a folder + Could not create the folder New folder diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt index 81174d05..160434c9 100644 --- a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt @@ -77,6 +77,10 @@ class EditorModelTest { entryFile(root, EditorForm(name = " mail ", dir = "work")), ) assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail"))) + assertEquals( + File(root, "b/mail.gpg"), + entryFile(root, EditorForm(name = "mail", dir = "./a/../b")), + ) } @Test -- cgit v1.2.3 From eff94a13a76e6f1f72e21848e6600bbbd731a660 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:45:12 +0100 Subject: feat(app): add the Compose editor screen and generator sheet Stateless editor: name, folder breadcrumbs with Change, password with reveal and generator, username, extra content and the OTP actions. The generator sheet has random and diceware tabs with the old options; the manual OTP dialog and the sheet are FLAG_SECURE. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/editor/EditorScreen.kt | 314 +++++++++++++++++++++ .../app/passwordstore/ui/editor/GeneratorSheet.kt | 197 +++++++++++++ app/src/main/res/values-it/strings.xml | 11 + app/src/main/res/values/strings.xml | 12 + 4 files changed, 534 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt (limited to 'app/src/main/res/values-it') 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. + * 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/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. + * 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, + 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(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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 100b0b7c..91cd28aa 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -363,4 +363,15 @@ Scegli una cartella Impossibile creare la cartella Nuova cartella + + + Nuova voce + Modifica voce + Chiudi + Cartella + Cambia + OTP da QR + OTP manuale + Casuale + Usa diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6d9dbba..34b11662 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -397,4 +397,16 @@ Choose a folder Could not create the folder New folder + + + New entry + Edit entry + Close + Folder + Change + OTP from QR + Manual OTP + Random + Diceware + Use -- cgit v1.2.3 From 7bdcb041de95c8d8d8d14f1dbd34a3380b326871 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:59:12 +0100 Subject: refactor(app): remove the legacy editor, generators and folder picker The Compose editor, generator sheet, OTP dialog and folder picker replace PasswordCreationActivity, the generator and manual OTP dialog fragments, SelectFolderActivity with its fragment and adapter, their layouts and menus, and upstream's unused EditPasswordScreen with the PasswordField it used. flowbinding and swiperefreshlayout had no other users. BasePGPActivity keeps what autofill decryption still needs. Also drops two stale SshKey baseline entries, the snackbar FAB anchor whose view is gone, and resources only the deleted files used. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 2 - app/lint-baseline.xml | 46 +- app/src/main/AndroidManifest.xml | 9 - .../injection/pwgen/DicewareModule.kt | 3 +- .../ui/adapters/PasswordItemRecyclerAdapter.kt | 99 ---- .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 8 - .../passwordstore/ui/crypto/EditPasswordScreen.kt | 105 ----- .../ui/crypto/PasswordCreationActivity.kt | 510 --------------------- .../DicewarePasswordGeneratorDialogFragment.kt | 90 ---- .../ui/dialogs/OtpImportDialogFragment.kt | 47 -- .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 148 ------ .../ui/folderselect/SelectFolderActivity.kt | 51 --- .../ui/folderselect/SelectFolderFragment.kt | 98 ---- .../util/extensions/AndroidExtensions.kt | 7 +- app/src/main/res/drawable/ic_done_24dp.xml | 15 - app/src/main/res/drawable/ic_save_24dp.xml | 15 - app/src/main/res/drawable/ic_save_copy_24dp.xml | 15 - .../main/res/layout/fragment_manual_otp_entry.xml | 53 --- app/src/main/res/layout/fragment_pwgen.xml | 115 ----- .../main/res/layout/fragment_pwgen_diceware.xml | 73 --- .../main/res/layout/password_creation_activity.xml | 147 ------ app/src/main/res/layout/password_recycler_view.xml | 38 -- app/src/main/res/layout/password_row_layout.xml | 50 -- app/src/main/res/layout/select_folder_layout.xml | 16 - app/src/main/res/menu/pgp_handler_new_password.xml | 20 - .../main/res/menu/pgp_handler_select_folder.xml | 15 - app/src/main/res/values-de/strings.xml | 8 - app/src/main/res/values-fr/strings.xml | 8 - app/src/main/res/values-gl/strings.xml | 8 - app/src/main/res/values-it/strings.xml | 8 - app/src/main/res/values-ko-rKR/strings.xml | 8 - app/src/main/res/values-pl-rPL/strings.xml | 8 - app/src/main/res/values-pt-rBR/strings.xml | 8 - app/src/main/res/values-ru/strings.xml | 8 - app/src/main/res/values-zh-rCN/strings.xml | 8 - app/src/main/res/values/strings.xml | 8 - .../specs/2026-11-02-compose-ui-overhaul-design.md | 8 +- gradle/libs.versions.toml | 3 - .../app/passwordstore/ui/compose/PasswordField.kt | 81 ---- 39 files changed, 9 insertions(+), 1958 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt delete mode 100644 app/src/main/res/drawable/ic_done_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_save_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_save_copy_24dp.xml delete mode 100644 app/src/main/res/layout/fragment_manual_otp_entry.xml delete mode 100644 app/src/main/res/layout/fragment_pwgen.xml delete mode 100644 app/src/main/res/layout/fragment_pwgen_diceware.xml delete mode 100644 app/src/main/res/layout/password_creation_activity.xml delete mode 100644 app/src/main/res/layout/password_recycler_view.xml delete mode 100644 app/src/main/res/layout/password_row_layout.xml delete mode 100644 app/src/main/res/layout/select_folder_layout.xml delete mode 100644 app/src/main/res/menu/pgp_handler_new_password.xml delete mode 100644 app/src/main/res/menu/pgp_handler_select_folder.xml delete mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt (limited to 'app/src/main/res/values-it') diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cc10d21e..5f7a8ee8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -73,7 +73,6 @@ dependencies { implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) implementation(libs.androidx.recyclerviewSelection) - implementation(libs.androidx.swiperefreshlayout) implementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) @@ -86,7 +85,6 @@ dependencies { implementation(libs.aps.zxingAndroidEmbedded) implementation(libs.thirdparty.fastscroll) - implementation(libs.thirdparty.flowbinding.android) implementation(libs.thirdparty.jgit) { exclude(group = "org.apache.httpcomponents", module = "httpclient") } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 20c4683f..a19d0ad9 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -36,29 +36,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> - - - - - - - - @@ -197,26 +175,4 @@ column="4"/> - - - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4859c0e..0429b2ac 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -95,12 +95,6 @@ android:label="@string/action_settings" android:parentActivityName=".ui.main.MainActivity" /> - - - ( - 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) -> 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? = 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() { - override fun getPosition() = absoluteAdapterPosition - - override fun getSelectionKey() = item.stableId - } - } - } - - class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : - ItemDetailsLookup() { - - override fun getItemDetails(event: MotionEvent): ItemDetails? { - 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/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index ba49a62c..ccd3bf08 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -29,7 +29,6 @@ import app.passwordstore.util.extensions.clipboard import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.services.ClipboardService import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys @@ -44,12 +43,6 @@ import kotlinx.coroutines.withContext @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")!! } - /** Action to invoke if [keyImportAction] succeeds. */ private var onKeyImport: (() -> Unit)? = null private val keyImportAction = @@ -103,7 +96,6 @@ open class BasePGPActivity : AppCompatActivity() { 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 = 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/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/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(id).isChecked - } - - private fun getSelectedOptions(): List { - 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(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, 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/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt deleted file mode 100644 index 2139d7d2..00000000 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.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.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 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() - supportActionBar?.show() - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList) } - } - - 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 acf4b56d..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.data.repo.PasswordRepository -import app.passwordstore.databinding.PasswordRecyclerViewBinding -import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter -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) - - model.navigateTo( - PasswordRepository.getRepositoryDirectory(), - 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/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index a13484c6..bad55de4 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -95,18 +95,13 @@ fun FragmentActivity.isPermissionGranted(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a - * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the - * [view] - */ +/** Show a [Snackbar] in a [FragmentActivity]. */ fun FragmentActivity.snackbar( view: View = findViewById(android.R.id.content), message: String, length: Int = Snackbar.LENGTH_SHORT, ): Snackbar { val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) snackbar.show() return snackbar } 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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 c2b5114f..00000000 --- a/app/src/main/res/layout/password_recycler_view.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - 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 @@ - - - - - - - - - - - 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 @@ - - - - - - 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 @@ - - - - - - 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 @@ - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d13ab19f..a9fa4b00 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -39,7 +39,6 @@ In die Zwischenablage kopiert Bitte setzen Sie einen Dateinamen. - Bitte setzen Sie einen Pfad. Sie können kein leeres Passwort oder leere Extra-Angaben setzen. Es ist ein Fehler während der Git-Operation aufgetreten. @@ -63,7 +62,6 @@ Name Passwort Zusätzliche Angaben - Benutzername verschlüsseln Auswählen Speichern Speichern und kopieren @@ -125,7 +123,6 @@ Passwort erstellen Erstellen - Einschließen Länge Nummern Symbole @@ -173,7 +170,6 @@ Commit-Log anzeigen Zeige das Passwort. Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren. - Erstellen Passwort unverschlüsselt senden an… App-Icon @@ -207,7 +203,6 @@ Autofill-Unterstützung mit installierten Browsern: Der private SSH-Schlüssel konnte nicht geöffnet werden. Bitte überprüfen Sie, ob die Datei existiert. - Neues Passwort Gespeicherte Passphrase für lokalen SSH-Schlüssel löschen Gespeichertes HTTPS-Passwort löschen Schlüsselpasswort merken @@ -276,7 +271,6 @@ .gpg-id wurde gefunden, enthält jedoch ungültige Schlüssel-ID, Fingerabdruck oder Benutzer-ID. .gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird. Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen - Ordner Unbekannter Fehler 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. @@ -319,11 +313,9 @@ Host-Schlüssel erfolgreich gelöscht! QR-Code scannen Wählen Sie ein Bild. - Manuell eingeben Schlüssel Benutzer-Konto Verknüpfung auf dem Home-Screen erstellen - Neues Passwort oder neuen Ordner erstellen Fehler beim Import des PGP-Schlüssels Ein bestehender Schlüssel mit dieser ID wurde gefunden. Möchten Sie ihn ersetzen? PGP-Schlüssel erfolgreich importiert diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 0cfb345c..d41048f8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -38,7 +38,6 @@ Copié dans le presse-papiers Veuillez fournir un nom de fichier - Veuillez fournir un chemin d\'accès au fichier Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide Une erreur c\'est produite lors d\'une opération Git @@ -62,7 +61,6 @@ Nom Mot de passe Extra - Chiffrer le nom d\'utilisateur Sélectionner Sauvegarder Enregistrer et copier @@ -113,7 +111,6 @@ Générer un mot de passe Générer - Inclure Longueur Chiffres Symboles @@ -161,7 +158,6 @@ Afficher l\'historique des commits Montrer le mot de passe Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe - Générer Envoyer le mot de passe en clair via… Icône de l\'application @@ -195,7 +191,6 @@ Prise en charge du remplissage automatique avec les navigateurs installés: Impossible d\'ouvrir la clef ssh, merci de vérifier que le ficher existe - Nouveau mot de passe Effacer le mot de passe enregistrée pour la clé SSH locale Effacer le mot de passe HTTPS enregistré Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr) @@ -261,7 +256,6 @@ Impossible d\'importer la configuration TOTP Exportation des mots de passe… Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent - Dossier Erreur inconnue 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. @@ -300,10 +294,8 @@ La clé d\'hôte a été effacée avec succès! Scanner le code QR Choisissez une image - Saisir manuellement Secret Compte - Créer un nouveau mot de passe ou dossier Impossible d\'importer la clé PGP Clé PGP importée avec succès 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 diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 589208ab..3fb321e7 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -39,7 +39,6 @@ Copiado ao portapapeis Debes proporcionar un nome de ficheiro - Por favor indica a ruta ao ficheiro Non podes deixar baleiro o contrasinal ou o contido extra Algo fallou na operación de Git @@ -63,7 +62,6 @@ Nome Contrasinal Contido extra - Cifrar identificador Seleccionar Gardar Gardar e copiar @@ -126,7 +124,6 @@ Crear contrasinal Crear - Incluír Lonxitude Números Símbolos @@ -174,7 +171,6 @@ Mostrar rexistro dos cambios Mostrar contrasinal Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal - Crear Enviar contrasinal como texto plano usando… Icona da app @@ -209,7 +205,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Soporte de autocompletado cos navegadores: Non se pode abrir a chave privada ssh, comproba que existe tal ficheiro - Novo contrasinal Baleirar a frase de paso gardada para a chave SSH local Baleirar contrasinal HTTPS gardado Lembrar frase de paso da chave @@ -278,7 +273,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Atopouse .gpg-id, pero contén un ID de chave, impresión dixital ou ID de usuaria non válidos Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte O nome do ficheiro non pode conter \'/\', establece un directorio superior - Directorio Fallo descoñecido 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. @@ -321,11 +315,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Eliminouse correctamente a chave gardada do host! Escanear código QR Elixe unha imaxe - Escribir manualmente Segredo Conta Crear atallo na pantalla de inicio - Crear novo contrasinal ou cartafol Fallou a importación da chave PGP Atopouse unha chave existente con este ID, queres substituíla? Chave PGP importada correctamente diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 91cd28aa..c14efa33 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -38,7 +38,6 @@ Copiato negli appunti Inserisci un nome per il file - Inserisci il percorso del file Non puoi usare una password vuota o dei contenuti extra vuoti Si è verificato un errore durante un\'operazione Git @@ -62,7 +61,6 @@ Nome Password Contenuto extra - Crittografa nome utente Seleziona Salva Salva e Copia @@ -108,7 +106,6 @@ Genera Password Genera - Includi Lunghezza Numeri Simboli @@ -156,7 +153,6 @@ Mostra registro dei commit Mostra la password Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti. - Genera Invia password come testo semplice usando… Icona app @@ -190,7 +186,6 @@ Supporto alla compilazione automatica nei browser installati: Impossibile aprire la chiave privata SSH, controlla che il file esista - Nuova password Cancella la frase segreta salvata per la chiave SSH locale Cancella la password HTTPS salvata Ricorda la frase segreta della chiave @@ -256,7 +251,6 @@ Impossibile importare la configurazione di TOTP Esportazione delle password… Il nome del file non deve contenere \'/\', imposta la directory qui sopra - Directory Errore sconosciuto 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. @@ -293,7 +287,6 @@ Cancella la chiave host salvata Chiave host cancellata con successo! Scegli un\'immagine - Crea nuova password o cartella Inizializza gli ID GPG in %1$s. Nome utente OTP (scade tra %ds) @@ -327,7 +320,6 @@ Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure. Impostazioni proxy Scansiona codice QR - Inserisci manualmente Segreto Account Aggiungi scorciatoia alla schermata Home diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 7f63ad8b..3efbed68 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -37,7 +37,6 @@ 클립보드로 복사됨 파일 이름이 필요합니다 - 파일 경로가 필요합니다 비어있는 비밀번호나 추가 정보를 사용할 수 없습니다 Git 동작 중 문제가 발생하였습니다 @@ -61,7 +60,6 @@ 이름 비밀번호 추가 내용 - 암호화 계정 이름 선택 저장 저장 및 복사 @@ -121,7 +119,6 @@ 암호 생성 생성 - 포함 길이 숫자만 특수문자 @@ -169,7 +166,6 @@ 커밋 로그 보기 비밀번호 표시하기 암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다. - 생성 비밀번호를 일반 텍스트로 보내는 중… 앱 아이콘 @@ -203,7 +199,6 @@ 자동 완성 지원 하는 설치된 브라우저: SSH 개인 키를 열 수 없습니다. 파일이 존재하는지 확인하세요 - 새 암호 로컬 SSH 키의 저장된 비밀번호 지우기 저장된 HTTPS 비밀번호 지우기 키 암호 기억하기 @@ -274,7 +269,6 @@ username@example.com:… .gpg-id를 찾았지만 잘못된 키 ID, 지문 또는 사용자 ID가 포함되어 있습니다 .gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다. 파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요 - 디렉토리 알 수 없는 오류 풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요. @@ -317,11 +311,9 @@ username@example.com:… 저장된 호스트 키를 성공적으로 지웠습니다! QR코드 스캔 이미지 선택 - 직접 입력 비밀 계정 홈 화면에 추가 - 새 비밀번호 또는 폴더 만들기 PGP 키를 가져오지 못했습니다 이 ID를 가진 기존 키가 발견되었습니다. 교체하시겠습니까? PGP 키를 성공적으로 가져왔습니다 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index a2068e65..65f14ce5 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -37,7 +37,6 @@ Skopiowano do schowka Podaj nazwę pliku - Podaj ścieżkę do pliku Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości Wystąpił błąd podczas operacji Git @@ -61,7 +60,6 @@ Nazwa Hasło Treść dodatkowa - Szyfruj nazwę użytkownika Wybierz Zapisz Zapisz i skopiuj @@ -122,7 +120,6 @@ Generuj hasło Wygeneruj - Załącz Długość Cyfry Symbole @@ -170,7 +167,6 @@ Pokaż dziennik commitów Pokaż hasło Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka. - Generuj Wyślij niezaszyfrowane hasło, używając… Ikona aplikacji @@ -202,7 +198,6 @@ Obsługa autouzupełniania w zainstalowanych przeglądarkach: Nie można otworzyć klucza prywatnego ssh, sprawdź czy plik istnieje - Nowe hasło Wyczyść zapisane hasło dla lokalnego klucza SSH Wyczyść zapisane hasło HTTPS Zapamiętaj hasło klucza @@ -268,7 +263,6 @@ Znaleziono .gpg-id, ale zawiera nieprawidłowy identyfikator klucza, odcisk palca lub identyfikator użytkownika Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej - Katalog Nieznany błąd 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. @@ -309,11 +303,9 @@ Pomyślnie wyczyszczono zapisany klucz hosta! Skanuj kod QR Wybierz obraz - Wprowadź ręcznie Sekret Konto Utwórz skrót na pulpicie - Utwórz nowe hasło lub folder Nie udało się zaimportować klucza PGP Znaleziono istniejący klucz z tym identyfikatorem, czy chcesz go zastąpić? Pomyślnie zaimportowano klucz PGP diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 909fd33e..86ebfb24 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -39,7 +39,6 @@ Copiado para a área de transferência Por favor, informe um nome de arquivo - Por favor, forneça o caminho do arquivo Você não pode usar uma senha vazia ou conteúdo extra vazio Ocorreu um erro durante uma operação do Git @@ -63,7 +62,6 @@ Nome Senha Conteúdo extra - Criptografar nome de usuário Selecionar Salvar Salvar e Copiar @@ -125,7 +123,6 @@ Gerar Senha Gerar - Incluir Tamanho Numerais Símbolos @@ -173,7 +170,6 @@ Mostrar log de commit Mostrar a senha Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência. - Gerar Enviar senha como texto simples usando… Ícone do aplicativo @@ -207,7 +203,6 @@ Suporte ao preenchimento automático com navegadores instalados: Não foi possível abrir a chave privada ssh, por favor verifique se o arquivo existe - Nova senha Limpar a frase secreta salva para chave SSH local Limpar senha HTTPS salva Lembrar senha da chave @@ -276,7 +271,6 @@ Encontrado .gpg-id, mas contém um ID de chave, fingerprint ou ID de usuário inválidos Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada Nome do arquivo não deve conter \'/\', defina o diretório acima - Diretório Erro desconhecido 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. @@ -319,11 +313,9 @@ Chave de host limpa com sucesso! Escanear QR code Selecione uma imagem - Inserir manualmente Segredo Conta Criar atalho na tela inicial - Criar nova senha ou pasta Falha ao importar a chave PGP Uma chave existente com esse ID foi encontrada, você deseja substituí-lo? Chave PGP importada com sucesso diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 2ef59d70..8873b34f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -43,7 +43,6 @@ Скопировано в буфер обмена Пожалуйста, укажите имя файла - Пожалуйста, задайте путь к файлу Вы не можете использовать пустой пароль или пустое поле информации Произошла ошибка выполнения операции Git @@ -67,7 +66,6 @@ название Пароль Дополнительно - Зашифровать имя пользователя Выбрать Сохранить Сохранить и скопировать @@ -118,7 +116,6 @@ Сгенерировать пароль Сгенерировать - Включая Длина Цифры Символы @@ -166,7 +163,6 @@ Показать журнал изменений Показывать пароли Видимость расшифрованных паролей, не влияет на копирование в буфер. - Сгенерировать Поделиться паролем в открытом виде с помощью Иконка приложения @@ -200,7 +196,6 @@ Поддержка автозаполнения установленными браузерами: Невозможно открыть приватный ключ ssh, пожалуйста проверьте, что файл существует - Новый пароль Очистить сохраненную кодовую фразу для локального SSH ключа Удалить сохраненный пароль HTTPS Запомнить кодовую фразу @@ -268,7 +263,6 @@ Экспорт паролей… Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя Имя файла не должно содержать \'/\', укажите директорию выше - Директория Неизвестная ошибка Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере. @@ -311,10 +305,8 @@ Сохраненный ключ хоста был успешно удален! Отсканировать QR-код Выберите изображение - Ввести вручную Учетная запись Разместить ярлык на рабочем столе - Создать новый пароль или папку Не удалось импортировать PGP-ключ PGP ключ успешно импортирован diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 55fbeefa..746b74d2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -38,7 +38,6 @@ 复制到剪切板 请提供文件名 - 请提供文件路径 您不能使用空密码或空额外内容 Git操作过程中发生错误 @@ -62,7 +61,6 @@ 名称 密码 附加内容 - 加密用户名 选择 保存 保存并复制 @@ -121,7 +119,6 @@ 生成密码 生成 - 包含 长度 数字 标识 @@ -174,7 +171,6 @@ 显示提交日志 显示密码 控制密码解密后的可见性,这不会禁用复制到剪贴板 - 生成 使用明文发送密码… 应用图标 @@ -209,7 +205,6 @@ 已安装支持自动填充的浏览器: 无法打开SSH私钥,请检查文件是否存在 - 新密码 清除本地已保存的SSH密钥密码 清除保存的HTTPS密码 记住密钥密码 @@ -278,7 +273,6 @@ personal.com 找到了 .gpg-id,但它包含无效的密钥ID、指纹或用户ID 找到 .gpg-id 但它包含一个不受支持的短十六进制ID。 文件名不能包含\'/\' - 目录 未知错误 合并失败,你处于冲突状态。 @@ -324,11 +318,9 @@ GPG 已成功清除已保存的主机密钥! 扫描二维码 选择图像 - 手动输入 安全码 (Secret) 账户 在主屏幕上放置快捷方式 - 创建新密码或文件夹 导入 PGP 密钥失败 发现了 ID 相同的现有密钥,您要替换它吗? 成功导入 PGP 密钥 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 34b11662..239cb219 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,7 +47,6 @@ Copied to clipboard Please provide a file name - Please provide a file path You cannot use an empty password or empty extra content @@ -79,7 +78,6 @@ Username Password Extra content - Encrypt username Select Save Save and Copy @@ -148,7 +146,6 @@ Generate Password Generate - Include Length Numerals Symbols @@ -201,7 +198,6 @@ Show commit log Show the password Control the visibility of the passwords once decrypted. This does not disable copying to clipboard. - Generate Send password as plaintext using… App icon @@ -239,7 +235,6 @@ Unable to open the ssh private key, please check that the file exists - New password Clear saved passphrase for local SSH key Clear saved HTTPS password Remember key passphrase @@ -311,7 +306,6 @@ Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID Found .gpg-id, but it contains a short hex ID, which is not supported File name must not contain \'/\', set directory above - Directory Unknown error @@ -360,11 +354,9 @@ Successfully cleared saved host key! Scan QR code Choose an image - Enter manually Secret Account Place shortcut on home screen - Create new password or folder Failed to import PGP key An existing key with this ID was found, do you want to replace it? Successfully imported PGP key diff --git a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md index 75a599fa..7eab1692 100644 --- a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +++ b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md @@ -46,7 +46,10 @@ the maintainer's phone stays on 0.1.4 until then. - Password with reveal and a generator button; the generator is a bottom sheet with random and diceware tabs. - Username field, free-text extra content (pass format kept as text, nothing hidden). -- "OTP from QR" and "OTP manual" actions. +- "OTP from QR" (camera or image) and "OTP manual" actions. +- "Save and copy" in the overflow. Saving returns to the list; closing returns to where the + editor was opened. The folder picker is a dialog, shared with the list's move action. The + autofill save flow (`AutofillSaveActivity`) hosts the same editor. ## Architecture @@ -94,7 +97,8 @@ needs it. ## Security - `FLAG_SECURE` is set on the window while the entry view or editor destination is shown and - cleared when leaving it, so secrets stay out of screenshots and recents. + cleared when leaving it, so secrets stay out of screenshots and recents. It is counted, so + moving between the two never clears it. - `MainActivity` is not exported. Routes come only from `LaunchActivity` after its checks; an entry route is still validated with `isFileInRepo`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d8f4111..13d866e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,6 @@ agp = "9.5.0" androidxActivity = "1.9.2" bouncycastle = "1.86" coroutines = "1.9.0" -flowbinding = "1.2.0" hilt = "2.60.1" kotlin = "2.4.21" kotlinResult = "2.0.0" @@ -33,7 +32,6 @@ androidx-material = "com.google.android.material:material:1.12.0" androidx-preference = "androidx.preference:preference:1.2.1" androidx-recyclerview = "androidx.recyclerview:recyclerview:1.4.0" androidx-recyclerviewSelection = "androidx.recyclerview:recyclerview-selection:1.2.0" -androidx-swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0" aps-sublimeFuzzy = "com.github.android-password-store:sublime-fuzzy:2.3.4" aps-zxingAndroidEmbedded = "com.github.android-password-store:zxing-android-embedded:4.2.1" build-agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } @@ -77,7 +75,6 @@ thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", v thirdparty-commons_codec = "commons-codec:commons-codec:1.14" thirdparty-compose-lints = "com.slack.lint.compose:compose-lint-checks:1.4.1" thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0" -thirdparty-flowbinding-android = { module = "io.github.reactivecircus.flowbinding:flowbinding-android", version.ref = "flowbinding" } # JGit upgrades also raise its minimum Java runtime requirements that we cannot satisfy on Android # noinspection GradleDependency thirdparty-jgit = "org.eclipse.jgit:org.eclipse.jgit:5.13.5.202508271544-r" diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt deleted file mode 100644 index 066a839c..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/compose/PasswordField.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.compose - -import androidx.annotation.StringRes -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -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.platform.LocalClipboardManager -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation - -@Composable -public fun PasswordField( - value: String, - label: String, - initialVisibility: Boolean, - modifier: Modifier = Modifier, - readOnly: Boolean = false, -) { - var visible by remember { mutableStateOf(initialVisibility) } - TextField( - value = value, - onValueChange = {}, - readOnly = readOnly, - label = { Text(label) }, - visualTransformation = - if (visible) VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - ToggleButton( - visible = visible, - contentDescription = "Toggle password visibility", - onButtonClick = { visible = !visible }, - ) - }, - modifier = modifier, - ) -} - -@Composable -private fun ToggleButton( - visible: Boolean, - contentDescription: String, - onButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - IconButton(onClick = onButtonClick, modifier = modifier) { - val icon = - if (visible) painterResource(id = R.drawable.baseline_visibility_off_24) - else painterResource(id = R.drawable.baseline_visibility_24) - Icon(painter = icon, contentDescription = contentDescription) - } -} - -@Composable -public fun CopyButton( - textToCopy: String, - @StringRes buttonLabelRes: Int, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboardManager.current - IconButton(onClick = { clipboard.setText(AnnotatedString(textToCopy)) }, modifier = modifier) { - Icon( - painter = painterResource(R.drawable.ic_content_copy), - contentDescription = stringResource(buttonLabelRes), - ) - } -} -- cgit v1.2.3 From 3d8373dfff5101e58281b3b8f2e3b0ae2212678d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:47:09 +0100 Subject: feat(app): add the setup ViewModels Git server (URL, mode, clone state, the after-clone key check), SSH key (import and generation off the main thread, outcome kept across recreation) and PGP keys (rows with key IDs, import with the replace question, delete by key ID, creation of a local store). Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/ui/setup/GitServerViewModel.kt | 127 ++++++++++++ .../app/passwordstore/ui/setup/PgpKeysViewModel.kt | 225 +++++++++++++++++++++ .../app/passwordstore/ui/setup/SshKeyViewModel.kt | 141 +++++++++++++ app/src/main/res/values-it/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 5 files changed, 505 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt create mode 100644 app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt create mode 100644 app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt (limited to 'app/src/main/res/values-it') 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..f2b8ca30 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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(null) + + /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */ + var cloned by mutableStateOf(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 + if (value.isNotEmpty()) 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. + */ + suspend fun clearStore(root: File) { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return + progress = R.string.delete_directory_progress_text + withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() } + progress = R.string.git_clone_progress + } + + /** 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/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt new file mode 100644 index 00000000..e6000206 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -0,0 +1,225 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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.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.data.repo.PasswordRepository +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 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(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 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] and imports it. */ + fun import(uri: Uri, context: Context) { + if (busy) return + busy = true + val resolver = context.applicationContext.contentResolver + viewModelScope.launch { + try { + val bytes = + withContext(dispatcherProvider.io()) { + resolver.openInputStream(uri)?.use { it.readBytes() } + } + if (bytes == null) importResult = KeyImport.Failed(null) else add(bytes, replace = false) + } 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 unless the replace question keeps them. */ + private suspend fun add(bytes: ByteArray, replace: Boolean) { + val result = keyManager.addKey(PGPKey(bytes), replace) + if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { + pending = bytes + 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 KeyImport.Failed(result.error.message) + bytes.fill(0) + if (result.isOk) updateKeySet() + } + + /** + * 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. + */ + suspend fun createStore(root: File, row: KeyRow): String? = + withContext(dispatcherProvider.io()) { + 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() } + // Only removes the folder while it is still empty, as the old onboarding did. + root.delete() + return e.message.orEmpty() + } + + fun markDone() { + done = true + } + + override fun onCleared() { + dropPending() + } +} 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..d70b06af --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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 java.io.IOException +import java.security.GeneralSecurityException +import java.security.ProviderException +import javax.inject.Inject +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(null) + private set + + /** Why the last import or generation failed. */ + var failure by mutableStateOf(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: IOException) { + failure = e.message.orEmpty() + } catch (e: IllegalArgumentException) { + failure = e.message.orEmpty() + } catch (e: SecurityException) { + 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: GeneralSecurityException) { + failure = e.message.orEmpty() + } catch (e: ProviderException) { + failure = e.message.orEmpty() + } catch (e: IOException) { + 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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c14efa33..c2edc768 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -366,4 +366,10 @@ OTP manuale Casuale Usa + + + Ancora nessuna chiave SSH + Chiave privata importata + Generata su questo dispositivo + Clonazione… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 239cb219..d53700a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -401,4 +401,10 @@ Random Diceware Use + + + No SSH key yet + Imported private key + Generated on this device + Cloning… -- cgit v1.2.3 From fcbd5999f28f287507844b55f2bee82e714620c8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:57:05 +0100 Subject: fix(app): harden the setup key imports - PGP key import reads at most 1 MiB (bounded read, buffer zeroed) and refuses larger files with a new message. - SshKeyViewModel catches any Exception from import and generation (CancellationException rethrown) instead of a short list, so an unexpected provider or sshj error no longer crashes the app. - Pending PGP key bytes are zeroed in a finally block, also on cancellation during addKey after a replace, and an earlier pending pick is zeroed before a new one takes its place. - PGPKeyManager never replaces a stored secret key with a public-only key of the same ID, with or without replace (KeyDowngradeException); the setup ViewModel shows a localized message for it. Unit test added. - storeRecipients strips '#' comments and a trailing '!' as pass reads .gpg-id; tests added. - GitServerViewModel.clearStore returns the failure when the store folder cannot be emptied, so the clone does not start; the plan's Task 3 call site reports it. - readGpgId opens the file with NOFOLLOW_LINKS and reads at most 64 KiB + 1 bytes, refusing more. - SshKey.import writes the new private key to a temp file and moves it over atomically before dropping the old key's Keystore entry, public key and passphrase, so an I/O error keeps the old key. - KeyUtils.isSecretKey notes (ponytail) that GnuPG stub and smartcard keyrings count as secret. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/ui/setup/GitServerViewModel.kt | 11 ++-- .../app/passwordstore/ui/setup/PgpKeysViewModel.kt | 58 +++++++++++++++------- .../java/app/passwordstore/ui/setup/SetupModel.kt | 45 ++++++++++++++--- .../app/passwordstore/ui/setup/SshKeyViewModel.kt | 22 ++++---- .../java/app/passwordstore/util/git/sshj/SshKey.kt | 32 +++++++++--- app/src/main/res/values-it/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ .../app/passwordstore/ui/setup/SetupModelTest.kt | 19 +++++++ .../passwordstore/crypto/errors/CryptoException.kt | 4 ++ .../kotlin/app/passwordstore/crypto/KeyUtils.kt | 2 + .../app/passwordstore/crypto/PGPKeyManager.kt | 4 ++ .../app/passwordstore/crypto/PGPKeyManagerTest.kt | 11 ++++ docs/superpowers/plans/2026-11-03-compose-setup.md | 8 ++- 13 files changed, 172 insertions(+), 50 deletions(-) (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt index f2b8ca30..d549624e 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -83,13 +83,16 @@ constructor( /** * 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. + * keys with it. Returns null, or why it failed as a string resource: then the clone must not + * start. */ - suspend fun clearStore(root: File) { - if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return + suspend fun clearStore(root: File): Int? { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null progress = R.string.delete_directory_progress_text - withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() } + 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. */ diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt index e6000206..ed7fcf37 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -13,11 +13,13 @@ 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.util.coroutines.DispatcherProvider import com.github.michaelbull.result.onSuccess @@ -126,18 +128,23 @@ constructor( } } - /** Reads the key file at [uri] and imports it. */ + /** 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 { it.readBytes() } + resolver.openInputStream(uri)?.use { + readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge) + } } - if (bytes == null) importResult = KeyImport.Failed(null) else add(bytes, replace = false) + if (bytes == null) importResult = KeyImport.Failed(null) + else add(bytes, replace = false, downgrade) } catch (e: IOException) { importResult = KeyImport.Failed(e.message) } catch (e: SecurityException) { @@ -168,22 +175,34 @@ constructor( importResult = null } - /** Imports [bytes]; they are zeroed afterwards unless the replace question keeps them. */ - private suspend fun add(bytes: ByteArray, replace: Boolean) { - val result = keyManager.addKey(PGPKey(bytes), replace) - if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { - pending = bytes - importResult = KeyImport.Exists - return + /** + * 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) } - // 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 KeyImport.Failed(result.error.message) - bytes.fill(0) - if (result.isOk) updateKeySet() } /** @@ -223,3 +242,6 @@ constructor( 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/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt index 8c7b20a1..ade23aa2 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -9,8 +9,11 @@ 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. */ @@ -73,9 +76,17 @@ fun cloneTarget(dir: File): CloneTarget { } } -/** The recipients named by a `.gpg-id` file's [text]; blank and unparsable lines are skipped. */ +/** + * 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 = - text.lines().map(String::trim).filter(String::isNotEmpty).mapNotNull(PGPIdentifier::fromString) + 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 @@ -91,12 +102,34 @@ private const val MAX_GPG_ID_BYTES = 64 * 1024 * than 64 KiB or unreadable. Blocking: call it off the main thread. */ fun readGpgId(dir: File): String? { - val file = File(dir, ".gpg-id") - val path = file.toPath() + 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 { - if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) null - else if (Files.size(path) > MAX_GPG_ID_BYTES) null else file.readText() + 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/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt index d70b06af..bc3e9474 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -17,10 +17,8 @@ import app.passwordstore.R import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.sshj.SshKey import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.IOException -import java.security.GeneralSecurityException -import java.security.ProviderException import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,11 +81,10 @@ class SshKeyViewModel @Inject constructor(private val dispatcherProvider: Dispat withContext(dispatcherProvider.io()) { SshKey.import(uri) } imported = true version++ - } catch (e: IOException) { - failure = e.message.orEmpty() - } catch (e: IllegalArgumentException) { - failure = e.message.orEmpty() - } catch (e: SecurityException) { + } 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 @@ -114,11 +111,10 @@ class SshKeyViewModel @Inject constructor(private val dispatcherProvider: Dispat SshKey.sshPublicKey.orEmpty() } version++ - } catch (e: GeneralSecurityException) { - failure = e.message.orEmpty() - } catch (e: ProviderException) { - failure = e.message.orEmpty() - } catch (e: IOException) { + } 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 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 8d5519a8..06cf5bc8 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 @@ -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 @@ -203,15 +205,20 @@ object SshKey { } private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (privateKeyFile.isFile) { privateKeyFile.delete() } + deleteKeyExtras() + type = null + } + + /** Everything of the current key but its private key file. */ + private fun deleteKeyExtras() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (publicKeyFile.isFile) { publicKeyFile.delete() } context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - type = null } fun import(uri: Uri) { @@ -241,12 +248,23 @@ object SshKey { ) // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - + // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's + // Keystore entry, public key and passphrase go. + val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp") + try { + // Canonicalize line endings to '\n'. + temp.writeText(lines.joinToString("\n")) + Files.move( + temp.toPath(), + privateKeyFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } type = Type.Imported + deleteKeyExtras() } /** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c2edc768..192345cb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -372,4 +372,7 @@ Chiave privata importata Generata su questo dispositivo Clonazione… + Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. + Il file supera 1 MiB: non è una chiave PGP. + Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d53700a8..8c046ca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,4 +407,7 @@ Imported private key Generated on this device Cloning… + Could not empty the store folder, so the clone did not start. + The file is larger than 1 MiB: not a PGP key. + 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. diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt index 8d8243eb..53ea80cf 100644 --- a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt @@ -88,6 +88,25 @@ class SetupModelTest { assertEquals(emptyList(), storeRecipients("")) } + @Test + fun storeRecipientsDropCommentsAndExactSubkeyMarks() { + val ids = + storeRecipients( + "# store key\nF8993A789CDFB014! # laptop\n79E8208280490C77!\nalice@example.org#work\n # \n" + ) + assertEquals( + listOf("f8993a789cdfb014", "79e8208280490c77", "alice@example.org"), + ids.map(PGPIdentifier::toString), + ) + } + + @Test + fun readAtMostRefusesMoreThanTheLimit() { + assertEquals("abc", readAtMost("abc".byteInputStream(), 3)?.decodeToString()) + assertEquals("", readAtMost("".byteInputStream(), 3)?.decodeToString()) + assertNull(readAtMost("abcd".byteInputStream(), 3)) + } + @Test fun gpgIdTextIsUppercaseKeyIdsOnePerLine() { val ids = diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt index af6145d0..9589ceec 100644 --- a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt +++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt @@ -39,6 +39,10 @@ public class KeyNotFoundException(keyId: String) : public class KeyAlreadyExistsException(keyId: String) : KeyManagerException("Pre-existing key was found for $keyId") +/** Attempting to replace a stored secret key with a public-only key of the same `keyId`. */ +public class KeyDowngradeException(keyId: String) : + KeyManagerException("A secret key is stored for $keyId; a public key cannot replace it") + /** Sealed exception types for [app.passwordstore.crypto.CryptoHandler]. */ public sealed class CryptoHandlerException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index 311e9049..9acd0489 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -41,6 +41,8 @@ public object KeyUtils { } /** True when [key] parses as a secret key ring, the only kind that can decrypt. */ + // ponytail: GnuPG stub and smartcard keyrings (no usable secret material) count as secret too. + // Upgrade path: check for a secret subkey usable for decryption. public fun isSecretKey(key: PGPKey): Boolean = tryParseKeyring(key) is PGPSecretKeyRing /** diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt index 6f852af4..ef58ad19 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -14,6 +14,7 @@ import app.passwordstore.crypto.errors.InvalidKeyException import app.passwordstore.crypto.errors.KeyAlreadyExistsException import app.passwordstore.crypto.errors.KeyDeletionFailedException import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -59,6 +60,9 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : keyFile.writeBytes(keyBytes) return@runSuspendCatching key } + // Never with replace either: it would delete the only copy of the private key. + existingKeyRing is PGPSecretKeyRing && incomingKeyRing is PGPPublicKeyRing -> + throw KeyDowngradeException(tryGetId(key)?.toString() ?: "Failed to retrieve key ID") } // Check for replace flag first and if it is false, throw an error if (!replace) diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt index 0525c28a..81a666f4 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt @@ -9,6 +9,7 @@ import app.passwordstore.crypto.KeyUtils.tryGetId import app.passwordstore.crypto.PGPIdentifier.KeyId import app.passwordstore.crypto.PGPIdentifier.UserId import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -176,6 +177,16 @@ class PGPKeyManagerTest { assertTrue(keyManager.addKey(publicKey).isErr) } + @Test + fun replaceSecretKeyWithPublicKeyIsRefusedEvenWithReplace() = + runTest(dispatcher) { + keyManager.addKey(secretKey).unwrap() + assertIs(keyManager.addKey(publicKey, replace = true).unwrapError()) + assertIs(keyManager.addKey(publicKey, replace = false).unwrapError()) + // The private key is still the stored one. + assertContentEquals(secretKey.contents, keyManager.getAllKeys().unwrap().single().contents) + } + @Test fun replacePublicKeyWithSecretKey() = runTest(dispatcher) { diff --git a/docs/superpowers/plans/2026-11-03-compose-setup.md b/docs/superpowers/plans/2026-11-03-compose-setup.md index e4bcd5da..d62aeff6 100644 --- a/docs/superpowers/plans/2026-11-03-compose-setup.md +++ b/docs/superpowers/plans/2026-11-03-compose-setup.md @@ -1555,7 +1555,11 @@ fun GitServerDestination( viewModel.progress = R.string.git_clone_progress activity.lifecycleScope.launch { try { - viewModel.clearStore(root) + // 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( @@ -3757,7 +3761,7 @@ deleting `$OUT` if a visual check is wanted (copy them elsewhere first). `sshKeySummary`, `SshKeyType`, `SshKeyViewModel.import/generate/fail/dismissFailure/announced`, `type`, `protect`, `busy`, `version`, `imported`, `generated`, `failure` (Task 2) match Task 4; `GitServerViewModel.url/changeUrl/authMode/progress/cloned/hasHostKey/sshKey/refresh/ - clearHostKey/save/clearStore` (suspend) `/finishClone` (not suspend, launches in + clearHostKey/save/clearStore` (suspend, returns null or a failure string resource) `/finishClone` (not suspend, launches in `viewModelScope`) `/cloneEnded` (Task 2) match Task 3; `KeyRow(userId, keyId)` (no `gpgId` member any more), `KeyImport`, `PgpKeysViewModel.keys/recipients/loadRecipients/busy/ importResult/done/import/replace/dismissImport/deleteKey/createStore/markDone` (Task 2) match -- cgit v1.2.3 From 7f6f5a8ac6efcdf8e6d1e85d8059a5387fa8ee62 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:58:46 +0100 Subject: feat(app): add the Compose welcome and git server screens The welcome screen merges the old welcome and repository type screens. The git server screen serves clone and edit: the old URL checks as dialogs, authentication mode buttons, the SSH key's state with Manage, the host key reset, and a clone that keeps the screen on failure. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/setup/GitServerScreen.kt | 345 +++++++++++++++++++++ .../app/passwordstore/ui/setup/WelcomeScreen.kt | 103 ++++++ app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 452 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt new file mode 100644 index 00000000..fd440996 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt @@ -0,0 +1,345 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.settings.AuthMode +import app.passwordstore.util.settings.GitSettings.UpdateConnectionSettingsResult +import app.passwordstore.util.settings.Protocol +import com.github.michaelbull.result.fold +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Server URL and authentication mode; the SSH key's state ([sshKey], a string resource) with Manage + * while the mode is SSH key; "Clear saved host key" when one is saved. [progress] (a string + * resource) shows while a clone runs, and disables everything. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitServerScreen( + clone: Boolean, + url: String, + authMode: AuthMode, + @StringRes sshKey: Int, + hasHostKey: Boolean, + @StringRes progress: Int?, + onUrlChange: (String) -> Unit, + onAuthModeChange: (AuthMode) -> Unit, + onSshKey: () -> Unit, + onClearHostKey: () -> Unit, + onSubmit: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val idle = progress == null + val modes = remember(url) { authModes(url.trim()) } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_activity_git_clone)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + OutlinedTextField( + value = url, + onValueChange = onUrlChange, + modifier = Modifier.fillMaxWidth(), + enabled = idle, + label = { Text(stringResource(R.string.server_url)) }, + singleLine = true, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), + ) + Text(stringResource(R.string.connection_mode), style = MaterialTheme.typography.labelLarge) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + modes.forEachIndexed { index, mode -> + SegmentedButton( + selected = mode == authMode, + onClick = { onAuthModeChange(mode) }, + shape = SegmentedButtonDefaults.itemShape(index, modes.size), + enabled = idle, + ) { + Text(stringResource(mode.label())) + } + } + } + if (authMode == AuthMode.SshKey) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.connection_mode_ssh_key), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(stringResource(sshKey)) + } + TextButton(onClick = onSshKey, enabled = idle) { + Text(stringResource(R.string.ssh_key_manage)) + } + } + if (hasHostKey) + OutlinedButton(onClick = onClearHostKey, enabled = idle) { + Text(stringResource(R.string.clear_saved_host_key)) + } + if (progress != null) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + Text(stringResource(progress)) + } + Button(onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = idle) { + Text(stringResource(if (clone) R.string.clone_button else R.string.crypto_save)) + } + } + } +} + +@StringRes +private fun AuthMode.label(): Int = + when (this) { + AuthMode.SshKey -> R.string.connection_mode_ssh_key + AuthMode.Password -> R.string.connection_mode_basic_authentication + AuthMode.None -> R.string.connection_mode_none + } + +private sealed interface ServerDialog { + data class Url(val problem: UrlProblem) : ServerDialog + + data class Username(val protocol: Protocol) : ServerDialog + + data object DeleteStore : ServerDialog +} + +/** + * The git server screen: wires [GitServerScreen] to the [viewModel], the URL checks, the save and, + * with [clone], the clone into the store folder. [onCloned] gets whether the store still needs a + * PGP key. A failed clone stays here, so URL or SSH key can be fixed and the clone retried. + */ +@Composable +fun GitServerDestination( + activity: MainActivity, + clone: Boolean, + onClose: () -> Unit, + onSshKey: () -> Unit, + onCloned: (keyNeeded: Boolean) -> Unit, + modifier: Modifier = Modifier, + viewModel: GitServerViewModel = hiltViewModel(), +) { + val root = PasswordRepository.getRepositoryDirectory() + val keyboard = LocalSoftwareKeyboardController.current + var dialog by remember { mutableStateOf(null) } + + // Back from the SSH key screen (or from an instance it opened) with another key. + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose {} + } + // A finished clone moves on from here, also when the screen was recreated in between. + val cloned = viewModel.cloned + LaunchedEffect(cloned) { if (cloned != null) onCloned(cloned) } + // A running clone holds the screen: leaving would cancel it half way. + BackHandler(enabled = viewModel.progress != null) {} + + fun say(message: String) { + activity.snackbar(message = message, length = Snackbar.LENGTH_LONG) + } + + // ponytail: a theme or locale change cancels the clone with the activity's scope, like every + // git operation of the app; the finally block frees the screen, and a new clone first offers to + // delete what was left. Once the clone succeeded, the key check runs in the ViewModel's scope + // (finishClone), so a recreation from then on loses nothing. + fun startClone() { + viewModel.progress = R.string.git_clone_progress + activity.lifecycleScope.launch { + try { + // A folder that could not be emptied is reported as such, not as a vague clone error. + viewModel.clearStore(root)?.let { failure -> + say(activity.getString(failure)) + return@launch + } + activity + .launchGitOperation(GitOp.CLONE) + .fold( + success = { viewModel.finishClone(root) }, + failure = { activity.promptOnErrorHandler(it) }, + ) + } finally { + viewModel.cloneEnded() + } + } + } + + fun submit() { + if (viewModel.progress != null) return + keyboard?.hide() + val problem = urlProblem(viewModel.url.trim()) + if (problem != null) { + dialog = ServerDialog.Url(problem) + return + } + when (val result = viewModel.save()) { + UpdateConnectionSettingsResult.FailedToParseUrl -> + say(activity.getString(R.string.git_server_config_save_error)) + is UpdateConnectionSettingsResult.MissingUsername -> + dialog = ServerDialog.Username(result.newProtocol) + is UpdateConnectionSettingsResult.AuthModeMismatch -> + say( + activity.getString( + R.string.git_server_config_save_auth_mode_mismatch, + result.newProtocol, + result.validModes.joinToString(", "), + ) + ) + UpdateConnectionSettingsResult.Valid -> + if (!clone) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT) + .show() + onClose() + } else { + if (PasswordRepository.repository == null) PasswordRepository.initialize() + if (cloneTarget(root) == CloneTarget.Occupied) dialog = ServerDialog.DeleteStore + else startClone() + } + } + } + + Box(modifier) { + GitServerScreen( + clone = clone, + url = viewModel.url, + authMode = viewModel.authMode, + sshKey = viewModel.sshKey, + hasHostKey = viewModel.hasHostKey, + progress = viewModel.progress, + onUrlChange = viewModel::changeUrl, + onAuthModeChange = { viewModel.authMode = it }, + onSshKey = onSshKey, + onClearHostKey = { + viewModel.clearHostKey() + say(activity.getString(R.string.clear_saved_host_key_success)) + }, + onSubmit = ::submit, + onBack = { if (viewModel.progress == null) onClose() }, + ) + when (val current = dialog) { + null -> Unit + is ServerDialog.Url -> { + val (title, text) = + when (current.problem) { + UrlProblem.HttpsPort -> + R.string.https_scheme_with_port_title to R.string.https_scheme_with_port_message + UrlProblem.SshScheme -> + R.string.ssh_scheme_needed_title to R.string.ssh_scheme_needed_message + UrlProblem.GitScheme -> + R.string.git_scheme_disallowed_title to R.string.git_scheme_disallowed_message + } + val fixed = fixUrl(viewModel.url.trim(), current.problem) + SetupDialog( + title = stringResource(title), + text = stringResource(text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { + dialog = null + fixed?.let(viewModel::changeUrl) + }, + onDismiss = { dialog = null }, + dismiss = if (fixed != null) stringResource(R.string.dialog_cancel) else null, + ) + } + is ServerDialog.Username -> + SetupDialog( + title = stringResource(R.string.ssh_scheme_needed_title), + text = + stringResource( + if (current.protocol == Protocol.Https) + R.string.git_server_config_save_missing_username_https + else R.string.git_server_config_save_missing_username_ssh + ), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = null }, + onDismiss = { dialog = null }, + ) + ServerDialog.DeleteStore -> + SetupDialog( + title = stringResource(R.string.dialog_delete_title), + text = stringResource(R.string.dialog_delete_msg, root.toString()), + confirm = stringResource(R.string.dialog_delete), + onConfirm = { + dialog = null + startClone() + }, + onDismiss = { dialog = null }, + dismiss = stringResource(R.string.dialog_do_not_delete), + ) + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/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. + * 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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 192345cb..51992f04 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -372,6 +372,8 @@ Chiave privata importata Generata su questo dispositivo Clonazione… + Nessuna + Gestisci Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. Il file supera 1 MiB: non è una chiave PGP. Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c046ca5..7f9cfd46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,6 +407,8 @@ Imported private key Generated on this device Cloning… + None + Manage Could not empty the store folder, so the clone did not start. The file is larger than 1 MiB: not a PGP key. 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. -- cgit v1.2.3 From 2ede8b00cf9cc621a81478117701e269c4773fe4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:00:25 +0100 Subject: feat(app): add the Compose SSH key and key generation screens The SSH key screen shows the key's state and, for a generated key, the public key with copy and share, and imports a key file after the old replace confirmation. The generation screen ports the old one; the key is generated in the ViewModel after the screen lock prompt. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/setup/SshKeyScreen.kt | 379 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 3 files changed, 381 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt new file mode 100644 index 00000000..f93fc9be --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import app.passwordstore.R +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.auth.BiometricAuthenticator +import app.passwordstore.util.extensions.keyguardManager +import app.passwordstore.util.git.sshj.SshKey + +/** + * The SSH key: its state ([summary], a string resource), the public key with copy and share when + * the device generated it, import and generation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SshKeyScreen( + @StringRes summary: Int, + publicKey: String?, + busy: Boolean, + onImport: () -> Unit, + onGenerate: () -> Unit, + onCopy: () -> Unit, + onShare: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.ssh_keygen_existing_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + GroupedSurface { + Text( + stringResource(summary), + Modifier.padding(SpacingLarge), + style = MaterialTheme.typography.bodyLarge, + ) + } + if (publicKey != null) + GroupedSurface { + Column( + Modifier.padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Text( + stringResource(R.string.your_public_key), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectionContainer { + Text( + publicKey, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + Text( + stringResource(R.string.ssh_key_public_hint), + style = MaterialTheme.typography.bodySmall, + ) + Row { + TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_label)) } + TextButton(onClick = onShare) { Text(stringResource(R.string.ssh_keygen_share)) } + } + } + } + Column( + Modifier.padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Button(onClick = onImport, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pref_import_ssh_key_title)) + } + OutlinedButton(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pref_ssh_keygen_title)) + } + } + } + } +} + +/** A chooser sharing [publicKey] as text. */ +private fun shareKey(publicKey: String): Intent = + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, publicKey), + null, + ) + +/** + * The SSH key screen: wires [SshKeyScreen] to the [viewModel], the document picker and the replace + * confirmation. [onGenerate] opens the generation screen. + */ +@Composable +fun SshKeyDestination( + activity: MainActivity, + onClose: () -> Unit, + onGenerate: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SshKeyViewModel = hiltViewModel(), +) { + var resumes by remember { mutableIntStateOf(0) } + // Back from the generation screen (or another instance) with a new key: read it again. + LifecycleResumeEffect(Unit) { + resumes++ + onPauseOrDispose {} + } + val version = viewModel.version + val summary = remember(resumes, version) { sshKeySummary() } + val publicKey = + remember(resumes, version) { if (SshKey.canShowSshPublicKey) SshKey.sshPublicKey else null } + var replace by remember { mutableStateOf(false) } + val picker = + rememberLauncherForActivityResult(OpenDocument()) { uri -> + if (uri != null) viewModel.import(uri) + } + + LaunchedEffect(viewModel.imported) { + if (viewModel.imported) { + Toast.makeText(activity, R.string.ssh_key_success_dialog_title, Toast.LENGTH_LONG).show() + viewModel.announced() + } + } + BackHandler(enabled = viewModel.busy) {} + + Box(modifier) { + SshKeyScreen( + summary = summary, + publicKey = publicKey, + busy = viewModel.busy, + onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) }, + onGenerate = onGenerate, + onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } }, + onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } }, + onBack = { if (!viewModel.busy) onClose() }, + ) + if (replace) + SetupDialog( + title = stringResource(R.string.ssh_keygen_existing_title), + text = stringResource(R.string.ssh_keygen_existing_message), + confirm = stringResource(R.string.ssh_keygen_existing_replace), + onConfirm = { + replace = false + picker.launch(arrayOf("*/*")) + }, + onDismiss = { replace = false }, + dismiss = stringResource(R.string.ssh_keygen_existing_keep), + ) + viewModel.failure?.let { message -> + SetupDialog( + title = stringResource(R.string.ssh_key_error_dialog_title), + text = message, + confirm = stringResource(R.string.dialog_ok), + onConfirm = viewModel::dismissFailure, + onDismiss = viewModel::dismissFailure, + ) + } + } +} + +/** Key type with its explanation, the screen lock switch and Generate. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SshKeyGenScreen( + type: SshKeyType, + protect: Boolean, + canProtect: Boolean, + busy: Boolean, + onTypeChange: (SshKeyType) -> Unit, + onProtectChange: (Boolean) -> Unit, + onGenerate: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val types = SshKeyType.entries + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.pref_ssh_keygen_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier.padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(SpacingLarge), + verticalArrangement = Arrangement.spacedBy(SpacingLarge), + ) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + types.forEachIndexed { index, entry -> + SegmentedButton( + selected = entry == type, + onClick = { onTypeChange(entry) }, + shape = SegmentedButtonDefaults.itemShape(index, types.size), + enabled = !busy, + ) { + Text(stringResource(entry.label)) + } + } + } + // ponytail: stringResource drops the explanations' bold title; plain text reads fine. + Text(stringResource(type.explanation), style = MaterialTheme.typography.bodyMedium) + Row( + Modifier.fillMaxWidth() + .toggleable( + value = protect, + enabled = canProtect && !busy, + role = Role.Switch, + onValueChange = onProtectChange, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.ssh_keygen_require_authentication), Modifier.weight(1f)) + Switch(checked = protect, onCheckedChange = null, enabled = canProtect && !busy) + } + Button(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) { + Text( + stringResource( + if (busy) R.string.ssh_key_gen_generating_progress else R.string.ssh_keygen_generate + ) + ) + } + } + } +} + +/** + * The key generation screen: replace confirmation, screen lock prompt when protected, then the + * generation in the [viewModel] and the public key (or error) dialog; both dialogs close it. + */ +@Composable +fun SshKeyGenDestination( + activity: MainActivity, + onClose: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SshKeyViewModel = hiltViewModel(), +) { + val canProtect = remember { activity.keyguardManager.isDeviceSecure } + var replace by remember { mutableStateOf(false) } + // While the screen lock prompt is up. Plain remember: a recreation drops the prompt's callback, + // and the screen must not stay disabled for it. + var asking by remember { mutableStateOf(false) } + + fun generate() { + if (!(canProtect && viewModel.protect)) return viewModel.generate(requireAuthentication = false) + asking = true + BiometricAuthenticator.authenticate(activity, R.string.biometric_prompt_title_ssh_keygen) { + result -> + // Failed attempts are handled by the prompt itself. + if (result is BiometricAuthenticator.Result.Retry) return@authenticate + asking = false + if (result is BiometricAuthenticator.Result.Success) + viewModel.generate(requireAuthentication = true) + else viewModel.fail(activity.getString(R.string.biometric_auth_generic_failure)) + } + } + + BackHandler(enabled = viewModel.busy) {} + + Box(modifier) { + SshKeyGenScreen( + type = viewModel.type, + protect = canProtect && viewModel.protect, + canProtect = canProtect, + busy = viewModel.busy || asking, + onTypeChange = { viewModel.type = it }, + onProtectChange = { viewModel.protect = it }, + onGenerate = { if (SshKey.exists) replace = true else generate() }, + onBack = { if (!viewModel.busy) onClose() }, + ) + if (replace) + SetupDialog( + title = stringResource(R.string.ssh_keygen_existing_title), + text = stringResource(R.string.ssh_keygen_existing_message), + confirm = stringResource(R.string.ssh_keygen_existing_replace), + onConfirm = { + replace = false + generate() + }, + onDismiss = { replace = false }, + dismiss = stringResource(R.string.ssh_keygen_existing_keep), + onDismissButton = onClose, + ) + viewModel.generated?.let { key -> + SetupDialog( + title = stringResource(R.string.your_public_key), + text = stringResource(R.string.ssh_keygen_message, key), + confirm = stringResource(R.string.ssh_keygen_share), + onConfirm = { + activity.startActivity(shareKey(key)) + onClose() + }, + onDismiss = onClose, + dismiss = stringResource(R.string.ssh_keygen_later), + ) + } + viewModel.failure?.let { message -> + SetupDialog( + title = stringResource(R.string.error_generate_ssh_key), + text = stringResource(R.string.ssh_key_error_dialog_text) + message, + confirm = stringResource(R.string.dialog_ok), + onConfirm = onClose, + onDismiss = onClose, + ) + } + } +} diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 51992f04..50d89f4b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -374,6 +374,7 @@ Clonazione… Nessuna Gestisci + Fornisci questa chiave pubblica al tuo server Git. Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. Il file supera 1 MiB: non è una chiave PGP. Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7f9cfd46..812a33e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -409,6 +409,7 @@ Cloning… None Manage + Provide this public key to your Git server. Could not empty the store folder, so the clone did not start. The file is larger than 1 MiB: not a PGP key. 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. -- cgit v1.2.3 From e4915013a54fb642a639dc0ceaaa2d76696be33e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:02:03 +0100 Subject: feat(app): add the Compose PGP keys screen One screen for managing keys, choosing the key of a new local store and importing the key a store needs. Rows show user ID and key ID; the import keeps the old success, replace and failure dialogs. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/setup/PgpKeysScreen.kt | 331 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + 3 files changed, 337 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt (limited to 'app/src/main/res/values-it') 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..2f060f56 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -0,0 +1,331 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.commitChange +import app.passwordstore.util.extensions.snackbar +import com.github.michaelbull.result.onFailure +import com.google.android.material.snackbar.Snackbar +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable + +/** What the PGP keys screen is for. */ +@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, + recipients: ImmutableList, + busy: Boolean, + onImport: () -> Unit, + onSelect: (KeyRow) -> Unit, + onDelete: (KeyRow) -> Unit, + onLater: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var deleting by remember { mutableStateOf(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 label's semantics; the FAB takes its description from the icon. + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.pref_import_pgp_key_title)) }, + icon = { Icon(Icons.Filled.Add, stringResource(R.string.pref_import_pgp_key_title)) }, + onClick = { if (!busy) onImport() }, + ) + }, + ) { 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; the next commit picks up + // the new `.gpg-id`, and choosing the key again re-creates nothing that would conflict. + fun choose(row: KeyRow) { + if (viewModel.busy) return + viewModel.busy = true + activity.lifecycleScope.launch { + try { + val error = viewModel.createStore(root, row) + if (error != null) { + activity.snackbar( + message = activity.getString(R.string.store_create_failed, error), + length = Snackbar.LENGTH_LONG, + ) + } else { + // A failed commit is reported like any git error; the store exists either way, and + // the next commit picks up the `.gpg-id`. + activity + .commitChange( + activity.getString(R.string.git_commit_gpg_id, activity.getString(R.string.app_name)) + ) + .onFailure { activity.promptOnErrorHandler(it) } + viewModel.markDone() + } + } finally { + viewModel.busy = false + } + } + } + + Box(modifier) { + PgpKeysScreen( + mode = mode, + keys = viewModel.keys, + recipients = viewModel.recipients, + busy = viewModel.busy, + onImport = { picker.launch(arrayOf("*/*")) }, + onSelect = ::choose, + onDelete = viewModel::deleteKey, + onLater = onDone, + 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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 50d89f4b..050623b7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -375,6 +375,9 @@ Nessuna Gestisci Fornisci questa chiave pubblica al tuo server Git. + Importa la chiave privata per cui è cifrato questo archivio, così le sue voci si possono leggere e salvare. + Destinatari dell\'archivio + Impossibile creare l\'archivio: %1$s Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. Il file supera 1 MiB: non è una chiave PGP. Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 812a33e5..055e0593 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,6 +410,9 @@ None Manage Provide this public key to your Git server. + Import the private key this store is encrypted for, so its entries can be read and saved. + Store recipients + Could not create the store: %1$s Could not empty the store folder, so the clone did not start. The file is larger than 1 MiB: not a PGP key. 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. -- cgit v1.2.3 From b4e1002735486bf56b67b8233c2c2abfa875eed4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:14:04 +0100 Subject: fix(app): localize the occupied store refusal Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt | 2 +- app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt | 8 ++++---- app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt index 9ba96bbb..34e5bed6 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -261,7 +261,7 @@ fun PgpKeysDestination( viewModel.busy = true activity.lifecycleScope.launch { try { - val error = viewModel.createStore(root, row) + 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), diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt index afb65265..d270275f 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -208,13 +208,13 @@ constructor( /** * 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. + * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed; [occupied] is the + * reason given when [root] already holds a store. */ - suspend fun createStore(root: File, row: KeyRow): String? = + suspend fun createStore(root: File, row: KeyRow, occupied: String): String? = withContext(dispatcherProvider.io()) { // A restored, already-cloned store must keep its .gpg-id. - if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) - return@withContext "the folder is not empty" + if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) return@withContext occupied try { check(root.isDirectory || root.mkdirs()) { "Could not create $root" } PasswordRepository.createRepository(root) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 050623b7..72bcb009 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -378,6 +378,7 @@ Importa la chiave privata per cui è cifrato questo archivio, così le sue voci si possono leggere e salvare. Destinatari dell\'archivio Impossibile creare l\'archivio: %1$s + la cartella contiene già un archivio Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. Il file supera 1 MiB: non è una chiave PGP. Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 055e0593..dc8069b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -413,6 +413,7 @@ Import the private key this store is encrypted for, so its entries can be read and saved. Store recipients Could not create the store: %1$s + the folder already holds a store Could not empty the store folder, so the clone did not start. The file is larger than 1 MiB: not a PGP key. 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. -- cgit v1.2.3 From 2911783653f699e41a07bb9536460d9d0a1c3b70 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:18:42 +0100 Subject: refactor(app): remove the legacy setup screens The Compose welcome, git server, SSH key, key generation and PGP keys screens replace OnboardingActivity with its fragments, GitServerConfigActivity with BasicBottomSheet, SshKeyImportActivity, SshKeyGenActivity, ShowSshKeyFragment, PGPKeyImportActivity and PGPKeyListActivity with KeyList and PGPKeyListViewModel, their layouts and manifest entries, and ui:compose's APSAppBar. Resources only they used are removed too. Co-Authored-By: Claude Opus 5.5 --- app/lint.xml | 6 - app/src/main/AndroidManifest.xml | 29 -- .../passwordstore/ui/dialogs/BasicBottomSheet.kt | 178 ------------ .../ui/git/config/GitServerConfigActivity.kt | 304 --------------------- .../ui/onboarding/activity/OnboardingActivity.kt | 27 -- .../ui/onboarding/fragments/CloneFragment.kt | 78 ------ .../onboarding/fragments/KeySelectionFragment.kt | 77 ------ .../ui/onboarding/fragments/WelcomeFragment.kt | 33 --- .../passwordstore/ui/pgp/PGPKeyImportActivity.kt | 109 -------- .../java/app/passwordstore/ui/pgp/PGPKeyList.kt | 169 ------------ .../app/passwordstore/ui/pgp/PGPKeyListActivity.kt | 94 ------- .../ui/sshkeygen/ShowSshKeyFragment.kt | 39 --- .../ui/sshkeygen/SshKeyGenActivity.kt | 173 ------------ .../ui/sshkeygen/SshKeyImportActivity.kt | 66 ----- .../util/viewmodel/PGPKeyListViewModel.kt | 48 ---- app/src/main/res/drawable/ic_add_48dp.xml | 15 - .../main/res/drawable/ic_arrow_back_black_24dp.xml | 15 - app/src/main/res/layout/activity_git_clone.xml | 94 ------- app/src/main/res/layout/activity_onboarding.xml | 11 - app/src/main/res/layout/activity_ssh_keygen.xml | 69 ----- app/src/main/res/layout/basic_bottom_sheet.xml | 62 ----- app/src/main/res/layout/fragment_clone.xml | 96 ------- app/src/main/res/layout/fragment_key_selection.xml | 80 ------ app/src/main/res/layout/fragment_welcome.xml | 67 ----- app/src/main/res/values-de/strings.xml | 5 - app/src/main/res/values-fr/strings.xml | 4 - app/src/main/res/values-gl/strings.xml | 5 - app/src/main/res/values-hdpi/dimens.xml | 10 - app/src/main/res/values-it/strings.xml | 5 - app/src/main/res/values-ko-rKR/strings.xml | 5 - app/src/main/res/values-pl-rPL/strings.xml | 4 - app/src/main/res/values-pt-rBR/strings.xml | 5 - app/src/main/res/values-ru/strings.xml | 5 - app/src/main/res/values-zh-rCN/strings.xml | 7 - app/src/main/res/values/dimens.xml | 4 - app/src/main/res/values/strings.xml | 5 - .../specs/2026-11-02-compose-ui-overhaul-design.md | 17 ++ .../main/kotlin/app/passwordstore/ui/APSAppBar.kt | 76 ------ 38 files changed, 17 insertions(+), 2079 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt delete mode 100644 app/src/main/res/drawable/ic_add_48dp.xml delete mode 100644 app/src/main/res/drawable/ic_arrow_back_black_24dp.xml delete mode 100644 app/src/main/res/layout/activity_git_clone.xml delete mode 100644 app/src/main/res/layout/activity_onboarding.xml delete mode 100644 app/src/main/res/layout/activity_ssh_keygen.xml delete mode 100644 app/src/main/res/layout/basic_bottom_sheet.xml delete mode 100644 app/src/main/res/layout/fragment_clone.xml delete mode 100644 app/src/main/res/layout/fragment_key_selection.xml delete mode 100644 app/src/main/res/layout/fragment_welcome.xml delete mode 100644 app/src/main/res/values-hdpi/dimens.xml delete mode 100644 ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt (limited to 'app/src/main/res/values-it') diff --git a/app/lint.xml b/app/lint.xml index c0d501e0..8834cbd7 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -13,12 +13,6 @@ - - - - - - diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0429b2ac..546bdde4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,11 +33,6 @@ tools:ignore="GoogleAppIndexingWarning" tools:targetApi="s"> - - - - - - - - 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? = 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/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/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/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) { - 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, - 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/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() ?: 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/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 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/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 @@ - - - - - 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 @@ - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9fa4b00..493391a8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -171,7 +171,6 @@ Zeige das Passwort. Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren. Passwort unverschlüsselt senden an… - App-Icon Eintrag zum Ausfüllen auswählen Phishing-resistente Suche @@ -283,16 +282,12 @@ 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 Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen. - Um fortzufahren, muss ein PGP-Schlüssel ausgewählt werden. Dateiname bereits vergeben Ein Ordner mit diesem Namen existiert bereits. - Los geht\'s - Wählen Sie einen \nRepository-Typ. Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten. Remote-Repository klonen Lokales Repository anlegen - Wählen Sie einen\nPGP-Schlüssel. Wählen einen PGP-Schlüssel, mit dem der Store initialisiert werden soll. Schlüssel auswählen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d41048f8..d2a469e7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -159,7 +159,6 @@ Montrer le mot de passe Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe Envoyer le mot de passe en clair via… - Icône de l\'application Sélectionnez l\'entrée à remplir Recherche protégée contre le hameçonnage @@ -271,12 +270,9 @@ Un fichier portant ce nom existe déjà Un dossier portant ce nom existe déjà - Allons-y - Sélectionnez le type de dépôt \n Sélectionnez si vous voulez créer un dépôt local ou cloner un dépôt distant. Cloner le dépôt distant Créer un dépôt local - Sélectionnez\nla clé GPG Sélectionnez une clé GPG pour initialiser votre magasin Sélectionner la clé diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 3fb321e7..ad4c663d 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -172,7 +172,6 @@ Mostrar contrasinal Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal Enviar contrasinal como texto plano usando… - Icona da app Elixe o elemento a completar Busca resistente ao phishing @@ -285,16 +284,12 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid 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 O repositorio non cambiou de base, non é preciso cambiar a outra póla - Hai que elexir unha chave GPG para continuar Xa existe un ficheiro con ese nome Xa existe un cartafol con ese nome - Imos alá - Escolle \nTipo de repositorio Escolle se queres crear un repositorio local ou clonar un remoto. Clonar repositorio remoto Crear repositorio local - Elixe\nChave\nGPG Elixe a chave GPG coa que queres iniciar a almacenaxe Elixe chave 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 @@ - - - - 50dp - 70dp - 48dp - diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 72bcb009..8edc9d3d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -154,7 +154,6 @@ Mostra la password Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti. Invia password come testo semplice usando… - Icona app Seleziona la voce da compilare Ricerca resistente al phishing @@ -265,12 +264,9 @@ Un file con quel nome esiste già Una cartella con quel nome esiste già - Andiamo - Seleziona \nTipo di repository Seleziona se vuoi creare un repo locale o clonarne uno remoto. Clona repo remoto Crea repo locale - Seleziona\nChiave\nGPG Seleziona una chiave GPG con cui inizializzare il tuo archivio Seleziona chiave @@ -315,7 +311,6 @@ Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido Trovato .gpg-id, ma contiene un ID esadecimale corto, che non è supportato Merge non riuscito, il repository è in conflitto. - Per proseguire devi selezionare una chiave GPG Il protocollo git:// è sconsigliato Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure. Impostazioni proxy diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 3efbed68..aaa438dd 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -167,7 +167,6 @@ 비밀번호 표시하기 암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다. 비밀번호를 일반 텍스트로 보내는 중… - 앱 아이콘 입력할 항목 선택 피싱 방지 검색 @@ -281,16 +280,12 @@ username@example.com:… 리베이스하는 동안 충돌이 발생했습니다. 로컬 %1$s 브랜치가 %2$s이라는 다른 브랜치로 푸시되었습니다.\n이 브랜치를 사용하여 컴퓨터에서 충돌을 해결하세요. 이 저장소는 리베이스가 아니므로 다른 브랜치로 푸시할 필요가 없습니다. - 계속하려면 GPG 키를 선택해야 합니다 동일한 이름의 파일이 이미 있습니다 이 이름의 폴더가 이미 존재합니다 - 시작합니다! - 저장소 유형 \n선택 로컬 리포지토리를 만들 것인지, 원격 리포지토리를 복제할 것인지 선택합니다. 원격 저장소 복제 로컬 저장소 생성 - GPG 키\n선택 스토어를 초기화할 GPG 키를 선택합니다 키 선택 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 65f14ce5..815cf41c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -168,7 +168,6 @@ Pokaż hasło Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka. Wyślij niezaszyfrowane hasło, używając… - Ikona aplikacji Wybierz wpis do wypełnienia Wyszukiwanie odporne na wyłudzenie @@ -278,12 +277,9 @@ Plik o tej nazwie już istnieje Folder o tej nazwie już istnieje - Zaczynajmy - Wybierz \ntyp repozytorium Zaznacz, czy chcesz utworzyć lokalne repozytorium, czy sklonować zdalne repozytorium. Klonuj zdalne repozytorium Utwórz lokalne repozytorium - Wybierz\nklucz GPG Wybierz klucz GPG, którym zainicjować swoje repozytorium Wybierz klucz diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 86ebfb24..a1d1a38c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -171,7 +171,6 @@ Mostrar a senha Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência. Enviar senha como texto simples usando… - Ícone do aplicativo Selecione a entrada para preencher Busca resistente a phishing @@ -283,16 +282,12 @@ 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 O repositório não está sendo rebased, não há necessidade de fazer push para outro branch - A seleção de uma chave GPG é necessária para prosseguir Já existe um arquivo com esse nome Já existe uma pasta com esse nome - Vamos lá - Selecione \nTipo de Repositório Selecione se você quer criar um repositório local ou clonar um repositório remoto. Clonar repositório remoto Criar repositório local - Selecione\nChave GPG Selecione uma chave GPG para inicializar o armazenamento com Selecione a chave diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8873b34f..d0934a4d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -164,7 +164,6 @@ Показывать пароли Видимость расшифрованных паролей, не влияет на копирование в буфер. Поделиться паролем в открытом виде с помощью - Иконка приложения Выберите запись для заполнения Устойчивый к фишингу поиск @@ -275,16 +274,12 @@ При попытке rebase\'а возник конфликт. Ваша локальная ветка %1$s была запушена %2$s\n Используйте эту ветку для разрешения конфликтов с помощью компьютера Не нужно пушить в другую ветку - rebase не был запущен - Для продолжения необходимо выбрать GPG-ключ Файл с таким именем уже существует Папка с таким именем уже существует - Поехали - Выберите \nтип репозитория Создать локальное хранилище, или клонировать удаленный репозиторий. Клонировать удаленный репозиторий Создать локальное хранилище - Выберите\nGPG ключ Выберите GPG ключ для инициализации хранилища Выберите ключ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 746b74d2..ddcdda1a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -172,7 +172,6 @@ 显示密码 控制密码解密后的可见性,这不会禁用复制到剪贴板 使用明文发送密码… - 应用图标 选择要填写的条目 防钓鱼搜索 @@ -285,18 +284,12 @@ personal.com 使用此分支解决计算机上的冲突 存储库没有变更,无需推送到另一个分支 - 选择一个 GPG 密钥才能继续 该名称的文件已存在 该名称的文件夹已存在 - 开始配置 - 选择仓库模式 你是要同步仓库还是现在创建一个仓库呢? 同步远程仓库 创建本地仓库 - 选择 -GPG -密钥 选择一个GPG密钥来初始化您的仓库 选择密钥 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e5d7ff94..8351a358 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,9 +7,5 @@ 16dp 16dp - 16dp 8dp - 100dp - 140dp - 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc8069b8..59ba74cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,7 +199,6 @@ Show the password Control the visibility of the passwords once decrypted. This does not disable copying to clipboard. Send password as plaintext using… - App icon Select entry to fill into @@ -321,17 +320,13 @@ The repository is not rebasing, no need to push to another branch - Selecting a GPG key is necessary to proceed A file by that name already exists A folder by that name already exists - Let\'s Go - Select \nRepository Type Select if you want to create a local repo or clone a remote repo. Clone Remote Repo Create Local Repo - Select\nGPG Key Select a GPG key to initialize your store with Select key diff --git a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md index 7eab1692..94f76479 100644 --- a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +++ b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md @@ -51,6 +51,23 @@ the maintainer's phone stays on 0.1.4 until then. editor was opened. The folder picker is a dialog, shared with the list's move action. The autofill save flow (`AutofillSaveActivity`) hosts the same editor. +### Setup + +- Without a store the app opens on a welcome screen: clone a remote repository or create a + local store; Settings is reachable from it. +- The git server screen serves the clone and later edits: URL, authentication mode (SSH key or + password; none or password for HTTP(S)), the SSH key's state with "Manage", "Clear saved host + key". A failed clone stays on the screen, so URL or key can be fixed and the clone retried. +- The SSH key screen shows the key's state and, for a key generated on the device, the public + key (copy, share); it imports a key file (refusing files sshj cannot read, such as OpenPGP + keys) and opens key generation (RSA, ECDSA, Ed25519, optionally behind the screen lock). +- One PGP keys screen manages keys (import, delete), picks the key of a new local store (the + repository is created only then, its `.gpg-id` naming the key by its uppercase long key ID, as + `pass init ` does), and imports the key a store needs: after a clone that no + imported key can decrypt, and when an entry is opened with no key imported. +- Screens outside the graph (Settings, autofill, git operations) and the entry view's key + import open these through a `MainActivity` setup intent until steps 6 and 7. + ## Architecture ### Activities diff --git a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt b/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.kt deleted file mode 100644 index 7ac99023..00000000 --- a/ui/compose/src/main/kotlin/app/passwordstore/ui/APSAppBar.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 - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import app.passwordstore.ui.compose.preview.DevicePreviews -import app.passwordstore.ui.compose.preview.ThemePreviews -import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.compose.theme.SpacingMedium - -@SuppressLint("ComposableLambdaParameterNaming") // The lint doesn't really apply to `actions` -@Composable -@OptIn(ExperimentalMaterial3Api::class) -public fun APSAppBar( - title: String, - backgroundColor: Color, - navigationIcon: Painter?, - modifier: Modifier = Modifier, - onNavigationIconClick: (() -> Unit) = {}, - actions: @Composable RowScope.() -> Unit = {}, -) { - TopAppBar( - title = { Text(text = title) }, - navigationIcon = { - if (navigationIcon != null) { - IconButton(onClick = onNavigationIconClick) { - Icon(painter = navigationIcon, contentDescription = "Back navigation button") - } - } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors(containerColor = backgroundColor), - modifier = modifier.shadow(SpacingMedium), - ) -} - -@ThemePreviews -@DevicePreviews -@Composable -private fun APSAppBarPreview() { - APSTheme { - APSAppBar( - title = "Preview", - backgroundColor = MaterialTheme.colorScheme.surface, - navigationIcon = rememberVectorPainter(Icons.AutoMirrored.Filled.ArrowBack), - actions = { - IconButton(onClick = {}) { - Icon( - painter = rememberVectorPainter(Icons.Filled.Search), - contentDescription = "Search items", - ) - } - }, - ) - } -} -- cgit v1.2.3 From cc880cf62ea8391f693f034858578cd4b693043b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:36:42 +0100 Subject: feat(app): drop two sort orders, fix the proxy user name The Compose list shows folders and entries in separate groups, so the files first and type independent sort orders did nothing visible. They are removed; a stored one migrates to folders first, and any unknown value reads as folders first instead of throwing. The OpenKeychain SSH key ID, which nothing reads any more, is removed once. GitSettings wrote the proxy user name to the proxy preferences but read it from the main settings, so ProxyUtils never authenticated. Adds the pure parts of the settings port: proxy port parsing, HEAD status and the commit log. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/settings/SettingsModel.kt | 79 +++++++++++++++++++ .../app/passwordstore/util/settings/GitSettings.kt | 2 +- .../app/passwordstore/util/settings/Migrations.kt | 19 +++++ .../util/settings/PasswordSortOrder.kt | 17 ++-- .../viewmodel/SearchableRepositoryViewModel.kt | 7 +- app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-gl/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ko-rKR/strings.xml | 2 - app/src/main/res/values-pl-rPL/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/arrays.xml | 4 - app/src/main/res/values/strings.xml | 2 - .../passwordstore/ui/settings/SettingsModelTest.kt | 90 ++++++++++++++++++++++ .../passwordstore/util/settings/GitSettingsTest.kt | 27 +++++++ .../passwordstore/util/settings/MigrationsTest.kt | 25 ++++++ .../util/settings/PasswordSortOrderTest.kt | 26 +++++++ 20 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt (limited to 'app/src/main/res/values-it') 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. + * 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 { + 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/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) (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) 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/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index ca570dc4..f8cbf1e4 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -71,12 +71,7 @@ private fun PasswordItem.Companion.makeComparator( directoryStructure: DirectoryStructure, ): Comparator { 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 { it.type } PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator } .then( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 493391a8..618aac0d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,8 +96,6 @@ Findet Passwörter auch in Unterordnern. Passwortsortierung Ordner zuerst - Dateien zuerst - typunabhängig Zuletzt verwendet Automatisch ausfüllen Autofill aktivieren diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d2a469e7..4cc7d29d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -94,8 +94,6 @@ Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel. Ordre de tri des mots de passe Dossiers en premier - Fichiers en premier - Indifférent au type d\'entrée Récemment utilisé Saisie automatique Saisie automatique diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index ad4c663d..34b1ce6a 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -97,8 +97,6 @@ Organización dos contrasinais Orde para mostrar contrasinais Primeiro cartafoles - Primeiro ficheiros - Independentemente do tipo Usadas recentemente Completado automático Activar completado automático diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8edc9d3d..9b67482c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -94,8 +94,6 @@ Trova ricorsivamente le password della directory corrente. Ordine password Prima le cartelle - Prima i file - Indipendente dal tipo Usato di recente Compilazione automatica Attiva la compilazione automatica diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index aaa438dd..06e0a555 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -95,8 +95,6 @@ 암허 파일 정리 암호 정렬 순서 폴더 우선 - 파일 우선 - 타입순 최신순 자동완성 자동완성 활성화 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 815cf41c..6bdb966c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -94,8 +94,6 @@ Organizacja plików haseł Kolejność sortowania haseł Najpierw foldery - Najpierw pliki - Niezależnie od typu Ostatnio używane Autouzupełnianie Włącz autouzupełnianie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a1d1a38c..8f40c831 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,8 +96,6 @@ Encontrar senhas do diretório corrente recursivamente. Ordenação da Senha Pastas primeiro - Arquivos primeiro - Tipo independente Usado recentemente Preenchimento Automático Ativar preenchimento automático diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d0934a4d..af3078bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -99,8 +99,6 @@ Рекурсивный поиск паролей в текущей директории Порядок сортировки паролей Сначала папки - Сначала файлы - Типонезависимый Недавно использованные Автозаполнение Включить автозаполнение diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ddcdda1a..959d5fac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -95,8 +95,6 @@ 递归式寻找当前目录的密码 密码排序顺序 目录优先 - 文件优先 - 类型无关 最近使用 自动填充 启用自动填充 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9f44710b..2891987d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,14 +6,10 @@ @string/pref_folder_first_sort_order - @string/pref_file_first_sort_order - @string/pref_type_independent_sort_order @string/pref_recently_used_sort_order FOLDER_FIRST - FILE_FIRST - INDEPENDENT RECENTLY_USED diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59ba74cb..dc9140f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,8 +116,6 @@ Password file organization Password sort order Folders first - Files first - Type independent Recently used Autofill Enable Autofill diff --git a/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt new file mode 100644 index 00000000..693e07dd --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.git.GitCommit +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.util.FS + +class SettingsModelTest { + + private val root = Files.createTempDirectory("aps-settings").toFile() + private val identity = PersonIdent("test", "test@example.org") + + init { + // Ignore the developer's ~/.gitconfig (default branch, commit signing). + FS.DETECTED.setUserHome(root) + } + + @AfterTest fun cleanUp() = root.deleteRecursively().let {} + + private fun repo(): Git = + Git.init().setInitialBranch("master").setDirectory(File(root, "store")).call() + + private fun Git.commitFile(name: String, message: String): RevCommit { + File(repository.workTree, name).writeText(message) + add().addFilepattern(name).call() + return commit() + .setMessage(message) + .setAuthor(identity) + .setCommitter(identity) + .setSign(false) + .call() + } + + @Test + fun proxyPorts() { + assertEquals(-1, proxyPort("")) + assertEquals(-1, proxyPort(" ")) + assertEquals(8080, proxyPort("8080")) + assertEquals(8080, proxyPort(" 8080 ")) + assertEquals(1, proxyPort("1")) + assertEquals(65535, proxyPort("65535")) + assertNull(proxyPort("0")) + assertNull(proxyPort("65536")) + assertNull(proxyPort("99999999999")) + assertNull(proxyPort("-1")) + assertNull(proxyPort("80a")) + } + + @Test + fun headStatusFollowsHead() { + repo().use { git -> + // A new repository: HEAD names a branch that has no commit yet. + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + git.commitFile("b.gpg", "Add b") + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + assertFalse(needsAbort(git.repository)) + git.checkout().setName(first.name).call() + assertEquals(HeadStatus.Detached(first.name.take(8)), headStatus(git.repository)) + } + } + + @Test + fun gitLogIsNewestFirst() { + assertEquals(emptyList(), gitLog(null)) + repo().use { git -> + // No commit yet: JGit's NoHeadException is an empty log, not a crash. + assertEquals(emptyList(), gitLog(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + val second = git.commitFile("b.gpg", "Add b") + val log = gitLog(git.repository) + assertEquals(listOf("Add b", "Add a"), log.map(GitCommit::shortMessage)) + assertEquals(listOf(second.name, first.name), log.map(GitCommit::hash)) + assertEquals("test", log[0].authorName) + } + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt new file mode 100644 index 00000000..6b30c248 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import app.passwordstore.util.extensions.getString +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GitSettingsTest { + + @Test + fun proxyUsernameIsReadWhereItIsWritten() { + val settings = SPMockBuilder().createSharedPreferences() + val proxy = SPMockBuilder().createSharedPreferences() + val gitSettings = + GitSettings(settings, SPMockBuilder().createSharedPreferences(), proxy, "/nonexistent") + gitSettings.proxyUsername = "proxy-user" + assertEquals("proxy-user", gitSettings.proxyUsername) + assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME)) + assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt index 62109563..44bd12ba 100644 --- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt +++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt @@ -184,4 +184,29 @@ class MigrationsTest { assertFalse { sharedPrefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO) } assertTrue { sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, false) } } + + @Test + fun removedSortOrdersFallBackToFoldersFirst() { + val gitSettings = + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir) + for (removed in listOf("FILE_FIRST", "INDEPENDENT")) { + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, removed) } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("FOLDER_FIRST", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("RECENTLY_USED", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + + @Test + fun staleOpenKeystoreKeyIdIsRemoved() { + sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, "0123456789ABCDEF") } + runMigrations( + filesDir, + sharedPrefs, + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir), + ) + assertFalse(sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) + } } diff --git a/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt new file mode 100644 index 00000000..34348692 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import androidx.core.content.edit +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals + +class PasswordSortOrderTest { + + @Test + fun unknownAndRemovedValuesReadAsFoldersFirst() { + val prefs = SPMockBuilder().createSharedPreferences() + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + for (value in listOf("FILE_FIRST", "INDEPENDENT", "bogus")) { + prefs.edit { putString(PreferenceKeys.SORT_ORDER, value) } + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + } + prefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + assertEquals(PasswordSortOrder.RECENTLY_USED, PasswordSortOrder.getSortOrder(prefs)) + } +} -- cgit v1.2.3 From f58576017be0a280523a75a3580094ffac4ee7bd Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:39:12 +0100 Subject: feat(app): add the Compose settings screens The categories and one screen per category, with the old rows over the same keys. Screen lock gated switches store their value only after a successful prompt. Clearing a saved HTTPS password or SSH passphrase, which never removed anything, now asks and removes it. Co-Authored-By: Claude Opus 5.5 --- .../ui/settings/SettingsDestination.kt | 297 ++++++++++++++++++ .../passwordstore/ui/settings/SettingsScreen.kt | 348 +++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + 4 files changed, 655 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt (limited to 'app/src/main/res/values-it') 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..1885c1ee --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt @@ -0,0 +1,297 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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(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. + 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() + refresh() + } + PreferenceKeys.BIOMETRIC_AUTH_2 -> + authenticate(R.string.biometric_prompt_title) { result -> + if (result is Result.Success) { + viewModel.putBoolean(key, value) + // Shortcuts were built for the old lock setting, as the old screen knew. + activity.getSystemService()?.removeAllDynamicShortcuts() + } + } + 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/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt new file mode 100644 index 00000000..2327551c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -0,0 +1,348 @@ +/* + * Copyright (C) 2026 Danilo M. + * 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.text.input.KeyboardType +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.GroupedSurface +import app.passwordstore.util.features.Feature +import app.passwordstore.util.settings.PreferenceKeys +import kotlinx.collections.immutable.persistentListOf +import kotlinx.serialization.Serializable + +/** The settings categories, one screen each. A route argument: kept from minification. */ +@Keep +@Serializable +enum class SettingsSection(@StringRes val title: Int, @DrawableRes val icon: Int) { + General(R.string.pref_category_general_title, R.drawable.app_settings_alt_24px), + Autofill(R.string.pref_category_autofill_title, R.drawable.ic_wysiwyg_24px), + Passwords(R.string.pref_category_passwords_title, R.drawable.ic_password_24px), + Repository(R.string.pref_category_repository_title, R.drawable.ic_call_merge_24px), + Misc(R.string.pref_category_misc_title, R.drawable.ic_miscellaneous_services_24px), + Pgp(R.string.pref_category_pgp_title, R.drawable.ic_lock_open_24px), +} + +/** What a row asks for beyond storing a value: another screen, a dialog or a picker. */ +enum class SettingsAction { + GitServer, + Proxy, + GitConfig, + SshKey, + SshKeyGen, + KeyManager, + ClearSecret, + DeleteRepository, + Export, +} + +/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScaffold( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) { + GroupedSurface(content = content) + } + } +} + +/** The settings categories. */ +@Composable +fun SettingsScreen( + onSection: (SettingsSection) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(R.string.action_settings), onBack, modifier) { + SettingsSection.entries.forEach { section -> + ListItem( + headlineContent = { Text(stringResource(section.title)) }, + modifier = Modifier.clickable { onSection(section) }, + leadingContent = { Icon(painterResource(section.icon), contentDescription = null) }, + colors = rowColors(), + ) + } + } +} + +/** + * One category's rows. Switches and values go out through [onBoolean] and [onString] with their + * preference key (the destination decides which need a prompt first); everything else through + * [onAction]. [canAuthenticate] and [autofillEnabled] come from the activity. + */ +@Composable +fun SettingsSectionScreen( + section: SettingsSection, + state: SettingsState, + canAuthenticate: Boolean, + autofillEnabled: Boolean, + deleting: Boolean, + onBoolean: (key: String, value: Boolean) -> Unit, + onString: (key: String, value: String) -> Unit, + onAction: (SettingsAction) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(section.title), onBack, modifier) { + when (section) { + SettingsSection.General -> { + PreferenceChoice( + title = stringResource(R.string.pref_app_theme_title), + values = persistentListOf(*stringArrayResource(R.array.app_theme_values)), + labels = persistentListOf(*stringArrayResource(R.array.app_theme_options)), + selected = state.theme, + onSelect = { onString(PreferenceKeys.APP_THEME, it) }, + ) + PreferenceChoice( + title = stringResource(R.string.pref_directory_structure), + values = persistentListOf(*stringArrayResource(R.array.directory_structure_values)), + labels = persistentListOf(*stringArrayResource(R.array.directory_structure_entries)), + selected = state.directoryStructure, + onSelect = { onString(PreferenceKeys.DIRECTORY_STRUCTURE, it) }, + ) + PreferenceChoice( + title = stringResource(R.string.pref_sort_order_title), + values = persistentListOf(*stringArrayResource(R.array.sort_order_values)), + labels = persistentListOf(*stringArrayResource(R.array.sort_order_entries)), + selected = state.sortOrder, + onSelect = { onString(PreferenceKeys.SORT_ORDER, it) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_disable_sync_on_pull_title), + checked = state.disableSync, + onCheckedChange = { onBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, it) }, + summary = stringResource(R.string.pref_disable_sync_on_pull_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_recursive_filter_title), + checked = state.filterRecursively, + onCheckedChange = { onBoolean(PreferenceKeys.FILTER_RECURSIVELY, it) }, + summary = stringResource(R.string.pref_recursive_filter_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_search_on_start_title), + checked = state.searchOnStart, + onCheckedChange = { onBoolean(PreferenceKeys.SEARCH_ON_START, it) }, + summary = stringResource(R.string.pref_search_on_start_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_show_hidden_title), + checked = state.showHidden, + onCheckedChange = { onBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, it) }, + summary = stringResource(R.string.pref_show_hidden_summary), + ) + // Enabled on Android 14 too (maintainer decision 4): upstream blocked it there for issue + // 2802; if that platform bug shows on the maintainer's Android 14 tablet, the block + // returns. + PreferenceSwitch( + title = stringResource(R.string.pref_biometric_auth_title), + checked = state.biometric, + onCheckedChange = { onBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, it) }, + summary = + stringResource( + if (canAuthenticate) R.string.pref_biometric_auth_summary + else R.string.pref_biometric_auth_summary_error + ), + ) + } + SettingsSection.Autofill -> { + PreferenceSwitch( + title = stringResource(R.string.pref_autofill_enable_title), + checked = autofillEnabled, + onCheckedChange = { onBoolean(PreferenceKeys.AUTOFILL_ENABLE, it) }, + ) + PreferenceText( + title = stringResource(R.string.preference_default_username_title), + value = state.defaultUsername, + onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, it) }, + summary = stringResource(R.string.preference_default_username_summary), + enabled = autofillEnabled, + ) + PreferenceText( + title = stringResource(R.string.preference_custom_public_suffixes_title), + value = state.customSuffixes, + onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, it) }, + summary = stringResource(R.string.preference_custom_public_suffixes_summary), + hint = stringResource(R.string.preference_custom_public_suffixes_hint), + singleLine = false, + enabled = autofillEnabled, + ) + } + SettingsSection.Passwords -> { + PreferenceChoice( + title = stringResource(R.string.pref_password_generator_type_title), + values = persistentListOf(*stringArrayResource(R.array.pwgen_provider_values)), + labels = persistentListOf(*stringArrayResource(R.array.pwgen_provider_labels)), + selected = state.pwgenType, + onSelect = { onString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, it) }, + ) + PreferenceText( + title = stringResource(R.string.pref_clipboard_timeout_title), + value = state.clipboardTimeout, + onValueChange = { onString(PreferenceKeys.GENERAL_SHOW_TIME, it) }, + summary = + stringResource( + R.string.pref_clipboard_timeout_summary, + state.clipboardTimeout.ifEmpty { "45" }, + ), + keyboardType = KeyboardType.Number, + ) + PreferenceSwitch( + title = stringResource(R.string.show_password_pref_title), + checked = state.showPassword, + onCheckedChange = { onBoolean(PreferenceKeys.SHOW_PASSWORD, it) }, + summary = stringResource(R.string.show_password_pref_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_copy_title), + checked = state.copyOnDecrypt, + onCheckedChange = { onBoolean(PreferenceKeys.COPY_ON_DECRYPT, it) }, + summary = stringResource(R.string.pref_copy_summary), + ) + } + SettingsSection.Repository -> { + PreferenceSwitch( + title = stringResource(R.string.pref_rebase_on_pull_title), + checked = state.rebaseOnPull, + onCheckedChange = { onBoolean(PreferenceKeys.REBASE_ON_PULL, it) }, + summary = + stringResource( + if (state.rebaseOnPull) R.string.pref_rebase_on_pull_summary_on + else R.string.pref_rebase_on_pull_summary + ), + ) + if (state.isGitRepo) { + PreferenceAction( + title = stringResource(R.string.pref_edit_git_server_settings), + onClick = { onAction(SettingsAction.GitServer) }, + ) + if (state.httpsRemote) + PreferenceAction( + title = stringResource(R.string.pref_edit_proxy_settings), + onClick = { onAction(SettingsAction.Proxy) }, + ) + PreferenceAction( + title = stringResource(R.string.pref_edit_git_config), + onClick = { onAction(SettingsAction.GitConfig) }, + ) + PreferenceAction( + title = stringResource(R.string.pref_import_ssh_key_title), + onClick = { onAction(SettingsAction.SshKey) }, + ) + } + PreferenceAction( + title = stringResource(R.string.pref_ssh_keygen_title), + onClick = { onAction(SettingsAction.SshKeyGen) }, + ) + if (state.isGitRepo && state.canShowSshKey) + PreferenceAction( + title = stringResource(R.string.pref_ssh_see_key_title), + onClick = { onAction(SettingsAction.SshKey) }, + ) + state.savedSecret?.let { title -> + PreferenceAction( + title = stringResource(title), + onClick = { onAction(SettingsAction.ClearSecret) }, + ) + } + PreferenceAction( + title = stringResource(R.string.pref_git_delete_repo_title), + onClick = { onAction(SettingsAction.DeleteRepository) }, + summary = stringResource(R.string.pref_git_delete_repo_summary), + enabled = !deleting, + ) + } + SettingsSection.Misc -> { + PreferenceAction( + title = stringResource(R.string.prefs_export_passwords_title), + onClick = { onAction(SettingsAction.Export) }, + summary = stringResource(R.string.prefs_export_passwords_summary), + ) + PreferenceSwitch( + title = stringResource(R.string.pref_clear_clipboard_title), + checked = state.clearClipboardHistory, + onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, it) }, + summary = stringResource(R.string.pref_clear_clipboard_summary), + ) + if (!BuildConfig.DEBUG) + PreferenceSwitch( + title = stringResource(R.string.pref_debug_logging_title), + checked = state.debugLogging, + onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) }, + summary = stringResource(R.string.pref_debug_logging_summary), + ) + } + SettingsSection.Pgp -> { + PreferenceAction( + title = stringResource(R.string.pref_pgp_key_manager_title), + onClick = { onAction(SettingsAction.KeyManager) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_pgp_ascii_armor_title), + checked = state.asciiArmor, + onCheckedChange = { onBoolean(PreferenceKeys.ASCII_ARMOR, it) }, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_passphrase_cache_title), + checked = state.passphraseCache, + onCheckedChange = { onBoolean(Feature.EnablePGPPassphraseCache.configKey, it) }, + summary = stringResource(R.string.pref_passphrase_cache_summary), + // Without a screen lock it can still be turned off. + enabled = canAuthenticate || state.passphraseCache, + ) + PreferenceSwitch( + title = stringResource(R.string.pref_passphrase_cache_auto_clear_title), + checked = state.autoClearCache, + onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, it) }, + summary = stringResource(R.string.pref_passphrase_cache_auto_clear_summary), + enabled = state.passphraseCache, + ) + } + } + } +} diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9b67482c..b9cf71fc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -375,4 +375,9 @@ Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. Il file supera 1 MiB: non è una chiave PGP. Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. + + + Impossibile eliminare tutto il contenuto della cartella dell\'archivio. + Verrà chiesto di nuovo alla prossima operazione Git. + Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc9140f8..0224a72c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,4 +410,9 @@ Could not empty the store folder, so the clone did not start. The file is larger than 1 MiB: not a PGP key. 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. + + + Could not delete everything in the store folder. + It will be asked again at the next Git operation. + Delete the local store? Entries not pushed to the remote are lost. -- cgit v1.2.3 From 9b4ba97d02a27fac4452f75427cbb79d311c28f4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:40:32 +0100 Subject: feat(app): add the Compose proxy screen Host, port, user name and password. An invalid host or port blocks the save, an emptied port is cleared, the proxy applies at once, and the screen is FLAG_SECURE while the saved password is on it. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/settings/ProxyScreen.kt | 197 +++++++++++++++++++++ .../passwordstore/ui/settings/ProxyViewModel.kt | 60 +++++++ app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 259 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt (limited to 'app/src/main/res/values-it') 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. + * 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..ccd275a6 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.net.InetAddresses +import android.os.Build +import android.util.Patterns +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import app.passwordstore.util.proxy.ProxyUtils +import app.passwordstore.util.settings.GitSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** A numeric address or a web host name, as the old proxy screen accepted. */ +fun isProxyHost(text: String): Boolean { + val numeric = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text) + else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() + return numeric || Patterns.WEB_URL.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()) + + val portError: Boolean + get() = proxyPort(port) == null + + /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */ + fun save(): Boolean { + if (hostError) 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/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b9cf71fc..50566188 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -379,5 +379,6 @@ Impossibile eliminare tutto il contenuto della cartella dell\'archivio. Verrà chiesto di nuovo alla prossima operazione Git. + Inserisci una porta da 1 a 65535, oppure niente Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0224a72c..02f02e18 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,5 +414,6 @@ Could not delete everything in the store folder. It will be asked again at the next Git operation. + Enter a port from 1 to 65535, or nothing Delete the local store? Entries not pushed to the remote are lost. -- cgit v1.2.3 From d6b0c910e065fd51481eea1ab5819d7c8f57438e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:42:00 +0100 Subject: feat(app): add the Compose git config and commit log screens Author name and email, HEAD status, commit log, abort rebase, hard reset and gc. The hard reset now warns and prefills the current branch; a finished reset or gc stays on the screen with a snackbar, and a failed operation keeps the screen too. The commit log is read off the main thread. BreakOutOfDetached's not-rebasing dialog no longer finishes the calling activity. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/main/MainActivity.kt | 7 + .../passwordstore/ui/settings/GitConfigScreen.kt | 328 +++++++++++++++++++++ .../ui/settings/GitConfigViewModel.kt | 82 ++++++ .../app/passwordstore/ui/settings/GitLogScreen.kt | 118 ++++++++ .../util/git/operation/BreakOutOfDetached.kt | 5 +- app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 541 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt create mode 100644 app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index c8b1861a..5cc2da86 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -40,6 +40,7 @@ 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 @@ -228,6 +229,12 @@ class MainActivity : BaseGitActivity() { 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 { + 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) 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..d8001191 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.compose.theme.SpacingLarge +import app.passwordstore.ui.git.base.BaseGitActivity.GitOp +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupDialog +import app.passwordstore.util.extensions.snackbar +import com.github.michaelbull.result.fold +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch + +/** + * Author name and email with Save, then the repository utilities: HEAD ([head], already a + * sentence), commit log, abort rebase ([canAbort]), hard reset, gc. [busy] disables everything. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitConfigScreen( + name: String, + email: String, + head: String, + canAbort: Boolean, + busy: Boolean, + onNameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onSave: () -> Unit, + onLog: () -> Unit, + onAbort: () -> Unit, + onReset: () -> Unit, + onGc: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_activity_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(null) } + var branch by rememberSaveable { mutableStateOf("") } + + LifecycleResumeEffect(Unit) { + viewModel.refresh() + onPauseOrDispose {} + } + // A running operation holds the screen: leaving would cancel it half way. + BackHandler(enabled = viewModel.busy) {} + // A finished reset or gc is announced once, also when the screen was recreated in between: + // the result stays in the ViewModel until the snackbar was shown. + val done = viewModel.done + LaunchedEffect(done) { + val label = + when (done) { + GitOp.RESET -> R.string.reset_to_remote + GitOp.GC -> R.string.git_run_gc_job + else -> return@LaunchedEffect + } + activity.snackbar( + message = activity.getString(R.string.git_tool_done, activity.getString(label)), + length = Snackbar.LENGTH_LONG, + ) + viewModel.done = null + } + + // ponytail: the activity's scope, like every git operation of the app: a theme or locale change + // cancels it, and the finally block frees the screen. + fun run(op: GitOp, remote: String = "") { + if (viewModel.busy) return + viewModel.busy = true + activity.lifecycleScope.launch { + try { + activity + .gitOperation(op, remote) + .fold( + success = { + if (op == GitOp.BREAK_OUT_OF_DETACHED) + viewModel.abortedBranch = PasswordRepository.getCurrentBranch() + viewModel.done = op + }, + failure = { activity.promptOnErrorHandler(it) }, + ) + } finally { + viewModel.busy = false + viewModel.refresh() + } + } + } + + val head = + when (val status = viewModel.head) { + is HeadStatus.Branch -> stringResource(R.string.git_head_on_branch, status.name) + is HeadStatus.Detached -> stringResource(R.string.git_head_detached, status.hash) + HeadStatus.Missing -> stringResource(R.string.git_head_missing) + null -> "" + } + Box(modifier) { + GitConfigScreen( + name = viewModel.name, + email = viewModel.email, + head = head, + canAbort = viewModel.canAbort, + busy = viewModel.busy, + onNameChange = { viewModel.name = it }, + onEmailChange = { viewModel.email = it }, + onSave = { + if (viewModel.save()) { + Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT) + .show() + onClose() + } else dialog = GitConfigDialog.InvalidEmail + }, + onLog = onLog, + onAbort = { + // Checked again: BreakOutOfDetached reports success without doing anything otherwise. + if (PasswordRepository.repository?.let(::needsAbort) == true) + run(GitOp.BREAK_OUT_OF_DETACHED) + else viewModel.refresh() + }, + onReset = { + branch = PasswordRepository.getCurrentBranch().orEmpty() + dialog = GitConfigDialog.Reset + }, + onGc = { run(GitOp.GC) }, + onBack = { if (!viewModel.busy) onClose() }, + ) + when (dialog) { + null -> Unit + GitConfigDialog.InvalidEmail -> + SetupDialog( + text = stringResource(R.string.invalid_email_dialog_text), + confirm = stringResource(R.string.dialog_ok), + onConfirm = { dialog = null }, + onDismiss = { dialog = null }, + ) + GitConfigDialog.Reset -> + ResetDialog( + branch = branch, + onBranchChange = { branch = it }, + onConfirm = { + dialog = null + run(GitOp.RESET, branch.trim()) + }, + onDismiss = { dialog = null }, + ) + } + if (done == GitOp.BREAK_OUT_OF_DETACHED) { + val aborted = viewModel.abortedBranch.orEmpty() + SetupDialog( + title = stringResource(R.string.git_abort_and_push_title), + text = + stringResource( + R.string.git_break_out_of_detached_success, + aborted, + "conflicting-$aborted-...", + ), + 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. + * 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(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(null) + + /** The branch an abort pushed from, for its report. */ + var abortedBranch by mutableStateOf(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. + * 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?>(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?, + 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/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt index d55d9970..4b4913d0 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt @@ -57,9 +57,8 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call .setMessage( callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) ) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - } + // Only closes the dialog: the calling activity is the app's main screen. + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null) .show() false } else { diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 50566188..28a38301 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -380,5 +380,7 @@ Impossibile eliminare tutto il contenuto della cartella dell\'archivio. Verrà chiesto di nuovo alla prossima operazione Git. Inserisci una porta da 1 a 65535, oppure niente + I commit e le modifiche locali che non sono su questo remote branch vengono scartati. + %1$s: completato Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02f02e18..403ee2ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -415,5 +415,7 @@ Could not delete everything in the store folder. It will be asked again at the next Git operation. Enter a port from 1 to 65535, or nothing + Local commits and changes that are not on this remote branch are discarded. + %1$s: done Delete the local store? Entries not pushed to the remote are lost. -- cgit v1.2.3 From cebdf8dd4720388d56d9af537fe81fd927e51779 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:50:51 +0100 Subject: refactor(app): remove the legacy settings screens The Compose settings, git config, commit log and proxy screens replace SettingsActivity with its six preference providers, GitConfigActivity with TextInputDialog, GitLogActivity with its adapter and GitLogModel, and ProxySelectorActivity, with their layouts and manifest entries. The dead OpenKeystore key ID row goes with its string, the Android 14 platform bug summary and normal_margin lose their last users, and the setup intent loses the two starts only Settings used. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 23 --- .../passwordstore/ui/dialogs/TextInputDialog.kt | 71 -------- .../ui/git/config/GitConfigActivity.kt | 176 ------------------- .../app/passwordstore/ui/git/log/GitLogActivity.kt | 49 ------ .../app/passwordstore/ui/git/log/GitLogAdapter.kt | 65 ------- .../java/app/passwordstore/ui/main/MainActivity.kt | 8 +- .../ui/proxy/ProxySelectorActivity.kt | 109 ------------ .../passwordstore/ui/settings/AutofillSettings.kt | 114 ------------ .../passwordstore/ui/settings/GeneralSettings.kt | 120 ------------- .../app/passwordstore/ui/settings/MiscSettings.kt | 76 -------- .../app/passwordstore/ui/settings/PGPSettings.kt | 136 --------------- .../passwordstore/ui/settings/PasswordSettings.kt | 48 ----- .../ui/settings/RepositorySettings.kt | 193 --------------------- .../passwordstore/ui/settings/SettingsActivity.kt | 129 -------------- .../passwordstore/ui/settings/SettingsProvider.kt | 15 -- .../java/app/passwordstore/ui/setup/SetupGraph.kt | 2 - .../java/app/passwordstore/util/git/GitLogModel.kt | 60 ------- app/src/main/res/layout/activity_git_config.xml | 113 ------------ app/src/main/res/layout/activity_git_log.xml | 22 --- .../layout/activity_preference_recyclerview.xml | 18 -- .../main/res/layout/activity_proxy_selector.xml | 106 ----------- app/src/main/res/layout/dialog_text_input.xml | 25 --- app/src/main/res/layout/git_log_row_layout.xml | 45 ----- .../main/res/layout/oreo_autofill_instructions.xml | 51 ------ app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-gl/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ko-rKR/strings.xml | 1 - app/src/main/res/values-pl-rPL/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/strings.xml | 2 - .../specs/2026-11-02-compose-ui-overhaul-design.md | 21 ++- 36 files changed, 22 insertions(+), 1786 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt delete mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt delete mode 100644 app/src/main/java/app/passwordstore/util/git/GitLogModel.kt delete mode 100644 app/src/main/res/layout/activity_git_config.xml delete mode 100644 app/src/main/res/layout/activity_git_log.xml delete mode 100644 app/src/main/res/layout/activity_preference_recyclerview.xml delete mode 100644 app/src/main/res/layout/activity_proxy_selector.xml delete mode 100644 app/src/main/res/layout/dialog_text_input.xml delete mode 100644 app/src/main/res/layout/git_log_row_layout.xml delete mode 100644 app/src/main/res/layout/oreo_autofill_instructions.xml (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 546bdde4..677321c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,12 +33,6 @@ tools:ignore="GoogleAppIndexingWarning" tools:targetApi="s"> - - - - - - - - - * 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/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/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() { - - 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/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index 4d618cca..75062f6b 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -258,13 +258,13 @@ class MainActivity : BaseGitActivity() { private const val EXTRA_SETUP = "SETUP" /** - * Opens one setup screen in an instance of its own, for callers outside the graph (legacy - * Settings, autofill, git operations) and for those waiting for a result (the entry view's + * 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, until steps 6 and 7 move - // Settings and autofill into the graph. + // ponytail: a second instance instead of in-graph navigation, until step 7 moves autofill + // into the graph and the git operations' dialogs become Compose dialogs. fun setupIntent(context: Context, start: SetupStart): Intent = Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name) } diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt deleted file mode 100644 index cb5fbf40..00000000 --- a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.proxy - -import android.content.SharedPreferences -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.postDelayed -import androidx.core.widget.doOnTextChanged -import app.passwordstore.R -import app.passwordstore.databinding.ActivityProxySelectorBinding -import app.passwordstore.injection.prefs.ProxyPreferences -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.viewBinding -import app.passwordstore.util.proxy.ProxyUtils -import app.passwordstore.util.settings.GitSettings -import app.passwordstore.util.settings.PreferenceKeys -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class ProxySelectorActivity : AppCompatActivity() { - - @Inject lateinit var gitSettings: GitSettings - @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences - @Inject lateinit var proxyUtils: ProxyUtils - private val binding by viewBinding(ActivityProxySelectorBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) - proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) - proxyPrefs - .getInt(PreferenceKeys.PROXY_PORT, -1) - .takeIf { it != -1 } - ?.let { proxyPort.setText("$it") } - proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) - save.setOnClickListener { saveSettings() } - proxyHost.doOnTextChanged { text, _, _, _ -> - if (text != null) { - proxyHost.error = - if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) { - null - } else { - getString(R.string.invalid_proxy_url) - } - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun isNumericAddress(text: CharSequence): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - InetAddresses.isNumericAddress(text as String) - } else { - @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches() - } - } - - private fun saveSettings() { - proxyPrefs.edit { - binding.proxyHost.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyHost = it } - binding.proxyUser.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyUsername = it } - binding.proxyPort.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - ?.let { gitSettings.proxyPort = it.toInt() } - binding.proxyPassword.text - ?.toString() - ?.takeIf { it.isNotEmpty() } - .let { gitSettings.proxyPassword = it } - } - proxyUtils.setDefaultProxy() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - - private companion object { - private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() - } -} diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt deleted file mode 100644 index afb2a691..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.settings - -import android.annotation.SuppressLint -import android.content.Intent -import android.provider.Settings -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.net.toUri -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import app.passwordstore.BuildConfig -import app.passwordstore.R -import app.passwordstore.util.extensions.autofillManager -import app.passwordstore.util.settings.PreferenceKeys -import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel -import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import de.Maxr1998.modernpreferences.PreferenceScreen -import de.Maxr1998.modernpreferences.helpers.editText -import de.Maxr1998.modernpreferences.helpers.onClick -import de.Maxr1998.modernpreferences.helpers.switch -import de.Maxr1998.modernpreferences.preferences.SwitchPreference - -class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { - - private val isAutofillServiceEnabled: Boolean - get() { - return activity.autofillManager?.hasEnabledAutofillServices() == true - } - - private fun showAutofillDialog(pref: SwitchPreference) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - pref.checked = isAutofillServiceEnabled - } - else -> {} - } - } - MaterialAlertDialogBuilder(activity).run { - setTitle(R.string.pref_autofill_enable_title) - @SuppressLint("InflateParams") - val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) - val supportedBrowsersTextView = layout.findViewById(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()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - false - } - } - } - } -} 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 46e62faa..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.main.MainActivity -import app.passwordstore.ui.setup.SetupStart -import app.passwordstore.util.auth.BiometricAuthenticator -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.startActivity(MainActivity.setupIntent(activity, SetupStart.PgpKeys)) - 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/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt deleted file mode 100644 index 23dd2829..00000000 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ /dev/null @@ -1,193 +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.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.main.MainActivity -import app.passwordstore.ui.proxy.ProxySelectorActivity -import app.passwordstore.ui.setup.SetupStart -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.startActivity(MainActivity.setupIntent(activity, SetupStart.GitServer)) - 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 { - generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.Ssh)) - true - } - } - pref(PreferenceKeys.SSH_KEYGEN) { - titleRes = R.string.pref_ssh_keygen_title - onClick { - generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.SshGenerate)) - true - } - } - showSshKeyPref = - pref(PreferenceKeys.SSH_SEE_KEY) { - titleRes = R.string.pref_ssh_see_key_title - visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey - onClick { - generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.Ssh)) - 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()?.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/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/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt index c2c2a2ac..40dfae74 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt @@ -33,10 +33,8 @@ import kotlinx.serialization.Serializable /** The setup screens [MainActivity.setupIntent] opens on their own. */ enum class SetupStart(val route: Any) { - GitServer(GitServerRoute(clone = false)), Ssh(SshKeyRoute), SshGenerate(SshKeyGenRoute), - PgpKeys(PgpKeysRoute()), PgpImport(PgpKeysRoute(KeysMode.Need)), } 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 { - 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 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/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - 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 @@ - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - 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 @@ - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 618aac0d..08b74717 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -215,7 +215,6 @@ Biometrische Authentifizierung Falls aktiviert, werden Sie beim Starten der App nach Ihrem Fingerabdruck gefragt. Fingerabdrucksensor fehlt oder ist nicht ansprechbar. - Lösche gespeicherte OpenKeystore SSH-Schlüssel-ID. Ihr öffentlicher Schlüssel Fehler beim Erstellen des SSH-Schlüssels Alle Dateien und Ordner anzeigen diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4cc7d29d..796884d7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -203,7 +203,6 @@ Activer l\'authentification biométrique Lorsque cette option est activée, Password Store vous demandera votre empreinte digitale au lancement Lecteur d\'empreinte digitale non accessible ou manquant - Effacer la clé d\'identification SSH d\'OpenKeystore mémorisée Votre clé publique Une erreur est survenue pendant la génération de la clé ssh Afficher tous les fichiers et dossiers diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 34b1ce6a..b79e2990 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -217,7 +217,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid Activar autenticación biométrica Ao activala, Password Store vaiche pedir a túa pegada dactilar ao iniciar a app O hardware de pegada dixital non é accesible ou existente - Eliminar o ID lembrado da chave SSH en OpenKeystore A túa chave pública Algo fallou ao intentar crear a chave-ssh Mostar cartafoles ocultos diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 28a38301..ee7affd2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -198,7 +198,6 @@ Abilita autenticazione biometrica Se attiva, HardenedPass chiede l\'impronta digitale all\'avvio dell\'app L\'hardware delle impronte digitali non è accessibile o mancante - Elimina l\'ID della Chiave SSH di OpenKeystore memorizzato La tua chiave pubblica Errore durante la generazione della chiave SSH Mostra tutti i file e cartelle @@ -303,7 +302,6 @@ Autenticati per disattivare la cancellazione automatica Cancella automaticamente le frasi segrete memorizzate Cancella le frasi segrete memorizzate allo spegnimento dello schermo - Funzione non disponibile a causa di un bug della piattaforma Impossibile cifrare la password, non è stato salvato nulla: %1$s Nessuna immagine selezionata Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 06e0a555..b2836176 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -211,7 +211,6 @@ 생체 인증 사용 비밀번호 저장소를 활성화하면 앱을 시작할 때 지문을 입력하라는 메시지가 표시됩니다 지문 하드웨어에 액세스할 수 없거나 누락된 경우 - 저장된 OpenKeyStore SSH 키 ID 지우기 공개 키 Ssh 키를 생성하는 동안 오류가 발생했습니다. 모든 파일 및 폴더 표시 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 6bdb966c..34272cae 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -210,7 +210,6 @@ Włącz uwierzytelnianie biometryczne Gdy włączone, Password Store zapyta Cię o odcisk palca podczas uruchamiania aplikacji Czytnik linii papilarnych jest niedostępny - Wyczyść zapamiętany identyfikator klucza SSH OpenKeystore Twój klucz publiczny Błąd podczas generowania klucza SSH Pokaż ukryte pliki i foldery diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8f40c831..9f60b64e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -215,7 +215,6 @@ Ativar autenticação biométrica Quando ativado, o Password Store irá pedir a sua impressão digital ao iniciar o aplicativo Hardware de impressão digital não acessível ou ausente - Limpar ID de chave SSH lembrada do OpenKeystore Sua chave pública Erro ao tentar gerar a chave SSH Mostrar todos os arquivos e pastas diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index af3078bc..298d9508 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -208,7 +208,6 @@ Включить биометрическую аутентификацию Когда ключено, Password Store будет запрашивать ваш опечаток пальца при каждом запуске приложения Сенсор отпечатка пальца не доступен или отсутствует - Очистить сохраненный SSH Key идентификатор OpenKystortore Ваш публичный ключ Возникла ошибка при попытке генерации ssh ключа Показать все файлы и директории diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 959d5fac..85125fc2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -216,7 +216,6 @@ 启用生物识别认证 如果启用,Password Store 将在启动应用时提示您使用指纹进行身份验证 指纹硬件无法访问或丢失 - 清除记住的 OpenKeystore SSH 密钥ID 你的公开密钥 尝试生成SSH密钥时出错 显示所有文件和目录 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 8351a358..44d4f581 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -7,5 +7,4 @@ 16dp 16dp - 8dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 403ee2ae..653cf72c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -247,8 +247,6 @@ Enable biometric authentication When enabled, HardenedPass will prompt you for your fingerprint when launching the app Fingerprint hardware not accessible or missing - Feature unavailable due to a platform bug - Clear remembered OpenKeystore SSH Key ID Your public key Error while trying to generate the ssh-key Show all files and folders diff --git a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md index 94f76479..b3d0f0cd 100644 --- a/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md +++ b/docs/superpowers/specs/2026-11-02-compose-ui-overhaul-design.md @@ -65,8 +65,22 @@ the maintainer's phone stays on 0.1.4 until then. repository is created only then, its `.gpg-id` naming the key by its uppercase long key ID, as `pass init ` does), and imports the key a store needs: after a clone that no imported key can decrypt, and when an entry is opened with no key imported. -- Screens outside the graph (Settings, autofill, git operations) and the entry view's key - import open these through a `MainActivity` setup intent until steps 6 and 7. +- Settings opens these within the graph. Autofill, the git operations' "No SSH key" dialog and + the entry view's key import open them through a `MainActivity` setup intent until step 7. + +### Settings + +- Settings lists its categories (general, autofill, passwords, repository, misc, PGP); each opens + a screen of rows (switches, single choices, text values, actions) over the existing preference + keys. Switches behind the screen lock store their value only after a successful prompt. +- Rows that lead elsewhere navigate within `MainActivity`: git server, SSH key, key generation, + PGP keys, proxy, and the git configuration screen (author name and email, commit log, abort + rebase, hard reset, garbage collection). +- Destructive actions ask first: deleting the repository (which never follows symlinks out of the + store and returns to the welcome screen), clearing a saved password or passphrase, the hard + reset. +- The list's sort orders are folders first and recently used; "files first" and "type + independent" are gone, and stored values fall back to folders first. ## Architecture @@ -102,7 +116,8 @@ the maintainer's phone stays on 0.1.4 until then. Crypto, git, autofill parsing, preference keys, the Keystore-backed secret storage, clipboard handling and the passphrase cache. Existing stores, keys and settings carry over without -migration. +migration, with two exceptions from step 6: a stored "files first" or "type independent" sort +order (both removed) becomes folders first, and the unused OpenKeychain SSH key ID is dropped. ### Removed at the end -- cgit v1.2.3 From 136ee43d6080da288afae8ec133727962ea60640 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 18:45:38 +0100 Subject: fix(app): point the rebase conflict error at the current settings path The detached HEAD error sent users to "Settings > Git utils", a screen the Compose settings no longer have. Every locale now names Settings > Repository > Local Git config & utilities, and the English quotes are escaped so they show. The git configuration screen's title reuses that translated row label instead of an untranslatable English string. Co-Authored-By: Claude Opus 5.5 --- app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-gl/strings.xml | 2 +- app/src/main/res/values-it/strings.xml | 2 +- app/src/main/res/values-ko-rKR/strings.xml | 2 +- app/src/main/res/values-pl-rPL/strings.xml | 2 +- app/src/main/res/values-pt-rBR/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values/strings.xml | 3 +-- 10 files changed, 10 insertions(+), 11 deletions(-) (limited to 'app/src/main/res/values-it') diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt index 872fcd8b..d008eea3 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -83,7 +83,7 @@ fun GitConfigScreen( modifier = modifier, topBar = { TopAppBar( - title = { Text(stringResource(R.string.title_activity_git_config)) }, + title = { Text(stringResource(R.string.pref_edit_git_config)) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back)) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index b0c086cd..a0a20dc7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -269,7 +269,7 @@ Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen Unbekannter Fehler - 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. + 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. Merge fehlgeschlagen, Sie befinden sich in einem Konflikt. 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 Push wurde von Remote abgelehnt, Grund: %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a1fac4f4..d4d4d424 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -254,7 +254,7 @@ Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent Erreur inconnue - 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. + 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. La fusion a échoué, vous êtes dans un état de conflit. 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. L\'envoi a été rejeté par le dépôt distant, raison : %1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 11af77b1..f966230c 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -271,7 +271,7 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid O nome do ficheiro non pode conter \'/\', establece un directorio superior Fallo descoñecido - 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. + 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. Fallou o merge, hai un conflito nos datos. TAREFA: engadir método de recuperación. 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. O push foi rexeitado polo remoto, razón: %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ee7affd2..e7c05909 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -249,7 +249,7 @@ Il nome del file non deve contenere \'/\', imposta la directory qui sopra Errore sconosciuto - 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. + 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. Push rifiutato dal remote: esegui un pull prima di riprovare. Puoi usare Sincronizza, che esegue sia il pull sia il push Push rifiutato dal remote, motivo: %1$s Il remote ha rifiutato un push non fast-forward. Controlla la variabile receive.denyNonFastForwards nella configurazione del repository di destinazione. diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 01cf77a1..add3de87 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -267,7 +267,7 @@ username@example.com:… 파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요 알 수 없는 오류 - 풀이 실패하여 head가 분리된 상태입니다. \"설정 > git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요. + 풀이 실패하여 head가 분리된 상태입니다. \"설정 > 저장소 > 로컬 Git 설정 & 툴\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요. 병합에 실패하여 충돌 상태 입니다. 할 일: 복구 방법을 추가하세요. 원격에서 푸시가 거부된 경우 다시 푸시하기 전에 풀을 실행합니다. 풀/푸시 대신 동기화를 사용하면 두 가지를 모두 구현할 수 있습니다 원격에서 푸시를 거부했습니다. 원인: %1$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 32aa96d1..4d41b2f6 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -261,7 +261,7 @@ Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej Nieznany błąd - 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. + 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. Scalanie nie powiodło się, jesteś w stanie konfliktu. 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 Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium, powód: %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e4d975c4..14e862c5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -269,7 +269,7 @@ Nome do arquivo não deve conter \'/\', defina o diretório acima Erro desconhecido - 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. + 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. Falha ao realizar o merge, você está em estado de conflito. 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 Push rejeitado pelo remoto, razão: %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1c973cfb..ddc33aa3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -261,7 +261,7 @@ Имя файла не должно содержать \'/\', укажите директорию выше Неизвестная ошибка - Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки > утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере. + Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"Настройки > Репозиторий > Конфигурация локального Git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере. Слияние не удалось, вы находитесь в конфликтующем состоянии. ВТО: Добавьте метод восстановления. Запись изменений была отклонена удаленным репозиторием, сначала пполучите изменения перед повторной записью. Вы можете использовать Синхронизацию вместо получения/записи изменений, т.к. она реализует оба подхда. Запись изменений была отклонена удаленным репозиторием, причина: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a84f3358..f1a858fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,7 +19,6 @@ Delete directory Cancel Repository information - Git configuration Commit log @@ -304,7 +303,7 @@ Unknown 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. + 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. Merge has failed, you\'re in a conflicting state. Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both -- cgit v1.2.3