aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
committerDanilo M. <danix@danix.xyz>2026-11-04 18:51:35 +0100
commitba2a60a14717fd7c82cd2c4b1778673d1bb6388f (patch)
tree45fc389cbafedcfef0f88ae8b7e789af39690c84 /app/src
parent0ad0b1ca6a7f7fa814a92dd6eaf9da88948ee377 (diff)
parent136ee43d6080da288afae8ec133727962ea60640 (diff)
downloadHardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.tar.gz
HardenedPass-ba2a60a14717fd7c82cd2c4b1778673d1bb6388f.zip
Merge branch 'compose': Compose UI overhaul
Every screen moves from XML layouts, Views and Fragments to Jetpack Compose with Material 3, in one MainActivity with a NavHost: the list with breadcrumbs, search, pull to sync and multi-select; the entry view with OTP countdown; the editor with generator sheet and folder picker; setup (welcome, git server and clone, SSH key, PGP keys); settings, git config, commit log and proxy; and the autofill picker, publisher warning and decryption prompts. LaunchActivity stays the only exported activity. Fixes found on the way reach master with it, among them symlink-safe deletes, the proxy user name, screen lock gated switches, the Android 14 app lock and screen lock prompts, and the autofill passphrase cache rule. The View-era dependencies and view binding are gone; Material Components (Views) stays for the git dialogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/AndroidManifest.xml88
-rw-r--r--app/src/main/java/app/passwordstore/data/password/FieldItem.kt51
-rw-r--r--app/src/main/java/app/passwordstore/data/password/PasswordItem.kt34
-rw-r--r--app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt4
-rw-r--r--app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt3
-rw-r--r--app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt101
-rw-r--r--app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt99
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt221
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt149
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt184
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt47
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt115
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt124
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt16
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt125
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt238
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt149
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt298
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt105
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt510
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt105
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt132
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt178
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt90
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt47
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt148
-rw-r--r--app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt71
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt268
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt150
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt314
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt273
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt246
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt35
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt183
-rw-r--r--app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt220
-rw-r--r--app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt67
-rw-r--r--app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt98
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt176
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt304
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt49
-rw-r--r--app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt65
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt130
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt368
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListModel.kt71
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListScreen.kt340
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/StoreActions.kt179
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt42
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt283
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt27
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt78
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt77
-rw-r--r--app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt33
-rw-r--r--app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt388
-rw-r--r--app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt621
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt109
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt169
-rw-r--r--app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt94
-rw-r--r--app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt109
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt114
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt120
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt329
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt82
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt118
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt76
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt136
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt48
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt217
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt197
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt62
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt196
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt129
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt304
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt87
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt79
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt15
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt349
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt226
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt345
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt130
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt337
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt255
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt99
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt142
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt379
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt137
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt103
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt39
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt173
-rw-r--r--app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt66
-rw-r--r--app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt71
-rw-r--r--app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt4
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt38
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt40
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt70
-rw-r--r--app/src/main/java/app/passwordstore/util/git/GitLogModel.kt60
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt5
-rw-r--r--app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt40
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt83
-rw-r--r--app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt11
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/GitSettings.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/Migrations.kt19
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt17
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt48
-rw-r--r--app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt189
-rw-r--r--app/src/main/res/anim/scale_down.xml16
-rw-r--r--app/src/main/res/anim/scale_up.xml16
-rw-r--r--app/src/main/res/animator/slide_in_left.xml15
-rw-r--r--app/src/main/res/animator/slide_in_right.xml15
-rw-r--r--app/src/main/res/animator/slide_out_left.xml15
-rw-r--r--app/src/main/res/animator/slide_out_right.xml15
-rw-r--r--app/src/main/res/drawable/divider.xml10
-rw-r--r--app/src/main/res/drawable/ic_add_48dp.xml15
-rw-r--r--app/src/main/res/drawable/ic_arrow_back_black_24dp.xml15
-rw-r--r--app/src/main/res/drawable/ic_content_copy.xml15
-rw-r--r--app/src/main/res/drawable/ic_done_24dp.xml15
-rw-r--r--app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml15
-rw-r--r--app/src/main/res/drawable/ic_save_24dp.xml15
-rw-r--r--app/src/main/res/drawable/ic_save_copy_24dp.xml15
-rw-r--r--app/src/main/res/drawable/password_row_background.xml20
-rw-r--r--app/src/main/res/layout/activity_git_clone.xml94
-rw-r--r--app/src/main/res/layout/activity_git_config.xml113
-rw-r--r--app/src/main/res/layout/activity_git_log.xml22
-rw-r--r--app/src/main/res/layout/activity_onboarding.xml11
-rw-r--r--app/src/main/res/layout/activity_oreo_autofill_filter.xml132
-rw-r--r--app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml174
-rw-r--r--app/src/main/res/layout/activity_preference_recyclerview.xml18
-rw-r--r--app/src/main/res/layout/activity_proxy_selector.xml106
-rw-r--r--app/src/main/res/layout/activity_pwdstore.xml19
-rw-r--r--app/src/main/res/layout/activity_ssh_keygen.xml69
-rw-r--r--app/src/main/res/layout/basic_bottom_sheet.xml62
-rw-r--r--app/src/main/res/layout/decrypt_layout.xml65
-rw-r--r--app/src/main/res/layout/dialog_password_entry.xml35
-rw-r--r--app/src/main/res/layout/dialog_text_input.xml25
-rw-r--r--app/src/main/res/layout/folder_dialog_fragment.xml38
-rw-r--r--app/src/main/res/layout/fragment_clone.xml96
-rw-r--r--app/src/main/res/layout/fragment_key_selection.xml80
-rw-r--r--app/src/main/res/layout/fragment_manual_otp_entry.xml53
-rw-r--r--app/src/main/res/layout/fragment_pwgen.xml115
-rw-r--r--app/src/main/res/layout/fragment_pwgen_diceware.xml73
-rw-r--r--app/src/main/res/layout/fragment_welcome.xml67
-rw-r--r--app/src/main/res/layout/git_credential_layout.xml15
-rw-r--r--app/src/main/res/layout/git_log_row_layout.xml45
-rw-r--r--app/src/main/res/layout/item_create_sheet.xml42
-rw-r--r--app/src/main/res/layout/item_field.xml30
-rw-r--r--app/src/main/res/layout/map_preference_widget_switch.xml13
-rw-r--r--app/src/main/res/layout/oreo_autofill_filter_row.xml69
-rw-r--r--app/src/main/res/layout/oreo_autofill_instructions.xml51
-rw-r--r--app/src/main/res/layout/password_creation_activity.xml147
-rw-r--r--app/src/main/res/layout/password_recycler_view.xml39
-rw-r--r--app/src/main/res/layout/password_row_layout.xml50
-rw-r--r--app/src/main/res/layout/select_folder_layout.xml16
-rw-r--r--app/src/main/res/menu/context_pass.xml34
-rw-r--r--app/src/main/res/menu/main_menu_git.xml36
-rw-r--r--app/src/main/res/menu/main_menu_no_auth.xml28
-rw-r--r--app/src/main/res/menu/main_menu_non_git.xml24
-rw-r--r--app/src/main/res/menu/pgp_handler.xml28
-rw-r--r--app/src/main/res/menu/pgp_handler_new_password.xml20
-rw-r--r--app/src/main/res/menu/pgp_handler_select_folder.xml15
-rw-r--r--app/src/main/res/values-de/strings.xml31
-rw-r--r--app/src/main/res/values-fr/strings.xml28
-rw-r--r--app/src/main/res/values-gl/strings.xml31
-rw-r--r--app/src/main/res/values-hdpi/dimens.xml10
-rw-r--r--app/src/main/res/values-it/strings.xml76
-rw-r--r--app/src/main/res/values-ko-rKR/strings.xml25
-rw-r--r--app/src/main/res/values-night/colors_material3.xml1
-rw-r--r--app/src/main/res/values-pl-rPL/strings.xml28
-rw-r--r--app/src/main/res/values-pt-rBR/strings.xml31
-rw-r--r--app/src/main/res/values-ru/strings.xml27
-rw-r--r--app/src/main/res/values-zh-rCN/strings.xml25
-rw-r--r--app/src/main/res/values/arrays.xml4
-rw-r--r--app/src/main/res/values/attrs.xml10
-rw-r--r--app/src/main/res/values/colors_material3.xml1
-rw-r--r--app/src/main/res/values/dimens.xml16
-rw-r--r--app/src/main/res/values/strings.xml80
-rw-r--r--app/src/main/res/values/styles_material3.xml12
-rw-r--r--app/src/main/res/values/themes_material3.xml14
-rw-r--r--app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt91
-rw-r--r--app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt27
-rw-r--r--app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt156
-rw-r--r--app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt57
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt105
-rw-r--r--app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt29
-rw-r--r--app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt90
-rw-r--r--app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt150
-rw-r--r--app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt33
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt27
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt25
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt26
192 files changed, 9203 insertions, 9599 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1cbe76a9..15d6447e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,36 +34,24 @@
tools:targetApi="s">
<activity
- android:name=".ui.passwords.PasswordStore"
- android:configChanges="orientation|screenSize"
- android:exported="false" />
-
- <activity
- android:name=".ui.onboarding.activity.OnboardingActivity"
- android:configChanges="orientation|screenSize"
- android:exported="false" />
-
- <activity
- android:name=".ui.proxy.ProxySelectorActivity"
- android:exported="false"
- android:label="@string/activity_proxy_label"
- android:windowSoftInputMode="adjustResize" />
-
- <activity
- android:name=".ui.crypto.DecryptActivity"
- android:exported="false" />
-
- <activity
android:name=".ui.main.LaunchActivity"
android:configChanges="orientation|screenSize"
android:exported="true"
- android:theme="@style/NoBackgroundThemeM3">
+ android:theme="@style/NoActionBarThemeM3">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
+ <!-- ponytail: rotation must not cancel store and git work in lifecycleScope; theme or locale changes still recreate. -->
+ <activity
+ android:name=".ui.main.MainActivity"
+ android:configChanges="orientation|screenSize"
+ android:exported="false"
+ android:theme="@style/NoActionBarThemeM3"
+ android:windowSoftInputMode="adjustResize" />
+
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:clearTaskOnLaunch="true"
@@ -73,35 +61,6 @@
android:windowSoftInputMode="stateAlwaysHidden"
tools:node="replace" />
- <activity
- android:name=".ui.git.config.GitServerConfigActivity"
- android:exported="false"
- android:label="@string/title_activity_git_clone"
- android:windowSoftInputMode="adjustResize" />
-
- <activity
- android:name=".ui.git.config.GitConfigActivity"
- android:exported="false"
- android:label="@string/title_activity_git_config"
- android:windowSoftInputMode="adjustResize" />
-
- <activity
- android:name=".ui.git.log.GitLogActivity"
- android:exported="false"
- android:label="@string/title_activity_git_log" />
-
- <activity
- android:name=".ui.settings.SettingsActivity"
- android:exported="false"
- android:label="@string/action_settings"
- android:parentActivityName=".ui.passwords.PasswordStore" />
-
- <activity
- android:name=".ui.crypto.PasswordCreationActivity"
- android:exported="false"
- android:label="@string/new_password_title"
- android:windowSoftInputMode="adjustResize" />
-
<service
android:name=".util.services.ClipboardService"
android:exported="false"
@@ -124,21 +83,11 @@
android:resource="@xml/oreo_autofill_service" />
</service>
- <activity
- android:name=".ui.folderselect.SelectFolderActivity"
- android:exported="false" />
- <activity
- android:name=".ui.sshkeygen.SshKeyImportActivity"
- android:exported="false"
- android:theme="@style/NoBackgroundThemeM3"
- android:windowSoftInputMode="adjustResize" />
- <activity
- android:name=".ui.sshkeygen.SshKeyGenActivity"
- android:exported="false"
- android:label="@string/pref_ssh_keygen_title"
- android:windowSoftInputMode="adjustResize" />
+ <!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress;
+ other recreations (theme, locale) cancel it. -->
<activity
android:name=".ui.autofill.AutofillDecryptActivity"
+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden"
android:exported="false"
android:theme="@style/NoBackgroundThemeM3" />
<activity
@@ -147,24 +96,19 @@
android:exported="false"
android:theme="@style/DialogLikeThemeM3"
android:windowSoftInputMode="adjustNothing" />
+ <!-- ponytail: rotation must not cancel a save running in lifecycleScope, as in MainActivity. -->
<activity
android:name=".ui.autofill.AutofillSaveActivity"
+ android:configChanges="orientation|screenSize"
android:exported="false"
- android:theme="@style/NoBackgroundThemeM3" />
+ android:theme="@style/NoActionBarThemeM3"
+ android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.autofill.AutofillPublisherChangedActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:theme="@style/DialogLikeThemeM3"
android:windowSoftInputMode="adjustNothing" />
- <activity
- android:name=".ui.pgp.PGPKeyImportActivity"
- android:theme="@style/NoBackgroundThemeM3" />
- <activity
- android:name=".ui.pgp.PGPKeyListActivity"
- android:exported="false"
- android:label="@string/activity_label_pgp_key_manager"
- android:parentActivityName=".ui.settings.SettingsActivity" />
</application>
<queries>
diff --git a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt b/app/src/main/java/app/passwordstore/data/password/FieldItem.kt
deleted file mode 100644
index 56ee8e0e..00000000
--- a/app/src/main/java/app/passwordstore/data/password/FieldItem.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.data.password
-
-import app.passwordstore.data.passfile.Totp
-
-class FieldItem
-private constructor(
- val type: ItemType,
- val label: String,
- val value: String,
- val action: ActionType,
-) {
- enum class ActionType {
- COPY,
- HIDE,
- }
-
- enum class ItemType() {
- USERNAME,
- PASSWORD,
- OTP,
- FREEFORM,
- }
-
- companion object {
- fun createOtpField(label: String, totp: Totp): FieldItem {
- return FieldItem(
- ItemType.OTP,
- label.format(totp.remainingTime.inWholeSeconds),
- totp.value,
- ActionType.COPY,
- )
- }
-
- fun createPasswordField(label: String, password: String): FieldItem {
- return FieldItem(ItemType.PASSWORD, label, password, ActionType.HIDE)
- }
-
- fun createUsernameField(label: String, username: String): FieldItem {
- return FieldItem(ItemType.USERNAME, label, username, ActionType.COPY)
- }
-
- fun createFreeformField(label: String, content: String): FieldItem {
- return FieldItem(ItemType.FREEFORM, label, content, ActionType.COPY)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
index 82798a05..5d32108f 100644
--- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
+++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt
@@ -6,22 +6,16 @@ package app.passwordstore.data.password
import android.content.Context
import android.content.Intent
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
+import app.passwordstore.ui.crypto.getLongName
import app.passwordstore.ui.main.LaunchActivity
import java.io.File
-data class PasswordItem(
- val name: String,
- val parent: PasswordItem? = null,
- val type: Char,
- val file: File,
- val rootDir: File,
-) : Comparable<PasswordItem> {
+data class PasswordItem(val name: String, val type: Char, val file: File, val rootDir: File) :
+ Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
- val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
+ val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString())
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
@@ -42,9 +36,7 @@ data class PasswordItem(
/** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */
fun createAuthEnabledIntent(context: Context): Intent {
val intent = Intent(context, LaunchActivity::class.java)
- intent.putExtra("NAME", toString())
- intent.putExtra("FILE_PATH", file.absolutePath)
- intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath)
intent.action = LaunchActivity.ACTION_DECRYPT_PASS
return intent
}
@@ -54,24 +46,12 @@ data class PasswordItem(
const val TYPE_CATEGORY = 'c'
const val TYPE_PASSWORD = 'p'
- @JvmStatic
- fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
- return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
- }
-
- @JvmStatic
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
- return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
- }
-
- @JvmStatic
- fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
- return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
+ return PasswordItem(name, TYPE_CATEGORY, file, rootDir)
}
- @JvmStatic
fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
- return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
+ return PasswordItem(name, TYPE_PASSWORD, file, rootDir)
}
}
}
diff --git a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
index 35c744a2..c9c4e928 100644
--- a/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/pwgen/DicewareModule.kt
@@ -13,14 +13,14 @@ import app.passwordstore.passgen.diceware.RandomIntGenerator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
-import dagger.hilt.android.components.FragmentComponent
+import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.InputStream
import java.security.SecureRandom
import javax.inject.Qualifier
@Module
-@InstallIn(FragmentComponent::class)
+@InstallIn(ViewModelComponent::class)
object DicewareModule {
@Provides
diff --git a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
index aa85d002..b7d32b71 100644
--- a/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
+++ b/app/src/main/java/app/passwordstore/injection/totp/TotpModule.kt
@@ -11,9 +11,10 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
+import dagger.hilt.android.components.ViewModelComponent
@Module
-@InstallIn(ActivityComponent::class)
+@InstallIn(ActivityComponent::class, ViewModelComponent::class)
interface TotpModule {
@Binds fun bindTotpFinder(impl: UriTotpFinder): TotpFinder
}
diff --git a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt
deleted file mode 100644
index 57428ffb..00000000
--- a/app/src/main/java/app/passwordstore/ui/adapters/FieldItemAdapter.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.adapters
-
-import android.text.method.PasswordTransformationMethod
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.core.content.res.ResourcesCompat
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-import app.passwordstore.data.passfile.Totp
-import app.passwordstore.data.password.FieldItem
-import app.passwordstore.databinding.ItemFieldBinding
-import app.passwordstore.ui.compose.R as composeR
-import com.google.android.material.textfield.TextInputLayout
-
-class FieldItemAdapter(
- private var fieldItemList: List<FieldItem>,
- private val showPassword: Boolean,
- private val copyTextToClipboard: (text: String?) -> Unit,
-) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
- val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return FieldItemViewHolder(binding.root, binding)
- }
-
- override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
- holder.bind(fieldItemList[position], showPassword, copyTextToClipboard)
- }
-
- override fun getItemCount(): Int {
- return fieldItemList.size
- }
-
- fun updateOTPCode(totp: Totp, labelFormat: String) {
- var otpItemPosition = -1
- fieldItemList =
- fieldItemList.mapIndexed { position, item ->
- if (item.type == FieldItem.ItemType.OTP) {
- otpItemPosition = position
- return@mapIndexed FieldItem.createOtpField(labelFormat, totp)
- }
-
- return@mapIndexed item
- }
-
- notifyItemChanged(otpItemPosition)
- }
-
- class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
- RecyclerView.ViewHolder(itemView) {
-
- fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipboard: (String?) -> Unit) {
- with(binding) {
- itemText.hint = fieldItem.label
- itemTextContainer.hint = fieldItem.label
- itemText.setText(fieldItem.value)
-
- when (fieldItem.action) {
- FieldItem.ActionType.COPY -> {
- itemTextContainer.apply {
- endIconDrawable =
- ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
- endIconMode = TextInputLayout.END_ICON_CUSTOM
- setEndIconOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.transformationMethod = null
- }
- FieldItem.ActionType.HIDE -> {
- itemTextContainer.apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- itemText.apply {
- transformationMethod =
- if (!showPassword) {
- PasswordTransformationMethod.getInstance()
- } else {
- null
- }
- if (fieldItem.type == FieldItem.ItemType.PASSWORD) {
- typeface =
- ResourcesCompat.getFont(
- binding.root.context,
- composeR.font.jetbrainsmono_nl_regular,
- )
- }
- setOnClickListener { copyTextToClipboard(itemText.text.toString()) }
- }
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt
deleted file mode 100644
index 54be64d6..00000000
--- a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.adapters
-
-import android.text.SpannableString
-import android.text.style.RelativeSizeSpan
-import android.view.MotionEvent
-import android.view.View
-import androidx.appcompat.widget.AppCompatImageView
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.recyclerview.selection.ItemDetailsLookup
-import androidx.recyclerview.selection.Selection
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
-import app.passwordstore.util.viewmodel.stableId
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.withContext
-
-open class PasswordItemRecyclerAdapter(
- coroutineScope: CoroutineScope,
- dispatcherProvider: DispatcherProvider,
-) :
- SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
- R.layout.password_row_layout,
- ::PasswordItemViewHolder,
- coroutineScope,
- dispatcherProvider,
- PasswordItemViewHolder::bind,
- ) {
-
- fun makeSelectable(recyclerView: RecyclerView) {
- makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
- }
-
- override fun onItemClicked(
- listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
- ): PasswordItemRecyclerAdapter {
- return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
- }
-
- override fun onSelectionChanged(
- listener: (selection: Selection<String>) -> Unit
- ): PasswordItemRecyclerAdapter {
- return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
- }
-
- class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
-
- private val name: AppCompatTextView = itemView.findViewById(R.id.label)
- private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
- private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
- var itemDetails: ItemDetailsLookup.ItemDetails<String>? = null
-
- suspend fun bind(item: PasswordItem, dispatcherProvider: DispatcherProvider) {
- val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
- val source =
- if (parentPath.isNotEmpty()) {
- "$parentPath\n$item"
- } else {
- "$item"
- }
- val spannable = SpannableString(source)
- spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
- name.text = spannable
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- folderIndicator.visibility = View.VISIBLE
- val count =
- withContext(dispatcherProvider.io()) {
- item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
- }
- childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
- childCount.text = "$count"
- } else {
- childCount.visibility = View.GONE
- folderIndicator.visibility = View.GONE
- }
- itemDetails =
- object : ItemDetailsLookup.ItemDetails<String>() {
- override fun getPosition() = absoluteAdapterPosition
-
- override fun getSelectionKey() = item.stableId
- }
- }
- }
-
- class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
- ItemDetailsLookup<String>() {
-
- override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
- val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
- return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
index 70525083..3f0fb5ef 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
@@ -8,24 +8,42 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
+import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
-import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.lifecycleScope
import app.passwordstore.Application.Companion.screenWasOff
import app.passwordstore.R
import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
import app.passwordstore.data.crypto.PGPPassphraseCache
import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.PasswordDialog
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.crypto.getParentPath
+import app.passwordstore.ui.entry.PassphraseDialog
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder
+import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.extensions.asLog
import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
import app.passwordstore.util.features.Features
@@ -47,62 +65,157 @@ import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
+/**
+ * Decrypts one entry for the autofill service and returns the dataset to fill. Its transparent,
+ * FLAG_SECURE window shows only the "no keys" and passphrase dialogs.
+ */
@AndroidEntryPoint
-class AutofillDecryptActivity : BasePGPActivity() {
+class AutofillDecryptActivity : AppCompatActivity() {
+ @SettingsPreferences @Inject lateinit var settings: SharedPreferences
+ @Inject lateinit var repository: CryptoRepository
+ @Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
@Inject lateinit var features: Features
@Inject lateinit var passphraseCache: PGPPassphraseCache
private lateinit var directoryStructure: DirectoryStructure
+ private lateinit var filePath: String
+ private lateinit var clientState: Bundle
+ private lateinit var action: AutofillAction
private var clearCache = true
- override fun onStart() {
- super.onStart()
- val filePath =
+ /** The screen lock prompt's outcome: the passphrase is cached only after a success. */
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+
+ /** Set once the flow started; reset after a key import, so the next window focus restarts it. */
+ private var started = false
+
+ /** Shows the "No keys imported" dialog. */
+ private var noKeys by mutableStateOf(false)
+
+ /** The recipients the passphrase dialog asks for; null while it is not shown. */
+ private var passphraseFor by mutableStateOf<List<PGPIdentifier>?>(null)
+
+ // The import's activity is still on top when its result arrives: the flow restarts on the next
+ // window focus, so the screen lock prompt is not shown (and evicted) behind it.
+ private val keyImport =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) started = false else finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and
+ // recents.
+ window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
+ // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see
+ // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field
+ // again. Keep the flow's state in a ViewModel if this ever matters.
+ if (savedInstanceState != null) {
+ finish()
+ return
+ }
+ filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
- val clientState =
+ clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
- val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
- val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
+ val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true)
+ action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
logcat { action.toString() }
- requireKeysExist {
- if (
- features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this)
- ) {
- BiometricAuthenticator.authenticate(
- this,
- R.string.biometric_prompt_title_gpg_passphrase_cache,
- ) { authResult ->
- decrypt(filePath, clientState, action, authResult)
- }
- } else {
- decrypt(filePath, clientState, action, BiometricResult.CanceledByUser)
+ setContent {
+ APSTheme {
+ if (noKeys)
+ AlertDialog(
+ onDismissRequest = { finish() },
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ noKeys = false
+ keyImport.launch(
+ MainActivity.setupIntent(this@AutofillDecryptActivity, SetupStart.PgpImport)
+ )
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ val identifiers = passphraseFor
+ if (identifiers != null)
+ PassphraseDialog(
+ wrong = false,
+ cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
+ clearCache = clearCache,
+ onSubmit = onSubmit@{ passphrase, clear ->
+ // Cleared before decrypting: a second submit in the same frame does nothing.
+ if (passphraseFor == null) return@onSubmit
+ passphraseFor = null
+ clearCache = clear
+ lifecycleScope.launch(dispatcherProvider.main()) {
+ decryptWithPassphrase(identifiers, passphrase, typed = true)
+ }
+ },
+ onCancel = { finish() },
+ )
}
}
}
- private fun decrypt(
- filePath: String,
- clientState: Bundle,
- action: AutofillAction,
- authResult: BiometricResult,
- ) {
+ // The flow waits for window focus, as LaunchActivity's prompt does: Android 14's SystemUI
+ // dismisses a screen lock prompt shown before its activity is on top. It runs once (again only
+ // after a key import); the old onStart start ran it on every return to this activity.
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus || started || isFinishing) return
+ started = true
+ start()
+ }
+
+ /** Asks for a key import first while no PGP key is imported. */
+ private fun start() {
+ if (isFinishing) return
+ lifecycleScope.launch { if (repository.hasKeys()) unlock() else noKeys = true }
+ }
+
+ private fun unlock() {
+ if (
+ features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this)
+ ) {
+ BiometricAuthenticator.authenticate(
+ this,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { authResult ->
+ decrypt(authResult)
+ }
+ } else {
+ decrypt(BiometricResult.CanceledByUser)
+ }
+ }
+
+ private fun decrypt(authResult: BiometricResult) {
+ auth = authResult
val gpgIdentifiers =
getPGPIdentifiers(
getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString())
- ) ?: return
+ )
+ ?: run {
+ finish()
+ return
+ }
lifecycleScope.launch(dispatcherProvider.main()) {
when (authResult) {
// Internally handled by the prompt dialog
@@ -111,8 +224,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
is BiometricResult.CanceledBySystem,
is BiometricResult.CanceledByUser,
is BiometricResult.Failure,
- is BiometricResult.HardwareUnavailableOrDisabled ->
- askPassphrase(filePath, gpgIdentifiers, clientState, action)
+ is BiometricResult.HardwareUnavailableOrDisabled -> askPassphrase(gpgIdentifiers)
is BiometricResult.Success -> {
/* clear passphrase cache on first use after application startup or if screen was off;
also make sure to purge a stale cache after caching has been disabled via PGP settings */
@@ -127,56 +239,30 @@ class AutofillDecryptActivity : BasePGPActivity() {
gpgIdentifiers.first(),
)
if (cachedPassphrase != null) {
- decryptWithPassphrase(
- File(filePath),
- gpgIdentifiers,
- clientState,
- action,
- cachedPassphrase,
- )
+ decryptWithPassphrase(gpgIdentifiers, cachedPassphrase)
} else {
- askPassphrase(filePath, gpgIdentifiers, clientState, action)
+ askPassphrase(gpgIdentifiers)
}
}
}
}
}
- private suspend fun askPassphrase(
- filePath: String,
- identifiers: List<PGPIdentifier>,
- clientState: Bundle,
- action: AutofillAction,
- ) {
+ private suspend fun askPassphrase(identifiers: List<PGPIdentifier>) {
if (!repository.isPasswordProtected(identifiers)) {
- decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "")
+ decryptWithPassphrase(identifiers, password = "")
return
}
- val dialog =
- PasswordDialog.newInstance(
- cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
- clearCache = clearCache,
- )
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle ->
- if (key == PasswordDialog.PASSWORD_RESULT_KEY) {
- val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!!
- clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY)
- lifecycleScope.launch(dispatcherProvider.main()) {
- decryptWithPassphrase(File(filePath), identifiers, clientState, action, value)
- }
- }
- }
+ passphraseFor = identifiers
}
+ /** Caches [password] only when the user [typed] it, as the entry view does. */
private suspend fun decryptWithPassphrase(
- filePath: File,
identifiers: List<PGPIdentifier>,
- clientState: Bundle,
- action: AutofillAction,
password: String,
+ typed: Boolean = false,
) {
- val credentials = decryptCredential(filePath, password, identifiers)
+ val credentials = decryptCredential(File(filePath), password, identifiers, typed)
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
@@ -201,6 +287,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
file: File,
password: String,
identifiers: List<PGPIdentifier>,
+ typed: Boolean,
): Credentials? {
runCatching { file.readBytes().inputStream() }
.onFailure { e ->
@@ -218,7 +305,9 @@ class AutofillDecryptActivity : BasePGPActivity() {
.onSuccess { result ->
return runCatching {
runCatching {
- if (features.isEnabled(EnablePGPPassphraseCache)) {
+ if (
+ typed && cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth)
+ ) {
passphraseCache.cachePassphrase(this, identifiers.first(), password)
settings.edit {
putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache)
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt
new file mode 100644
index 00000000..acbacd40
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterScreen.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.EntryRow
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.ui.settings.PreferenceSwitch
+import kotlinx.collections.immutable.ImmutableList
+
+/**
+ * The autofill picker: which site or app is being filled ([origin], as the user can check it), a
+ * search over the store's entries, the matching [rows] (null while the first search runs), and the
+ * switches for phishing-resistant search ([showStrict]: websites only), recording the pick as a
+ * match ([matchLabel]) and clearing earlier matches. [onPick] takes the entry's absolute path.
+ */
+@Composable
+fun AutofillFilterScreen(
+ origin: String,
+ query: String,
+ rows: ImmutableList<ListRow>?,
+ showStrict: Boolean,
+ strict: Boolean,
+ matchLabel: String,
+ match: Boolean,
+ clear: Boolean,
+ onQueryChange: (String) -> Unit,
+ onStrictChange: (Boolean) -> Unit,
+ onMatchChange: (Boolean) -> Unit,
+ onClearChange: (Boolean) -> Unit,
+ onPick: (path: String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fillInto = stringResource(R.string.oreo_autofill_select_and_fill_into)
+ val listState = rememberLazyListState()
+ // As the old picker did: every new result starts at its best match.
+ LaunchedEffect(rows) { listState.scrollToItem(0) }
+ Surface(modifier.fillMaxSize()) {
+ Column(Modifier.padding(vertical = SpacingLarge)) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = stringResource(R.string.app_name),
+ modifier = Modifier.size(60.dp).align(Alignment.CenterHorizontally),
+ )
+ Text(
+ buildAnnotatedString {
+ append(fillInto)
+ append("\n")
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(origin) }
+ },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge),
+ textAlign = TextAlign.Center,
+ )
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ modifier = Modifier.fillMaxWidth().padding(SpacingLarge),
+ label = { Text(stringResource(R.string.action_search)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null)
+ },
+ trailingIcon =
+ if (query.isNotEmpty()) {
+ {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search))
+ }
+ }
+ } else null,
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ )
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ when {
+ rows == null -> Unit
+ rows.isEmpty() ->
+ Text(
+ stringResource(R.string.oreo_autofill_filter_no_results),
+ modifier = Modifier.padding(horizontal = SpacingLarge),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ else ->
+ // One lazy item per row: a cleared search lists the whole store. The surface wraps
+ // a short list and is capped by the box for a long one.
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ LazyColumn(state = listState) {
+ items(rows, key = { it.path }) { row ->
+ EntryRow(
+ name = row.label,
+ subtitle = row.subtitle,
+ onClick = { onPick(row.path) },
+ )
+ }
+ }
+ }
+ }
+ }
+ if (showStrict)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_strict_domain_search),
+ strict,
+ onStrictChange,
+ )
+ PreferenceSwitch(matchLabel, match, onMatchChange)
+ PreferenceSwitch(
+ stringResource(R.string.oreo_autofill_matches_clear_existing),
+ clear,
+ onClearChange,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
index b027657e..c607b81f 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
@@ -10,36 +10,36 @@ import android.content.Intent
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
-import android.view.View
import android.view.autofill.AutofillManager
-import android.widget.TextView
+import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.text.bold
-import androidx.core.text.buildSpannedString
-import androidx.core.text.underline
-import androidx.core.widget.addTextChangedListener
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.list.ListRow
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.DirectoryStructure
import app.passwordstore.util.viewmodel.FilterMode
import app.passwordstore.util.viewmodel.ListMode
import app.passwordstore.util.viewmodel.SearchMode
-import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
import javax.inject.Inject
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.launch
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -85,11 +85,12 @@ class AutofillFilterView : AppCompatActivity() {
}
private lateinit var formOrigin: FormOrigin
- private lateinit var directoryStructure: DirectoryStructure
- private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
private val model: SearchableRepositoryViewModel by viewModels()
+ /** Set by the first tap on a row: a double tap must not start a second decryption. */
+ private var picked = false
+
private val decryptAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
@@ -100,7 +101,10 @@ class AutofillFilterView : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(binding.root)
+ // The picker decides which entry fills the requesting app: drop touches under an overlay, as
+ // the
+ // publisher warning does.
+ window.decorView.filterTouchesWhenObscured = true
setFinishOnTouchOutside(true)
val params = window.attributes
@@ -129,111 +133,77 @@ class AutofillFilterView : AppCompatActivity() {
return
}
}
- directoryStructure = AutofillPreferences.directoryStructure(this)
-
supportActionBar?.hide()
- bindUI()
- updateSearch()
setResult(RESULT_CANCELED)
- }
- private fun bindUI() {
- with(binding) {
- rvPassword.apply {
- adapter =
- SearchableRepositoryAdapter(
- R.layout.oreo_autofill_filter_row,
- ::PasswordViewHolder,
- lifecycleScope,
- dispatcherProvider,
- ) { item, _ ->
- val file = item.file.relativeTo(item.rootDir)
- val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
- val identifier = directoryStructure.getIdentifierFor(file)
- val accountPart = directoryStructure.getAccountPartFor(file)
- check(identifier != null || accountPart != null) {
- "At least one of identifier and accountPart should always be non-null"
- }
- title.text =
- if (identifier != null) {
- buildSpannedString {
- if (pathToIdentifier != null) append("$pathToIdentifier/")
- bold { underline { append(identifier) } }
- }
- } else {
- accountPart
- }
- subtitle.apply {
- if (identifier != null && accountPart != null) {
- text = accountPart
- visibility = View.VISIBLE
- } else {
- visibility = View.GONE
- }
- }
- }
- .onItemClicked { _, item -> decryptAndFill(item) }
- layoutManager = LinearLayoutManager(context)
- }
- search.apply {
- val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
- setText(initialSearch, TextView.BufferType.EDITABLE)
- addTextChangedListener { updateSearch() }
- }
- origin.text = buildSpannedString {
- append(getString(R.string.oreo_autofill_select_and_fill_into))
- append("\n")
- bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
- }
- strictDomainSearch.apply {
- visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
- isChecked = formOrigin is FormOrigin.Web
- setOnCheckedChangeListener { _, _ -> updateSearch() }
- }
- shouldMatch.text =
- getString(
- R.string.oreo_autofill_match_with,
- formOrigin.getPrettyIdentifier(applicationContext),
+ val isWeb = formOrigin is FormOrigin.Web
+ val initialQuery = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
+ val root = PasswordRepository.getRepositoryDirectory()
+ val directoryStructure = AutofillPreferences.directoryStructure(this)
+ // ponytail: cold flow, so each return to STARTED walks the store again; fine for a picker open
+ // a few seconds. Share it with stateIn in the ViewModel if a large store makes it slow.
+ val rows =
+ model.searchResult
+ .map { result ->
+ autofillRows(result.passwordItems.map { it.file }, root, directoryStructure)
+ }
+ .flowOn(dispatcherProvider.io())
+ val origin = formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)
+ val matchLabel =
+ getString(
+ R.string.oreo_autofill_match_with,
+ formOrigin.getPrettyIdentifier(applicationContext),
+ )
+
+ setContent {
+ APSTheme {
+ var query by rememberSaveable { mutableStateOf(initialQuery) }
+ var strict by rememberSaveable { mutableStateOf(isWeb) }
+ var match by rememberSaveable { mutableStateOf(true) }
+ var clear by rememberSaveable { mutableStateOf(false) }
+ // During the first composition, before rows is collected, so the ViewModel's default root
+ // listing (folders included) is never shown, and with the restored query and switch after
+ // recreation. The LaunchedEffect repeats it with equal arguments, which emits nothing.
+ remember { search(query, strict) }
+ LaunchedEffect(query, strict) { search(query, strict) }
+ val shown by rows.collectAsStateWithLifecycle<ImmutableList<ListRow>?>(initialValue = null)
+ AutofillFilterScreen(
+ origin = origin,
+ query = query,
+ rows = shown,
+ showStrict = isWeb,
+ strict = strict,
+ matchLabel = matchLabel,
+ match = match,
+ clear = clear,
+ onQueryChange = { query = it },
+ onStrictChange = { strict = it },
+ onMatchChange = { match = it },
+ onClearChange = { clear = it },
+ onPick = { path -> decryptAndFill(File(path), match, clear) },
)
- lifecycleScope.launch { handleSearchResults() }
- }
- }
-
- private suspend fun handleSearchResults() {
- model.searchResult.collect { result ->
- val list = result.passwordItems
- (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
- binding.rvPassword.scrollToPosition(0)
- }
- // Switch RecyclerView out for a "no results" message if the new list is empty and
- // the message is not yet shown (and vice versa).
- if (
- (list.isEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPasswordEmpty.id) ||
- (list.isNotEmpty() && binding.rvPasswordSwitcher.nextView.id == binding.rvPassword.id)
- ) {
- binding.rvPasswordSwitcher.showNext()
}
}
}
- private fun updateSearch() {
+ /** The old picker's search: fuzzy or strict domain, recursive, entries only. */
+ private fun search(query: String, strict: Boolean) {
model.search(
- binding.search.text.toString().trim(),
- filterMode =
- if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
+ query.trim(),
+ filterMode = if (strict) FilterMode.StrictDomain else FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories,
listMode = ListMode.FilesOnly,
)
}
- private fun decryptAndFill(item: PasswordItem) {
- if (binding.shouldClear.isChecked)
- AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
- if (binding.shouldMatch.isChecked)
- AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
+ private fun decryptAndFill(file: File, match: Boolean, clear: Boolean) {
+ if (picked) return
+ picked = true
+ if (clear) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
+ if (match) AutofillMatcher.addMatchFor(applicationContext, formOrigin, file)
// intent?.extras? is checked to be non-null in onCreate
decryptAction.launch(
- AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
+ AutofillDecryptActivity.makeDecryptFileIntent(file, intent!!.extras!!, this)
)
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt
new file mode 100644
index 00000000..9f436ea2
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.ui.list.ListRow
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * The autofill picker's rows for [files] (entries under [root], already ranked), in their order.
+ * The name is the origin identifier per [structure], the part that must match the site or app, so
+ * it stands out as the old picker's bold part did; the subtitle holds the account part and the
+ * folders above the identifier. Without an identifier the account part is the name.
+ */
+fun autofillRows(
+ files: List<File>,
+ root: File,
+ structure: DirectoryStructure,
+): ImmutableList<ListRow> =
+ files
+ .map { file ->
+ val relative = file.relativeTo(root)
+ val identifier = structure.getIdentifierFor(relative)
+ val account = structure.getAccountPartFor(relative)
+ val subtitle =
+ if (identifier == null) null
+ else
+ listOfNotNull(account, structure.getPathToIdentifierFor(relative))
+ .joinToString(" · ")
+ .ifEmpty { null }
+ ListRow(file.absolutePath, identifier ?: account ?: file.nameWithoutExtension, subtitle, 0)
+ }
+ .toImmutableList()
+
+/**
+ * Whether autofill may cache the passphrase it just decrypted with: only with the cache feature on
+ * ([cacheEnabled]) and after a successful screen lock prompt ([auth]), as the entry view does. A
+ * passphrase typed after a cancelled, failed or unavailable prompt is never kept.
+ */
+fun cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean =
+ cacheEnabled && auth is BiometricResult.Success
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
index 5b55143a..803896a5 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt
@@ -13,21 +13,20 @@ import android.os.Build
import android.os.Bundle
import android.service.autofill.FillResponse
import android.text.format.DateUtils
-import android.view.View
import android.view.autofill.AutofillManager
+import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.IntentCompat
import app.passwordstore.R
-import app.passwordstore.databinding.ActivityOreoAutofillPublisherChangedBinding
+import app.passwordstore.ui.compose.theme.APSTheme
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPublisherChangedException
import app.passwordstore.util.extensions.asLog
import app.passwordstore.util.extensions.getApplicationInfoCompat
import app.passwordstore.util.extensions.getPackageInfoCompat
-import app.passwordstore.util.extensions.viewBinding
import com.github.androidpasswordstore.autofillparser.FormOrigin
import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
-import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import logcat.LogPriority.ERROR
import logcat.logcat
@@ -65,15 +64,15 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
}
}
- private lateinit var appPackage: String
- private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContentView(binding.root)
setFinishOnTouchOutside(true)
+ // Tapjacking guard: a touch that reaches this window through another app's window (an overlay
+ // drawn to trick the user into re-enabling) is dropped. ViewGroup.dispatchTouchEvent checks the
+ // decor view's own flag before dispatching to any child, so the ComposeView is covered.
+ window.decorView.filterTouchesWhenObscured = true
- appPackage =
+ val appPackage =
intent.getStringExtra(EXTRA_APP_PACKAGE)
?: run {
logcat(ERROR) { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
@@ -81,63 +80,57 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
return
}
supportActionBar?.hide()
- showPackageInfo()
- with(binding) {
- okButton.setOnClickListener { finish() }
- advancedButton.setOnClickListener {
- advancedButton.visibility = View.GONE
- warningAppAdvancedInfo.visibility = View.VISIBLE
- resetButton.visibility = View.VISIBLE
- }
- resetButton.setOnClickListener {
- AutofillMatcher.clearMatchesFor(
- this@AutofillPublisherChangedActivity,
- FormOrigin.App(appPackage),
- )
- val fillResponse =
- IntentCompat.getParcelableExtra(
- intent,
- EXTRA_FILL_RESPONSE_AFTER_RESET,
- FillResponse::class.java,
+ val (packageInfo, appInfo, hash) =
+ runCatching {
+ Triple(
+ packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA),
+ computeCertificatesHash(this, appPackage),
)
- setResult(
- RESULT_OK,
- Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) },
+ }
+ .getOrElse { e ->
+ logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") }
+ finish()
+ return
+ }
+ val appName =
+ getString(
+ R.string.oreo_autofill_warning_publisher_app_name,
+ packageManager.getApplicationLabel(appInfo),
+ )
+ val installed =
+ getString(
+ R.string.oreo_autofill_warning_publisher_install_time,
+ DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime),
+ )
+ val details =
+ getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, hash)
+ setContent {
+ APSTheme {
+ PublisherChangedScreen(
+ appName = appName,
+ installed = installed,
+ details = details,
+ onKeepDisabled = { finish() },
+ onReset = { reset(appPackage) },
)
- finish()
}
}
}
- private fun showPackageInfo() {
- runCatching {
- with(binding) {
- val packageInfo =
- packageManager.getPackageInfoCompat(appPackage, PackageManager.GET_META_DATA)
- val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
- warningAppInstallDate.text =
- getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
- val appInfo =
- packageManager.getApplicationInfoCompat(appPackage, PackageManager.GET_META_DATA)
- warningAppName.text =
- getString(
- R.string.oreo_autofill_warning_publisher_app_name,
- packageManager.getApplicationLabel(appInfo),
- )
-
- val currentHash =
- computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
- warningAppAdvancedInfo.text =
- getString(
- R.string.oreo_autofill_warning_publisher_advanced_info_template,
- appPackage,
- currentHash,
- )
- }
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog("Failed to retrieve package info for $appPackage") }
- finish()
- }
+ /** Forgets the app's matches and fills with the response the service prepared for this case. */
+ private fun reset(appPackage: String) {
+ AutofillMatcher.clearMatchesFor(this, FormOrigin.App(appPackage))
+ val fillResponse =
+ IntentCompat.getParcelableExtra(
+ intent,
+ EXTRA_FILL_RESPONSE_AFTER_RESET,
+ FillResponse::class.java,
+ )
+ setResult(
+ RESULT_OK,
+ Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) },
+ )
+ finish()
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
index 9476b344..eb533411 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
@@ -9,12 +9,17 @@ import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.os.Bundle
+import android.view.WindowManager
import android.view.autofill.AutofillManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.PasswordCreationActivity
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.SaveResult
+import app.passwordstore.ui.editor.passContent
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.autofill.AutofillResponseBuilder
@@ -23,10 +28,15 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
+import javax.inject.Inject
import logcat.LogPriority.ERROR
import logcat.logcat
+/**
+ * Saves credentials the autofill service caught (or generates a password for a form) in the Compose
+ * editor, prefilled from the form's origin, then records the autofill match and, for a generated
+ * password, fills it in.
+ */
@AndroidEntryPoint
class AutofillSaveActivity : AppCompatActivity() {
@@ -102,60 +112,68 @@ class AutofillSaveActivity : AppCompatActivity() {
}
}
+ private val generate by unsafeLazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
+
+ @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- val repo = PasswordRepository.getRepositoryDirectory()
- val saveIntent =
- Intent(this, PasswordCreationActivity::class.java).apply {
- putExtras(
- bundleOf(
- "REPO_PATH" to repo.absolutePath,
- "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
- PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
- PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
- PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false),
- )
+ // The whole window is the editor: keep it out of screenshots and recents.
+ window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ val initial =
+ EditorForm(
+ name = intent.getStringExtra(EXTRA_NAME).orEmpty(),
+ dir = intent.getStringExtra(EXTRA_FOLDER_NAME).orEmpty().trim('/'),
+ password = intent.getStringExtra(EXTRA_PASSWORD).orEmpty(),
+ )
+ setContent {
+ APSTheme {
+ EditorDestination(
+ activity = this,
+ initial = initial,
+ original = null,
+ onClose = {
+ setResult(RESULT_CANCELED)
+ finish()
+ },
+ onSaved = ::finishSaved,
+ generate = generate,
)
}
- registerForActivityResult(StartActivityForResult()) { result ->
- val data = result.data
- if (result.resultCode == RESULT_OK && data != null) {
- val createdPath = data.getStringExtra("CREATED_FILE")!!
- formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
- val password = data.getStringExtra("PASSWORD")
- val resultIntent =
- if (password != null) {
- // Password was generated and should be filled into a form.
- val username = data.getStringExtra("USERNAME")
- val clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
- finish()
- return@registerForActivityResult
- }
- val credentials = Credentials(username, password, null)
- val fillInDataset =
- AutofillResponseBuilder.makeFillInDataset(
- this,
- credentials,
- clientState,
- AutofillAction.Generate,
- )
- Intent().apply {
- putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
- }
- } else {
- // Password was extracted from a form, there is nothing to fill.
- Intent()
+ }
+ }
+
+ /** Records the autofill match and, for a generated password, returns it to fill the form. */
+ private fun finishSaved(saved: SaveResult.Saved) {
+ formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) }
+ val resultIntent =
+ if (generate) {
+ val clientState =
+ intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ ?: run {
+ logcat(ERROR) { "AutofillSaveActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
}
- setResult(RESULT_OK, resultIntent)
- } else {
- setResult(RESULT_CANCELED)
- }
- finish()
+ // Read back from the saved content, as the old editor did, so a username in the extras
+ // counts too.
+ val entry = passwordEntryFactory.create(passContent(saved.form).encodeToByteArray())
+ val username =
+ entry.username ?: AutofillPreferences.directoryStructure(this).getUsernameFor(saved.file)
+ val credentials = Credentials(username, entry.password, null)
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(
+ this,
+ credentials,
+ clientState,
+ AutofillAction.Generate,
+ )
+ Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ } else {
+ // The password came from the form: there is nothing to fill.
+ Intent()
}
- .launch(saveIntent)
+ setResult(RESULT_OK, resultIntent)
+ finish()
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt
deleted file mode 100644
index 24a28564..00000000
--- a/app/src/main/java/app/passwordstore/ui/autofill/PasswordViewHolder.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.autofill
-
-import android.view.View
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.R
-
-class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
-
- val title: TextView = itemView.findViewById(R.id.title)
- val subtitle: TextView = itemView.findViewById(R.id.subtitle)
-}
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt
new file mode 100644
index 00000000..6287408e
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/autofill/PublisherChangedScreen.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/**
+ * Warns that the app being filled ([appName], installed [installed]) is signed by another publisher
+ * than when an entry was first matched to it. [onKeepDisabled] fills nothing. [onReset] forgets the
+ * old matches and fills; it is offered only after "Advanced information", which shows [details]
+ * (package and certificate hash), so re-enabling always takes two deliberate taps.
+ */
+@Composable
+fun PublisherChangedScreen(
+ appName: String,
+ installed: String,
+ details: String,
+ onKeepDisabled: () -> Unit,
+ onReset: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var advanced by rememberSaveable { mutableStateOf(false) }
+ val center = Modifier.fillMaxWidth()
+ Surface(modifier) {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_warning_red_24dp),
+ contentDescription =
+ stringResource(R.string.oreo_autofill_warning_publisher_warning_sign_description),
+ modifier = Modifier.size(40.dp),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_dataset_title),
+ modifier = center.semantics { heading() },
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_header),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ appName,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ installed,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ // ponytail: stringResource drops the footer's <b> in every locale but Italian (whose footer
+ // has none); the red title carries the emphasis. Keep spans with getText if it is missed.
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_footer),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = onKeepDisabled, modifier = center) {
+ Text(
+ stringResource(R.string.oreo_autofill_warning_publisher_changed_disable_autofill_button)
+ )
+ }
+ if (!advanced) {
+ OutlinedButton(onClick = { advanced = true }, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_advanced_info_button))
+ }
+ } else {
+ // The details come first, so Re-enable never sits where Advanced information was and a
+ // double tap cannot re-enable autofill.
+ SelectionContainer {
+ Text(
+ details,
+ modifier = center,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
+ )
+ }
+ OutlinedButton(onClick = onReset, modifier = center) {
+ Text(stringResource(R.string.oreo_autofill_warning_publisher_reenable_button))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
deleted file mode 100644
index 2b3f4dab..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.ClipData
-import android.content.Intent
-import android.content.SharedPreferences
-import android.os.Build
-import android.os.Bundle
-import android.os.PersistableBundle
-import android.view.WindowManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.annotation.CallSuper
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.data.crypto.CryptoRepository
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.pgp.PGPKeyImportActivity
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.clipboard
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.services.ClipboardService
-import app.passwordstore.util.settings.Constants
-import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-@Suppress("Registered")
-@AndroidEntryPoint
-open class BasePGPActivity : AppCompatActivity() {
-
- /** Full path to the repository */
- val repoPath by unsafeLazy { intent.getStringExtra("REPO_PATH")!! }
-
- /** Full path to the password file being worked on */
- val fullPath by unsafeLazy { intent.getStringExtra("FILE_PATH")!! }
-
- /**
- * Name of the password file
- *
- * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
- */
- val name: String by unsafeLazy { File(fullPath).nameWithoutExtension }
-
- /** Action to invoke if [keyImportAction] succeeds. */
- private var onKeyImport: (() -> Unit)? = null
- private val keyImportAction =
- registerForActivityResult(StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- onKeyImport?.invoke()
- onKeyImport = null
- } else {
- finish()
- }
- }
-
- /** [SharedPreferences] instance used by subclasses to persist settings */
- @SettingsPreferences @Inject lateinit var settings: SharedPreferences
- @Inject lateinit var repository: CryptoRepository
- @Inject lateinit var dispatcherProvider: DispatcherProvider
-
- /**
- * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
- * recent apps screen.
- */
- @CallSuper
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
- }
-
- /**
- * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
- * [showSnackbar] as false.
- */
- fun copyTextToClipboard(
- text: String?,
- showSnackbar: Boolean = true,
- @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text,
- ) {
- val clipboard = clipboard ?: return
- val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
- clip.description.extras =
- PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
- clipboard.setPrimaryClip(clip)
- if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
- snackbar(message = resources.getString(snackbarTextRes))
- }
- }
-
- /**
- * Function to execute [onKeysExist] only if there are PGP keys imported in the app's key manager.
- */
- fun requireKeysExist(onKeysExist: () -> Unit) {
- lifecycleScope.launch {
- val hasKeys = repository.hasKeys()
- if (!hasKeys) {
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@BasePGPActivity)
- .setTitle(resources.getString(R.string.no_keys_imported_dialog_title))
- .setMessage(resources.getString(R.string.no_keys_imported_dialog_message))
- .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ ->
- onKeyImport = onKeysExist
- keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java))
- }
- .show()
- }
- } else {
- onKeysExist()
- }
- }
- }
-
- /**
- * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
- * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
- * clearing the clipboard.
- */
- fun copyPasswordToClipboard(password: String?) {
- copyTextToClipboard(password)
- val clearAfter =
- settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
- ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
-
- if (clearAfter != 0) {
- val service =
- Intent(this, ClipboardService::class.java).apply {
- action = ClipboardService.ACTION_START
- putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
- }
- startForegroundService(service)
- }
- }
-
- /**
- * Get a list of available [PGPIdentifier]s for the current password repository. This method
- * throws when no identifiers were able to be parsed. If this method returns null, it means that
- * an invalid identifier was encountered and further execution must stop to let the user correct
- * the problem.
- */
- fun getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
- val repoRoot = PasswordRepository.getRepositoryDirectory()
- val gpgIdentifierFile =
- File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
- ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
- val gpgIdentifiers =
- gpgIdentifierFile
- .readLines()
- .filter { it.isNotBlank() }
- .map { line ->
- PGPIdentifier.fromString(line)
- ?: run {
- // The line being empty means this is most likely an empty `.gpg-id`
- // file we created. Skip the validation so we can make the user add a
- // real ID.
- if (line.isEmpty()) return@run
- // Apparently `gpg-id` being the first line is also acceptable?
- if (line == "gpg-id") return@run
- if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
- snackbar(message = resources.getString(R.string.short_gpg_id))
- } else {
- snackbar(message = resources.getString(R.string.invalid_gpg_id))
- }
- return null
- }
- }
- .filterIsInstance<PGPIdentifier>()
- if (gpgIdentifiers.isEmpty()) {
- error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
- }
- return gpgIdentifiers
- }
-
- @Suppress("ReturnCount")
- private fun File.findTillRoot(fileName: String, rootPath: File): File? {
- val gpgFile = File(this, fileName)
- if (gpgFile.exists()) return gpgFile
-
- if (this.absolutePath == rootPath.absolutePath) {
- return null
- }
- val parent = parentFile
- return if (parent != null && parent.exists()) {
- parent.findTillRoot(fileName, rootPath)
- } else {
- null
- }
- }
-
- companion object {
-
- const val EXTRA_FILE_PATH = "FILE_PATH"
- const val EXTRA_REPO_PATH = "REPO_PATH"
-
- /** Gets the relative path to the repository */
- fun getRelativePath(fullPath: String, repositoryPath: String): String =
- fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
-
- /** Gets the Parent path, relative to the repository */
- fun getParentPath(fullPath: String, repositoryPath: String): String {
- val relativePath = getRelativePath(fullPath, repositoryPath)
- val index = relativePath.lastIndexOf("/")
- return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
- .replace("/+".toRegex(), "/")
- }
-
- /** /path/to/store/social/facebook.gpg -> social/facebook */
- @JvmStatic
- fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
- var relativePath = getRelativePath(fullPath, repositoryPath)
- return if (relativePath.isNotEmpty() && relativePath != "/") {
- // remove preceding '/'
- relativePath = relativePath.substring(1)
- if (relativePath.endsWith('/')) {
- relativePath + basename
- } else {
- "$relativePath/$basename"
- }
- } else {
- basename
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
new file mode 100644
index 00000000..fbaa534f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.PersistableBundle
+import android.widget.Toast
+import androidx.annotation.StringRes
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.extensions.clipboard
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.services.ClipboardService
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/** Gets the relative path to the repository */
+private fun getRelativePath(fullPath: String, repositoryPath: String): String =
+ fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+
+/** Gets the Parent path, relative to the repository */
+fun getParentPath(fullPath: String, repositoryPath: String): String {
+ val relativePath = getRelativePath(fullPath, repositoryPath)
+ val index = relativePath.lastIndexOf("/")
+ return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/"
+ .replace("/+".toRegex(), "/")
+}
+
+/** /path/to/store/social/facebook.gpg -> social/facebook */
+fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
+ var relativePath = getRelativePath(fullPath, repositoryPath)
+ return if (relativePath.isNotEmpty() && relativePath != "/") {
+ // remove preceding '/'
+ relativePath = relativePath.substring(1)
+ if (relativePath.endsWith('/')) {
+ relativePath + basename
+ } else {
+ "$relativePath/$basename"
+ }
+ } else {
+ basename
+ }
+}
+
+/**
+ * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a
+ * snackbar, which can be disabled by passing [showSnackbar] as false.
+ */
+fun FragmentActivity.copyTextToClipboard(
+ text: String?,
+ showSnackbar: Boolean = true,
+ @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text,
+) {
+ val clipboard = clipboard ?: return
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ clipboard.setPrimaryClip(clip)
+ if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ snackbar(message = resources.getString(snackbarTextRes))
+ }
+}
+
+/**
+ * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts
+ * off an instance of [ClipboardService] to provide a way of clearing the clipboard.
+ */
+fun FragmentActivity.copyPasswordToClipboard(password: String?) {
+ copyTextToClipboard(password)
+ val clearAfter =
+ sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+
+ if (clearAfter != 0) {
+ val service =
+ Intent(this, ClipboardService::class.java).apply {
+ action = ClipboardService.ACTION_START
+ putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
+ }
+ startForegroundService(service)
+ }
+}
+
+/**
+ * Get a list of available [PGPIdentifier]s for the current password repository. This method throws
+ * when no identifiers were able to be parsed. If this method returns null, an invalid identifier
+ * was encountered and reported with a toast: further execution must stop to let the user correct
+ * the problem.
+ */
+fun Context.getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile =
+ File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
+ ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
+ val gpgIdentifiers =
+ gpgIdentifierFile
+ .readLines()
+ .filter { it.isNotBlank() }
+ .map { line ->
+ PGPIdentifier.fromString(line)
+ ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id`
+ // file we created. Skip the validation so we can make the user add a
+ // real ID.
+ if (line.isEmpty()) return@run
+ // Apparently `gpg-id` being the first line is also acceptable?
+ if (line == "gpg-id") return@run
+ val message =
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id
+ else R.string.invalid_gpg_id
+ // A toast on the application context, not a snackbar: the autofill decryption
+ // finishes its window right after, and the toast must outlive it.
+ Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
+ return null
+ }
+ }
+ .filterIsInstance<PGPIdentifier>()
+ if (gpgIdentifiers.isEmpty()) {
+ error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}")
+ }
+ return gpgIdentifiers
+}
+
+@Suppress("ReturnCount")
+private fun File.findTillRoot(fileName: String, rootPath: File): File? {
+ val gpgFile = File(this, fileName)
+ if (gpgFile.exists()) return gpgFile
+
+ if (this.absolutePath == rootPath.absolutePath) {
+ return null
+ }
+ val parent = parentFile
+ return if (parent != null && parent.exists()) {
+ parent.findTillRoot(fileName, rootPath)
+ } else {
+ null
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
deleted file mode 100644
index 8b2109bf..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.Intent
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.core.content.edit
-import androidx.fragment.app.setFragmentResultListener
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.Application.Companion.screenWasOff
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.data.password.FieldItem
-import app.passwordstore.databinding.DecryptLayoutBinding
-import app.passwordstore.ui.adapters.FieldItemAdapter
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
-import app.passwordstore.util.features.Features
-import app.passwordstore.util.settings.Constants
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.ByteArrayOutputStream
-import java.io.File
-import javax.inject.Inject
-import kotlin.time.Duration.Companion.seconds
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@AndroidEntryPoint
-class DecryptActivity : BasePGPActivity() {
-
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
- @Inject lateinit var passphraseCache: PGPPassphraseCache
- @Inject lateinit var features: Features
-
- private val binding by viewBinding(DecryptLayoutBinding::inflate)
- private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) }
- private var passwordEntry: PasswordEntry? = null
- private var retries = 0
- private var clearCache = true
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title = name
- with(binding) {
- setContentView(root)
- passwordCategory.text = relativeParentPath
- passwordFile.text = name
- passwordFile.setOnLongClickListener {
- copyTextToClipboard(name)
- true
- }
- }
- if (
- features.isEnabled(EnablePGPPassphraseCache) &&
- BiometricAuthenticator.canAuthenticate(this@DecryptActivity)
- ) {
- BiometricAuthenticator.authenticate(
- this@DecryptActivity,
- R.string.biometric_prompt_title_gpg_passphrase_cache,
- ) { authResult ->
- requireKeysExist { decrypt(isError = false, authResult) }
- }
- } else {
- requireKeysExist { decrypt(isError = false, BiometricResult.CanceledByUser) }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler, menu)
- passwordEntry?.let { entry ->
- menu.findItem(R.id.edit_password).isVisible = true
- if (!entry.password.isNullOrBlank()) {
- menu.findItem(R.id.share_password_as_plaintext).isVisible = true
- menu.findItem(R.id.copy_password).isVisible = true
- }
- }
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> onBackPressedDispatcher.onBackPressed()
- R.id.edit_password -> editPassword()
- R.id.share_password_as_plaintext -> shareAsPlaintext()
- R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- /**
- * Automatically finishes the activity after [PreferenceKeys.GENERAL_SHOW_TIME] seconds decryption
- * succeeded to prevent information leaks from stale activities.
- */
- private fun startAutoDismissTimer() {
- lifecycleScope.launch {
- val timeout =
- settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
- ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
- if (timeout != 0) {
- delay(timeout.seconds)
- finish()
- }
- }
- }
-
- /**
- * Edit the current password and hide all the fields populated by encrypted data so that when the
- * result triggers they can be repopulated with new data.
- */
- private fun editPassword() {
- val intent = Intent(this, PasswordCreationActivity::class.java)
- intent.action = Intent.ACTION_VIEW
- intent.putExtra("FILE_PATH", relativeParentPath)
- intent.putExtra("REPO_PATH", repoPath)
- intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
- intent.putExtra(PasswordCreationActivity.EXTRA_USERNAME, passwordEntry?.username)
- intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
- intent.putExtra(
- PasswordCreationActivity.EXTRA_EXTRA_CONTENT,
- passwordEntry?.extraContentWithoutUsername,
- )
- intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
- startActivity(intent)
- finish()
- }
-
- private fun shareAsPlaintext() {
- val sendIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
- type = "text/plain"
- }
- // Always show a picker to give the user a chance to cancel
- startActivity(
- Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))
- )
- }
-
- private fun decrypt(isError: Boolean, authResult: BiometricResult) {
- val gpgIdentifiers = getPGPIdentifiers(relativeParentPath) ?: return
- lifecycleScope.launch(dispatcherProvider.main()) {
- when (authResult) {
- // Internally handled by the prompt dialog
- is BiometricResult.Retry -> {}
- // If the dialog is dismissed for any reason, prompt for passphrase
- is BiometricResult.CanceledByUser,
- is BiometricResult.CanceledBySystem,
- is BiometricResult.Failure,
- is BiometricResult.HardwareUnavailableOrDisabled ->
- askPassphrase(isError, gpgIdentifiers, authResult)
- is BiometricResult.Success -> {
- /* clear passphrase cache on first use after application startup or if screen was off;
- also make sure to purge a stale cache after caching has been disabled via PGP settings */
- clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
- if (screenWasOff && clearCache) {
- passphraseCache.clearAllCachedPassphrases(this@DecryptActivity)
- screenWasOff = false
- }
- val cachedPassphrase =
- passphraseCache.retrieveCachedPassphrase(this@DecryptActivity, gpgIdentifiers.first())
- if (cachedPassphrase != null) {
- decryptWithPassphrase(cachedPassphrase, gpgIdentifiers, authResult)
- } else {
- askPassphrase(isError, gpgIdentifiers, authResult)
- }
- }
- }
- }
- }
-
- private suspend fun askPassphrase(
- isError: Boolean,
- gpgIdentifiers: List<PGPIdentifier>,
- authResult: BiometricResult,
- ) {
- if (retries < MAX_RETRIES) {
- retries += 1
- } else {
- finish()
- }
- if (!repository.isPasswordProtected(gpgIdentifiers)) {
- decryptWithPassphrase(passphrase = "", gpgIdentifiers, authResult)
- return
- }
- val dialog =
- PasswordDialog.newInstance(
- cacheEnabled = features.isEnabled(EnablePGPPassphraseCache),
- clearCache = clearCache,
- )
- if (isError) {
- dialog.setError()
- }
- dialog.show(supportFragmentManager, "PASSWORD_DIALOG")
- dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle ->
- if (key == PasswordDialog.PASSWORD_RESULT_KEY) {
- val passphrase = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!!
- clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY)
- lifecycleScope.launch(dispatcherProvider.main()) {
- decryptWithPassphrase(passphrase, gpgIdentifiers, authResult) {
- runCatching {
- if (authResult is BiometricResult.Success) {
- passphraseCache.cachePassphrase(
- this@DecryptActivity,
- gpgIdentifiers.first(),
- passphrase,
- )
- settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) }
- }
- }
- .onFailure { e -> logcat { e.asLog() } }
- }
- }
- }
- }
- }
-
- private suspend fun decryptWithPassphrase(
- passphrase: String,
- identifiers: List<PGPIdentifier>,
- authResult: BiometricResult,
- onSuccess: suspend () -> Unit = {},
- ) {
- val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() }
- val outputStream = ByteArrayOutputStream()
- val result = repository.decrypt(passphrase, identifiers, message, outputStream)
- if (result.isOk) {
- val entry = passwordEntryFactory.create(result.value.toByteArray())
- passwordEntry = entry
- createPasswordUI(entry)
- startAutoDismissTimer()
- onSuccess()
- } else {
- logcat(ERROR) { result.error.stackTraceToString() }
- decrypt(isError = true, authResult = authResult)
- }
- }
-
- private suspend fun createPasswordUI(entry: PasswordEntry) =
- withContext(dispatcherProvider.main()) {
- val labelFormat = resources.getString(R.string.otp_label_format)
- val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
- invalidateOptionsMenu()
-
- val items = arrayListOf<FieldItem>()
- if (!entry.password.isNullOrBlank()) {
- items.add(FieldItem.createPasswordField(getString(R.string.password), entry.password!!))
- if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
- copyPasswordToClipboard(entry.password)
- }
- }
-
- if (entry.hasTotp()) {
- items.add(FieldItem.createOtpField(labelFormat, entry.totp.first()))
- }
-
- if (!entry.username.isNullOrBlank()) {
- items.add(FieldItem.createUsernameField(getString(R.string.username), entry.username!!))
- }
-
- entry.extraContent.forEach { (key, value) ->
- items.add(FieldItem.createFreeformField(key, value))
- }
-
- val adapter = FieldItemAdapter(items, showPassword) { text -> copyTextToClipboard(text) }
- binding.recyclerView.adapter = adapter
- binding.recyclerView.itemAnimator = null
-
- if (entry.hasTotp()) {
- lifecycleScope.launch { entry.totp.collect { adapter.updateOTPCode(it, labelFormat) } }
- }
- }
-
- private companion object {
- private const val MAX_RETRIES = 3
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt
deleted file mode 100644
index d8610307..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/EditPasswordScreen.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.PasswordField
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.ui.compose.theme.SpacingMedium
-import app.passwordstore.util.time.UserClock
-import app.passwordstore.util.totp.UriTotpFinder
-
-/** Composable to show allow editing an existing [PasswordEntry]. */
-@Composable
-fun EditPasswordScreen(
- entryName: String,
- entry: PasswordEntry,
- onNavigateUp: () -> Unit,
- @Suppress("UNUSED_PARAMETER") onSave: (PasswordEntry) -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- topBar = {
- APSAppBar(
- title = entryName,
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = onNavigateUp,
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- }
- ) { paddingValues ->
- Box(modifier = modifier.padding(paddingValues)) {
- Column(
- modifier =
- Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize()
- ) {
- if (entry.password != null) {
- PasswordField(
- value = entry.password!!,
- label = stringResource(R.string.password),
- initialVisibility = false,
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- ExtraContent(entry = entry)
- }
- }
- }
-}
-
-@Composable
-private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) {
- TextField(
- value = entry.extraContentString,
- onValueChange = {},
- label = { Text("Extra content") },
- modifier = modifier.fillMaxWidth(),
- )
-}
-
-@Preview
-@Composable
-private fun EditPasswordScreenPreview() {
- APSTheme {
- EditPasswordScreen(
- entryName = "Test Entry",
- entry = createTestEntry(),
- onNavigateUp = {},
- onSave = {},
- )
- }
-}
-
-private fun createTestEntry() =
- PasswordEntry(
- UserClock(),
- UriTotpFinder(),
- """
- |My Password
- |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
- |login: msfjarvis
- |URL: example.com
- """
- .trimMargin()
- .encodeToByteArray(),
- )
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
deleted file mode 100644
index 7652c98e..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
+++ /dev/null
@@ -1,510 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.graphics.Bitmap
-import android.graphics.ImageDecoder
-import android.os.Build
-import android.os.Bundle
-import android.provider.MediaStore
-import android.text.InputType
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.core.content.edit
-import androidx.core.view.isVisible
-import androidx.core.widget.doAfterTextChanged
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.databinding.PasswordCreationActivityBinding
-import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment
-import app.passwordstore.ui.dialogs.OtpImportDialogFragment
-import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment
-import app.passwordstore.util.autofill.AutofillPreferences
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.isInsideRepository
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.DirectoryStructure
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.onSuccess
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.zxing.BinaryBitmap
-import com.google.zxing.LuminanceSource
-import com.google.zxing.RGBLuminanceSource
-import com.google.zxing.common.HybridBinarizer
-import com.google.zxing.integration.android.IntentIntegrator
-import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
-import com.google.zxing.qrcode.QRCodeReader
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.ByteArrayOutputStream
-import java.io.IOException
-import java.nio.file.Paths
-import javax.inject.Inject
-import kotlin.io.path.absolutePathString
-import kotlin.io.path.createDirectories
-import kotlin.io.path.deleteIfExists
-import kotlin.io.path.exists
-import kotlin.io.path.isSameFileAs
-import kotlin.io.path.nameWithoutExtension
-import kotlin.io.path.pathString
-import kotlin.io.path.relativeTo
-import kotlin.io.path.writeBytes
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@AndroidEntryPoint
-class PasswordCreationActivity : BasePGPActivity() {
-
- private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
- @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
-
- private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
- private val suggestedUsername by unsafeLazy { intent.getStringExtra(EXTRA_USERNAME) }
- private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) }
- private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
- private val shouldGeneratePassword by unsafeLazy {
- intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
- }
- private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
- private var oldCategory: String? = null
- private var copy: Boolean = false
-
- private val otpImportAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- binding.otpImportButton.isVisible = false
- val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
- val contents = "${intentResult.contents}\n"
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- snackbar(message = getString(R.string.otp_import_success))
- } else {
- snackbar(message = getString(R.string.otp_import_failure_generic))
- }
- }
-
- private val imageImportAction =
- registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri ->
- if (imageUri == null) {
- snackbar(message = getString(R.string.otp_import_failure_no_selection))
- return@registerForActivityResult
- }
- val bitmap =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri))
- .copy(Bitmap.Config.ARGB_8888, true)
- } else {
- @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
- }
- val intArray = IntArray(bitmap.width * bitmap.height)
- // copy pixel data from the Bitmap into the 'intArray' array
- bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
- val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
- val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
-
- val reader = QRCodeReader()
- runCatching {
- val result = reader.decode(binaryBitmap)
- val text = result.text
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$text")
- else binding.extraContent.append(text)
- snackbar(message = getString(R.string.otp_import_success))
- binding.otpImportButton.isVisible = false
- }
- .onFailure { snackbar(message = getString(R.string.otp_import_failure_generic)) }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title =
- if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
- with(binding) {
- setContentView(root)
- generatePassword.setOnClickListener { generatePassword() }
- otpImportButton.setOnClickListener {
- supportFragmentManager.setFragmentResultListener(
- OTP_RESULT_REQUEST_KEY,
- this@PasswordCreationActivity,
- ) { requestKey, bundle ->
- if (requestKey == OTP_RESULT_REQUEST_KEY) {
- val contents = bundle.getString(RESULT)
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else binding.extraContent.append(contents)
- }
- }
- val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
- if (hasCamera) {
- val items =
- arrayOf(
- getString(R.string.otp_import_qr_code),
- getString(R.string.otp_import_from_file),
- getString(R.string.otp_import_manual_entry),
- )
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setItems(items) { _, index ->
- when (index) {
- 0 ->
- otpImportAction.launch(
- IntentIntegrator(this@PasswordCreationActivity)
- .setOrientationLocked(false)
- .setBeepEnabled(false)
- .setDesiredBarcodeFormats(QR_CODE)
- .createScanIntent()
- )
- 1 -> imageImportAction.launch("image/*")
- 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
- .show()
- } else {
- OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
-
- directoryInputLayout.apply {
- if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
- isEnabled = true
- } else {
- setBackgroundColor(getColor(android.R.color.transparent))
- }
- val path = getRelativePath(fullPath, repoPath)
- // Keep empty path field visible if it is editable.
- if (path.isEmpty() && !isEnabled) visibility = View.GONE
- else {
- directory.setText(path)
- oldCategory = path
- }
- }
- if (suggestedName != null) {
- filename.setText(suggestedName)
- } else {
- filename.requestFocus()
- }
-
- if (
- AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
- DirectoryStructure.EncryptedUsername || suggestedUsername != null
- ) {
- usernameInputLayout.visibility = View.VISIBLE
- if (suggestedUsername != null) username.setText(suggestedUsername)
- else if (suggestedName != null) username.requestFocus()
- }
-
- // Allow the user to quickly switch between storing the username as the filename or
- // in the encrypted extras. This only makes sense if the directory structure is
- // FileBased.
- if (
- suggestedName == null &&
- AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
- DirectoryStructure.FileBased
- ) {
- encryptUsername.apply {
- visibility = View.VISIBLE
- setOnClickListener {
- if (isChecked) {
- // User wants to enable username encryption, so we use the filename
- // as username and insert it into the username input field.
- val login = filename.text.toString()
- filename.text?.clear()
- username.setText(login)
- usernameInputLayout.apply { visibility = View.VISIBLE }
- } else {
- // User wants to disable username encryption, so we take the username
- // from the username text field and insert it into the filename input field.
- val login = username.text.toString()
- username.text?.clear()
- filename.setText(login)
- usernameInputLayout.apply { visibility = View.GONE }
- }
- }
- }
- }
- suggestedPass?.let {
- password.setText(it)
- password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- }
- suggestedExtra?.let { extraContent.setText(it) }
- if (shouldGeneratePassword) {
- generatePassword()
- password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
- }
- }
- listOf(binding.filename, binding.username, binding.extraContent).forEach {
- it.doAfterTextChanged { updateViewState() }
- }
- updateViewState()
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressedDispatcher.onBackPressed()
- }
- R.id.save_password -> {
- copy = false
- requireKeysExist { encrypt() }
- }
- R.id.save_and_copy_password -> {
- copy = true
- requireKeysExist { encrypt() }
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun generatePassword() {
- supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
- requestKey,
- bundle ->
- if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
- binding.password.setText(bundle.getString(RESULT))
- }
- }
- when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_DICEWARE) {
- KEY_PWGEN_TYPE_CLASSIC ->
- PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_DICEWARE ->
- DicewarePasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
- }
- }
-
- private fun updateViewState() =
- with(binding) {
- encryptUsername.apply {
- if (visibility != View.VISIBLE) return@apply
- val hasUsernameInFileName = filename.text.toString().isNotBlank()
- val usernameIsEncrypted = username.text.toString().isNotEmpty()
- isEnabled = hasUsernameInFileName xor usernameIsEncrypted
- isChecked = usernameIsEncrypted
- }
- // Use PasswordEntry to parse extras for OTP
- val entry =
- passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
- otpImportButton.isVisible = !entry.hasTotp()
- }
-
- /** Encrypts the password and the extra content */
- private fun encrypt() {
- with(binding) {
- val oldName = suggestedName
- val editName = filename.text.toString().trim()
- var editUsername = username.text.toString()
- val editPass = password.text.toString()
- val editExtra = extraContent.text.toString()
-
- if (editName.isEmpty()) {
- snackbar(message = resources.getString(R.string.file_toast_text))
- return@with
- } else if (editName.contains('/')) {
- snackbar(message = resources.getString(R.string.invalid_filename_text))
- return@with
- }
-
- if (!editUsername.isEmpty()) {
- editUsername = "\nusername: $editUsername"
- }
-
- if (editPass.isEmpty() && editExtra.isEmpty()) {
- snackbar(message = resources.getString(R.string.empty_toast_text))
- return@with
- }
-
- if (copy) {
- copyPasswordToClipboard(editPass)
- }
-
- // pass enters the key ID into `.gpg-id`.
- val gpgIdentifiers = getPGPIdentifiers(directory.text.toString()) ?: return@with
- val content = "$editPass$editUsername\n$editExtra"
- val path =
- when {
- // If we allowed the user to edit the relative path, we have to consider it here
- // instead of fullPath.
- directoryInputLayout.isEnabled -> {
- val editRelativePath = directory.text.toString().trim()
- if (editRelativePath.isEmpty()) {
- snackbar(message = resources.getString(R.string.path_toast_text))
- return
- }
- val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/'))
- passwordDirectory.createDirectories()
- if (!passwordDirectory.exists()) {
- snackbar(
- message =
- "Failed to create directory ${passwordDirectory.relativeTo(Paths.get(repoPath)).pathString}"
- )
- return
- }
-
- "${passwordDirectory.pathString}/$editName.gpg"
- }
- else -> "$fullPath/$editName.gpg"
- }
-
- lifecycleScope.launch(dispatcherProvider.main()) {
- runCatching {
- val result =
- withContext(dispatcherProvider.io()) {
- val outputStream = ByteArrayOutputStream()
- repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream)
- }
- .getOrElse { e ->
- logcat(ERROR) { e.asLog("Failed to encrypt password file") }
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(R.string.password_creation_file_fail_title)
- .setMessage(
- getString(R.string.password_creation_encrypt_fail_message, e.message)
- )
- .setPositiveButton(android.R.string.ok, null)
- .show()
- return@runCatching
- }
- val passwordFile = Paths.get(path)
- // If we're not editing, this file should not already exist!
- // Additionally, if we were editing and the incoming and outgoing
- // filenames differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if (
- (!editing || (editing && suggestedName != passwordFile.nameWithoutExtension)) &&
- passwordFile.exists()
- ) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@runCatching
- }
-
- if (!passwordFile.toFile().isInsideRepository()) {
- snackbar(message = getString(R.string.message_error_destination_outside_repo))
- return@runCatching
- }
-
- withContext(dispatcherProvider.io()) { passwordFile.writeBytes(result.toByteArray()) }
-
- // associate the new password name with the last name's timestamp in history
- val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$suggestedName.gpg".base64()
- val timestamp = preference.getString(oldFilePathHash)
- if (timestamp != null) {
- preference.edit {
- remove(oldFilePathHash)
- putString(passwordFile.absolutePathString().base64(), timestamp)
- }
- }
-
- val returnIntent = Intent()
- returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
- returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
- returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
-
- if (shouldGeneratePassword) {
- val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
- val entry = passwordEntryFactory.create(content.encodeToByteArray())
- returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
- val username =
- entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile())
- returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
- }
-
- if (
- directoryInputLayout.isVisible &&
- directoryInputLayout.isEnabled &&
- oldName != editName
- ) {
- val oldPath = Paths.get(repoPath, oldCategory?.trim('/') ?: "", "$oldName.gpg")
- if (!oldPath.isSameFileAs(passwordFile) && !oldPath.deleteIfExists()) {
- setResult(RESULT_CANCELED)
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(R.string.password_creation_file_fail_title)
- .setMessage(
- getString(R.string.password_creation_file_delete_fail_message, oldName)
- )
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .show()
- return@runCatching
- }
- }
-
- val commitMessageRes =
- if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
- lifecycleScope.launch {
- commitChange(
- resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName))
- )
- .onSuccess {
- setResult(RESULT_OK, returnIntent)
- finish()
- }
- }
- }
- .onFailure { e ->
- if (e is IOException) {
- logcat(ERROR) { e.asLog("Failed to write password file") }
- setResult(RESULT_CANCELED)
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setTitle(getString(R.string.password_creation_file_fail_title))
- .setMessage(getString(R.string.password_creation_file_write_fail_message))
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .show()
- } else {
- logcat(ERROR) { e.asLog() }
- }
- }
- }
- }
- }
-
- companion object {
-
- private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
- private const val KEY_PWGEN_TYPE_DICEWARE = "diceware"
- const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
- const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
- const val RESULT = "RESULT"
- const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
- const val RETURN_EXTRA_NAME = "NAME"
- const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
- const val RETURN_EXTRA_USERNAME = "USERNAME"
- const val RETURN_EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_FILE_NAME = "FILENAME"
- const val EXTRA_USERNAME = "USERNAME"
- const val EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
- const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
- const val EXTRA_EDITING = "EDITING"
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt
deleted file mode 100644
index d845d689..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import android.app.Dialog
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.core.view.isVisible
-import androidx.core.widget.doOnTextChanged
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.R
-import app.passwordstore.databinding.DialogPasswordEntryBinding
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.unsafeLazy
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-/** [DialogFragment] to request a password from the user and forward it along. */
-class PasswordDialog : DialogFragment() {
-
- private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) }
- private var isError: Boolean = false
- private var clearCacheChecked: Boolean = true
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(binding.root)
- builder.setTitle(R.string.password)
-
- val cacheEnabled = requireArguments().getBoolean(CACHE_ENABLED_EXTRA, false)
- binding.autoClearCache.isVisible = cacheEnabled
-
- if (cacheEnabled) {
- clearCacheChecked = requireArguments().getBoolean(AUTO_CLEAR_CACHE_EXTRA)
- binding.autoClearCache.isChecked = clearCacheChecked
- binding.autoClearCache.setOnCheckedChangeListener { _, isChecked ->
- clearCacheChecked = isChecked
- }
- }
-
- builder.setPositiveButton(android.R.string.ok) { _, _ -> setPasswordAndDismiss() }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.setOnShowListener {
- if (isError) {
- binding.passwordField.error = getString(R.string.git_operation_wrong_password)
- }
- binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null }
- binding.passwordEditText.setOnKeyListener { _, keyCode, _ ->
- if (keyCode == KeyEvent.KEYCODE_ENTER) {
- setPasswordAndDismiss()
- return@setOnKeyListener true
- }
- false
- }
- }
- dialog.window?.setFlags(
- WindowManager.LayoutParams.FLAG_SECURE,
- WindowManager.LayoutParams.FLAG_SECURE,
- )
- return dialog
- }
-
- fun setError() {
- isError = true
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- finish()
- }
-
- private fun setPasswordAndDismiss() {
- val password = binding.passwordEditText.text.toString()
- setFragmentResult(
- PASSWORD_RESULT_KEY,
- bundleOf(PASSWORD_PHRASE_KEY to password, PASSWORD_CLEAR_KEY to clearCacheChecked),
- )
- dismissAllowingStateLoss()
- }
-
- companion object {
-
- private const val CACHE_ENABLED_EXTRA = "CACHE_ENABLED"
- private const val AUTO_CLEAR_CACHE_EXTRA = "AUTO_CLEAR_CACHE"
-
- const val PASSWORD_RESULT_KEY = "password_result"
- const val PASSWORD_PHRASE_KEY = "password_phrase"
- const val PASSWORD_CLEAR_KEY = "password_clear"
-
- fun newInstance(cacheEnabled: Boolean, clearCache: Boolean): PasswordDialog {
- val extras =
- bundleOf(CACHE_ENABLED_EXTRA to cacheEnabled, AUTO_CLEAR_CACHE_EXTRA to clearCache)
- val fragment = PasswordDialog()
- fragment.arguments = extras
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt b/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt
deleted file mode 100644
index f2a67706..00000000
--- a/app/src/main/java/app/passwordstore/ui/crypto/ViewPasswordScreen.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.crypto
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.capitalize
-import androidx.compose.ui.text.intl.Locale
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.data.passfile.PasswordEntry
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.CopyButton
-import app.passwordstore.ui.compose.PasswordField
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.ui.compose.theme.SpacingMedium
-import app.passwordstore.util.time.UserClock
-import app.passwordstore.util.totp.UriTotpFinder
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-
-/** Composable to show a decrypted [PasswordEntry]. */
-@Composable
-fun ViewPasswordScreen(
- entryName: String,
- entry: PasswordEntry,
- onNavigateUp: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Scaffold(
- topBar = {
- APSAppBar(
- title = entryName,
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = onNavigateUp,
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- }
- ) { paddingValues ->
- Box(modifier = modifier.padding(paddingValues)) {
- Column(
- modifier =
- Modifier.padding(vertical = SpacingMedium, horizontal = SpacingLarge).fillMaxSize()
- ) {
- if (entry.password != null) {
- PasswordField(
- value = entry.password!!,
- label = stringResource(R.string.password),
- initialVisibility = false,
- readOnly = true,
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- if (entry.hasTotp()) {
- val totp by entry.totp.collectAsState(runBlocking { entry.totp.first() })
- TextField(
- value = totp.value,
- onValueChange = {},
- readOnly = true,
- label = { Text("OTP (expires in ${totp.remainingTime.inWholeSeconds}s)") },
- trailingIcon = { CopyButton(totp.value, R.string.copy_label) },
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- if (entry.username != null) {
- TextField(
- value = entry.username!!,
- onValueChange = {},
- readOnly = true,
- label = { Text(stringResource(R.string.username)) },
- trailingIcon = { CopyButton(entry.username!!, R.string.copy_label) },
- modifier = Modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
- ExtraContent(entry = entry)
- }
- }
- }
-}
-
-@Composable
-private fun ExtraContent(entry: PasswordEntry, modifier: Modifier = Modifier) {
- entry.extraContent.forEach { (label, value) ->
- TextField(
- value = value,
- onValueChange = {},
- readOnly = true,
- label = { Text(label.capitalize(Locale.current)) },
- trailingIcon = { CopyButton(value, R.string.copy_label) },
- modifier = modifier.padding(bottom = SpacingMedium).fillMaxWidth(),
- )
- }
-}
-
-@Preview
-@Composable
-private fun ViewPasswordScreenPreview() {
- APSTheme {
- ViewPasswordScreen(entryName = "Test Entry", entry = createTestEntry(), onNavigateUp = {})
- }
-}
-
-private fun createTestEntry() =
- PasswordEntry(
- UserClock(),
- UriTotpFinder(),
- """
- |My Password
- |otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
- |login: msfjarvis
- |URL: example.com
- """
- .trimMargin()
- .encodeToByteArray(),
- )
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt
deleted file mode 100644
index e689f3bd..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/BasicBottomSheet.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.dialogs
-
-import android.content.Context
-import android.content.DialogInterface.OnDismissListener
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-import androidx.annotation.StringRes
-import androidx.core.view.isVisible
-import app.passwordstore.R
-import app.passwordstore.databinding.BasicBottomSheetBinding
-import app.passwordstore.util.extensions.viewBinding
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-
-/**
- * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
- * through [BasicBottomSheet.Builder] to create a similar UI, just at the bottom of the screen.
- */
-class BasicBottomSheet
-private constructor(
- val title: String?,
- val message: String,
- val positiveButtonLabel: String?,
- val negativeButtonLabel: String?,
- val positiveButtonClickListener: View.OnClickListener?,
- val negativeButtonClickListener: View.OnClickListener?,
- val onDismissListener: OnDismissListener?,
-) : BottomSheetDialogFragment() {
-
- private val binding by viewBinding(BasicBottomSheetBinding::bind)
-
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback =
- object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {}
-
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
- }
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View? {
- if (savedInstanceState != null) dismiss()
- return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(
- object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- if (!title.isNullOrEmpty()) {
- binding.bottomSheetTitle.isVisible = true
- binding.bottomSheetTitle.text = title
- }
- binding.bottomSheetMessage.text = message
- if (positiveButtonClickListener != null) {
- positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
- binding.bottomSheetOkButton.isVisible = true
- binding.bottomSheetOkButton.setOnClickListener {
- positiveButtonClickListener.onClick(it)
- dismiss()
- }
- }
- if (negativeButtonClickListener != null) {
- binding.bottomSheetCancelButton.isVisible = true
- negativeButtonLabel?.let { buttonLbl ->
- binding.bottomSheetCancelButton.text = buttonLbl
- }
- binding.bottomSheetCancelButton.setOnClickListener {
- negativeButtonClickListener.onClick(it)
- dismiss()
- }
- }
- if (onDismissListener != null) {
- dialog.setOnDismissListener(onDismissListener)
- }
- }
- }
- )
- }
-
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
-
- class Builder(val context: Context) {
-
- private var title: String? = null
- private var message: String? = null
- private var positiveButtonLabel: String? = null
- private var negativeButtonLabel: String? = null
- private var positiveButtonClickListener: View.OnClickListener? = null
- private var negativeButtonClickListener: View.OnClickListener? = null
- private var onDismissListener: OnDismissListener? = null
-
- fun setTitleRes(@StringRes titleRes: Int): Builder {
- this.title = context.resources.getString(titleRes)
- return this
- }
-
- fun setTitle(title: String): Builder {
- this.title = title
- return this
- }
-
- fun setMessageRes(@StringRes messageRes: Int): Builder {
- this.message = context.resources.getString(messageRes)
- return this
- }
-
- fun setMessage(message: String): Builder {
- this.message = message
- return this
- }
-
- fun setPositiveButtonClickListener(
- buttonLabel: String? = null,
- listener: View.OnClickListener,
- ): Builder {
- this.positiveButtonClickListener = listener
- this.positiveButtonLabel = buttonLabel
- return this
- }
-
- fun setNegativeButtonClickListener(
- buttonLabel: String? = null,
- listener: View.OnClickListener,
- ): Builder {
- this.negativeButtonClickListener = listener
- this.negativeButtonLabel = buttonLabel
- return this
- }
-
- fun setOnDismissListener(onDismissListener: OnDismissListener): Builder {
- this.onDismissListener = onDismissListener
- return this
- }
-
- fun build(): BasicBottomSheet {
- require(message != null) { "Message needs to be set" }
- return BasicBottomSheet(
- title,
- message!!,
- positiveButtonLabel,
- negativeButtonLabel,
- positiveButtonClickListener,
- negativeButtonClickListener,
- onDismissListener,
- )
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
deleted file mode 100644
index 2605e0ef..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/DicewarePasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.dialogs
-
-import android.app.AlertDialog
-import android.app.Dialog
-import android.content.SharedPreferences
-import android.graphics.Typeface
-import android.os.Bundle
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentPwgenDicewareBinding
-import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
-import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_LENGTH
-import app.passwordstore.util.settings.PreferenceKeys.DICEWARE_SEPARATOR
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.launch
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-
-@AndroidEntryPoint
-class DicewarePasswordGeneratorDialogFragment : DialogFragment() {
-
- @Inject lateinit var dicewareGenerator: DicewarePassphraseGenerator
- @Inject @PasswordGeneratorPreferences lateinit var prefs: SharedPreferences
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenDicewareBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.passwordSeparatorText.setText(prefs.getString(DICEWARE_SEPARATOR) ?: "-")
- binding.passwordLengthText.setText(prefs.getInt(DICEWARE_LENGTH, 5).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- lifecycleScope.launch {
- merge(
- binding.passwordLengthText.afterTextChanges(),
- binding.passwordSeparatorText.afterTextChanges(),
- )
- .collect { _ -> generatePassword(binding) }
- }
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"),
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generatePassword(binding)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generatePassword(binding) }
- }
- }
- }
-
- private fun generatePassword(binding: FragmentPwgenDicewareBinding) {
- val length = binding.passwordLengthText.text?.toString()?.toIntOrNull() ?: 5
- val separator = binding.passwordSeparatorText.text?.toString()?.getOrNull(0) ?: '-'
- setPreferences(length, separator)
- binding.passwordText.text = dicewareGenerator.generatePassphrase(length, separator)
- }
-
- private fun setPreferences(length: Int, separator: Char) {
- prefs.edit {
- putInt(DICEWARE_LENGTH, length)
- putString(DICEWARE_SEPARATOR, separator.toString())
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt
deleted file mode 100644
index 669e126e..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.dialogs
-
-import android.app.Dialog
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.appcompat.app.AlertDialog
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import app.passwordstore.R
-import app.passwordstore.ui.passwords.PasswordStore
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
-import java.io.File
-
-class FolderCreationDialogFragment : DialogFragment() {
-
- private lateinit var newFolder: File
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
- alertDialogBuilder.setTitle(R.string.title_create_folder)
- alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
- alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
- alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
- val dialog = alertDialogBuilder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.setOnShowListener {
- dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
- createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
- }
- }
- return dialog
- }
-
- private fun createDirectory(currentDir: String) {
- val dialog = requireDialog()
- val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
- val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
- newFolder = File("$currentDir/${folderNameView.text}")
- folderNameViewContainer.error =
- when {
- newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
- newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
- else -> null
- }
- if (folderNameViewContainer.error != null) return
- newFolder.mkdirs()
- (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
- // TODO(msfjarvis): Restore this functionality
- /*
- if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
- keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
- return
- } else {
- dismiss()
- }
- */
- dismiss()
- }
-
- companion object {
-
- private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
-
- fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
- val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
- val fragment = FolderCreationDialogFragment()
- fragment.arguments = extras
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt
deleted file mode 100644
index 0304b16c..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/ItemCreationBottomSheet.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.dialogs
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-import androidx.core.os.bundleOf
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.R
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_KEY
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD
-import app.passwordstore.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-
-class ItemCreationBottomSheet : BottomSheetDialogFragment() {
-
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback =
- object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {}
-
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
- }
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?,
- ): View? {
- if (savedInstanceState != null) dismiss()
- return inflater.inflate(R.layout.item_create_sheet, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(
- object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
- dismiss()
- }
- dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
- dismiss()
- }
- }
- }
- )
- }
-
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt
deleted file mode 100644
index 1a75175b..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/OtpImportDialogFragment.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.dialogs
-
-import android.app.Dialog
-import android.net.Uri
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.databinding.FragmentManualOtpEntryBinding
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class OtpImportDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
- builder.setView(binding.root)
- builder.setPositiveButton(android.R.string.ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding)),
- )
- }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- return dialog
- }
-
- private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
- val secret = binding.secret.text.toString()
- val account = binding.account.text.toString()
- if (secret.isBlank()) return ""
- val builder = Uri.Builder()
- builder.scheme("otpauth")
- builder.authority("totp")
- builder.appendQueryParameter("secret", secret)
- if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
- return builder.build().toString()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
deleted file mode 100644
index d4a2372b..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.dialogs
-
-import android.app.AlertDialog
-import android.app.Dialog
-import android.content.Context
-import android.graphics.Typeface
-import android.os.Bundle
-import android.widget.CheckBox
-import android.widget.EditText
-import android.widget.Toast
-import androidx.annotation.IdRes
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.core.content.edit
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentPwgenBinding
-import app.passwordstore.passgen.random.MaxIterationsExceededException
-import app.passwordstore.passgen.random.NoCharactersIncludedException
-import app.passwordstore.passgen.random.PasswordGenerator
-import app.passwordstore.passgen.random.PasswordLengthTooShortException
-import app.passwordstore.passgen.random.PasswordOption
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.launch
-import reactivecircus.flowbinding.android.widget.afterTextChanges
-import reactivecircus.flowbinding.android.widget.checkedChanges
-
-class PasswordGeneratorDialogFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val prefs = requireContext().getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
- val builder = MaterialAlertDialogBuilder(requireContext())
-
- val binding = FragmentPwgenBinding.inflate(layoutInflater)
- builder.setView(binding.root)
-
- binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
- binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
- binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
- binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
- binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
- binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
- binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
- binding.passwordText.typeface = Typeface.MONOSPACE
-
- lifecycleScope.launch {
- merge(
- binding.numerals.checkedChanges().skipInitialValue(),
- binding.symbols.checkedChanges().skipInitialValue(),
- binding.uppercase.checkedChanges().skipInitialValue(),
- binding.lowercase.checkedChanges().skipInitialValue(),
- binding.ambiguous.checkedChanges().skipInitialValue(),
- binding.pronounceable.checkedChanges().skipInitialValue(),
- binding.lengthNumber.afterTextChanges().skipInitialValue(),
- )
- .collect { generate(binding.passwordText) }
- }
-
- return builder
- .run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}"),
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }
- .apply {
- setOnShowListener {
- generate(binding.passwordText)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
- generate(binding.passwordText)
- }
- }
- }
- }
-
- private fun generate(passwordField: AppCompatTextView) {
- val passwordOptions = getSelectedOptions()
- val passwordLength = getLength()
- setPrefs(requireContext(), passwordOptions, passwordLength)
- passwordField.text =
- runCatching { PasswordGenerator.generate(passwordOptions, passwordLength) }
- .getOrElse { exception ->
- val errorText =
- when (exception) {
- is MaxIterationsExceededException ->
- requireContext().getString(R.string.pwgen_max_iterations_exceeded)
- is NoCharactersIncludedException ->
- requireContext().getString(R.string.pwgen_no_chars_error)
- is PasswordLengthTooShortException ->
- requireContext().getString(R.string.pwgen_length_too_short_error)
- else -> requireContext().getString(R.string.pwgen_some_error_occurred)
- }
- Toast.makeText(requireActivity(), errorText, Toast.LENGTH_SHORT).show()
- ""
- }
- }
-
- private fun isChecked(@IdRes id: Int): Boolean {
- return requireDialog().findViewById<CheckBox>(id).isChecked
- }
-
- private fun getSelectedOptions(): List<PasswordOption> {
- return listOfNotNull(
- PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
- PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
- PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
- PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
- PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
- PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) },
- )
- }
-
- private fun getLength(): Int {
- val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
- return lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
- }
-
- /**
- * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for generated
- * passwords.
- */
- private fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
- ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
- for (possibleOption in PasswordOption.entries) {
- putBoolean(possibleOption.key, possibleOption in options)
- }
- putInt("length", targetLength)
- }
- return true
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt b/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt
deleted file mode 100644
index 58c39968..00000000
--- a/app/src/main/java/app/passwordstore/ui/dialogs/TextInputDialog.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.dialogs
-
-import android.app.Dialog
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.core.os.bundleOf
-import androidx.fragment.app.DialogFragment
-import androidx.fragment.app.setFragmentResult
-import app.passwordstore.databinding.DialogTextInputBinding
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.unsafeLazy
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-/**
- * General purpose [DialogFragment] that can be used to accept a single text input. It provides a
- * configurable title and text input hint through the instantiation helper at
- * [TextInputDialog.newInstance]. Typical usage would look something like this:
- * ```kotlin
- * val dialog = TextInputDialog.newInstance(getString(R.string.dialog_title), getString(R.string.dialog_hint))
- * dialog.show(supportFragmentManager, "text_input_dialog")
- * dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle ->
- * doSomething(bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT))
- * }
- * ```
- */
-class TextInputDialog : DialogFragment() {
- private val binding by unsafeLazy { DialogTextInputBinding.inflate(layoutInflater) }
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- builder.setView(binding.root)
- builder.setTitle(arguments?.getString(BUNDLE_KEY_TITLE))
- arguments?.getString(BUNDLE_KEY_HINT)?.let { hint ->
- binding.textInputLayout.isHintEnabled = true
- binding.textInputLayout.isHintAnimationEnabled = true
- binding.textInputLayout.hint = hint
- }
- builder.setPositiveButton(android.R.string.ok) { dialogInterface, _ ->
- setFragmentResult(REQUEST_KEY, bundleOf(BUNDLE_KEY_TEXT to binding.editText.text.toString()))
- dialogInterface.dismiss()
- }
- val dialog = builder.create()
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- return dialog
- }
-
- override fun onCancel(dialog: DialogInterface) {
- super.onCancel(dialog)
- finish()
- }
-
- companion object {
- const val REQUEST_KEY = "text_input_dialog"
- const val BUNDLE_KEY_TEXT = "text"
- private const val BUNDLE_KEY_TITLE = "title"
- private const val BUNDLE_KEY_HINT = "hint"
-
- fun newInstance(title: String, hint: String? = null): TextInputDialog {
- val args = bundleOf(BUNDLE_KEY_TITLE to title, BUNDLE_KEY_HINT to hint)
- val dialog = TextInputDialog()
- dialog.arguments = args
- return dialog
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
new file mode 100644
index 00000000..6d9f9c9f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.app.Activity
+import android.content.pm.PackageManager
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.GetContent
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.fragment.app.FragmentActivity
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.list.FolderPicker
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.snackbar.Snackbar
+import com.google.zxing.integration.android.IntentIntegrator
+import java.io.File
+import kotlinx.coroutines.launch
+
+/**
+ * The editor: wires [EditorScreen] to the [viewModel], the generator sheet, the folder picker, OTP
+ * import and the save path (checks, recipients from `.gpg-id`, encryption, write, git commit).
+ * [initial] fills the form the first time; null means the entry to edit is no longer in memory
+ * (process death) and the editor closes. [original] is the file being edited, null for a new entry.
+ * [generate] opens the generator at once (autofill's "generate password"). [onClose] leaves without
+ * saving; [onSaved] runs after the commit.
+ */
+@Composable
+fun EditorDestination(
+ activity: FragmentActivity,
+ initial: EditorForm?,
+ original: File?,
+ onClose: () -> Unit,
+ onSaved: (SaveResult.Saved) -> Unit,
+ modifier: Modifier = Modifier,
+ generate: Boolean = false,
+ viewModel: EditorViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // ponytail: filled once per ViewModel, so a recreated activity keeps the edits; an edit restored
+ // after process death has nothing to fill from (the entry view's plaintext is gone) and closes,
+ // back to the entry view's passphrase prompt.
+ val first = remember { initial != null && viewModel.start(initial) }
+ val lost = !viewModel.started
+ LaunchedEffect(lost) { if (lost) onClose() }
+ if (lost) return
+
+ val keyboard = LocalSoftwareKeyboardController.current
+ val focusManager = LocalFocusManager.current
+ val scope = rememberCoroutineScope()
+ var sheet by rememberSaveable { mutableStateOf(first && generate) }
+ var picker by rememberSaveable { mutableStateOf(false) }
+ var otp by rememberSaveable { mutableStateOf(false) }
+ var failure by remember { mutableStateOf<String?>(null) }
+ val form = viewModel.form
+ val hasOtp = remember(form.extra) { viewModel.hasOtp(form.extra) }
+ val hasCamera = remember {
+ activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
+ }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+ val classicFirst = remember {
+ activity.sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "classic"
+ }
+
+ fun say(message: Int) {
+ activity.snackbar(message = activity.getString(message))
+ }
+
+ fun addLine(line: String) {
+ viewModel.form = viewModel.form.copy(extra = withLine(viewModel.form.extra, line))
+ }
+
+ // The one way out without saving: only while no save runs, keyboard and focus dropped first.
+ fun close() {
+ if (viewModel.saving) return
+ keyboard?.hide()
+ focusManager.clearFocus()
+ onClose()
+ }
+
+ fun save(copy: Boolean) {
+ if (viewModel.saving) return
+ val current = viewModel.form
+ val target = entryFile(root, current)
+ val problem =
+ formError(current)
+ ?: R.string.message_error_destination_outside_repo.takeUnless {
+ target.isInsideRepository()
+ }
+ if (problem != null) return say(problem)
+ // Recipients from the nearest .gpg-id, as pass does; null: an invalid ID, already reported.
+ val ids =
+ activity.getPGPIdentifiers(target.parentFile?.relativeTo(root)?.path.orEmpty()) ?: return
+ activity.lifecycleScope.launch {
+ val result = viewModel.save(ids, target, original, activity, copy)
+ if (result is SaveResult.Failed) failure = activity.getString(result.message, result.arg)
+ }
+ }
+
+ // Finishes a save: run again by an editor recreated in the middle (a theme or locale change
+ // cancels this effect), so the commit and onSaved always happen; the copy and notice only once.
+ val saved = viewModel.saved
+ LaunchedEffect(saved) {
+ if (saved == null) return@LaunchedEffect
+ if (viewModel.announce()) {
+ if (viewModel.copyOnSave) activity.copyPasswordToClipboard(saved.form.password)
+ saved.staleOriginal?.let {
+ activity.snackbar(
+ message =
+ activity.getString(
+ R.string.password_creation_file_delete_fail_message,
+ it.nameWithoutExtension,
+ ),
+ length = Snackbar.LENGTH_LONG,
+ )
+ }
+ }
+ // ponytail: commit failures are not shown, as for the list's move and delete. A commit cut
+ // short by recreation runs again; one that already went through finds nothing to commit.
+ activity.commitChange(
+ activity.getString(
+ if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text,
+ saved.file.relativeTo(root).path.removeSuffix(".gpg"),
+ )
+ )
+ keyboard?.hide()
+ focusManager.clearFocus()
+ viewModel.left()
+ onSaved(saved)
+ }
+
+ BackHandler(onBack = ::close)
+
+ val scan =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ val text = IntentIntegrator.parseActivityResult(result.resultCode, result.data)?.contents
+ if (result.resultCode == Activity.RESULT_OK && text != null) {
+ addLine("$text\n")
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ val image =
+ rememberLauncherForActivityResult(GetContent()) { uri ->
+ if (uri == null) {
+ say(R.string.otp_import_failure_no_selection)
+ } else {
+ scope.launch {
+ val text = viewModel.readQr(uri, activity)
+ if (text != null) {
+ addLine(text)
+ say(R.string.otp_import_success)
+ } else {
+ say(R.string.otp_import_failure_generic)
+ }
+ }
+ }
+ }
+
+ Box(modifier) {
+ EditorScreen(
+ form = form,
+ editing = original != null,
+ saving = viewModel.saving,
+ showOtpActions = !hasOtp,
+ initiallyRevealed = showPassword,
+ focus =
+ when {
+ !first -> EditorFocus.None
+ form.name.isEmpty() -> EditorFocus.Name
+ original == null -> EditorFocus.Username
+ else -> EditorFocus.None
+ },
+ onFormChange = { viewModel.form = it },
+ onClose = ::close,
+ onSave = ::save,
+ onChangeFolder = { picker = true },
+ onGenerate = { sheet = true },
+ onScanQr =
+ if (hasCamera) {
+ {
+ scan.launch(
+ IntentIntegrator(activity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
+ .createScanIntent()
+ )
+ }
+ } else null,
+ onQrImage = { image.launch("image/*") },
+ onManualOtp = { otp = true },
+ )
+ if (sheet)
+ GeneratorSheet(
+ classicFirst = classicFirst,
+ random = viewModel.randomSpec(activity),
+ diceware = viewModel.dicewareSpec(),
+ onRandom = { spec -> viewModel.generateRandom(spec, activity) },
+ onDiceware = viewModel::generateDiceware,
+ onUse = { password ->
+ viewModel.form = viewModel.form.copy(password = password)
+ sheet = false
+ },
+ onDismiss = { sheet = false },
+ )
+ if (picker)
+ FolderPicker(
+ root = root,
+ start = form.dir,
+ onPick = { dir ->
+ viewModel.form = viewModel.form.copy(dir = dir)
+ picker = false
+ },
+ onDismiss = { picker = false },
+ )
+ if (otp)
+ OtpDialog(
+ onAdd = { secret, account ->
+ totpUri(secret, account)?.let(::addLine)
+ otp = false
+ },
+ onDismiss = { otp = false },
+ )
+ failure?.let { message ->
+ AlertDialog(
+ onDismissRequest = { failure = null },
+ title = { Text(stringResource(R.string.password_creation_file_fail_title)) },
+ text = { Text(message) },
+ confirmButton = {
+ TextButton(onClick = { failure = null }) { Text(stringResource(R.string.dialog_ok)) }
+ },
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
new file mode 100644
index 00000000..d7437df3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Immutable
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.MaxIterationsExceededException
+import app.passwordstore.passgen.random.NoCharactersIncludedException
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.passgen.random.PasswordLengthTooShortException
+import app.passwordstore.passgen.random.PasswordOption
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.runCatching
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name]
+ * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route.
+ */
+@Immutable
+data class EditorForm(
+ val name: String = "",
+ val dir: String = "",
+ val password: String = "",
+ val username: String = "",
+ val extra: String = "",
+)
+
+/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */
+fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm =
+ EditorForm(
+ name = name,
+ dir = dir,
+ password = entry.password.orEmpty(),
+ username = entry.username.orEmpty(),
+ extra = entry.extraContentWithoutUsername,
+ )
+
+/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */
+fun passContent(form: EditorForm): String {
+ val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}"
+ return "${form.password}$username\n${form.extra}"
+}
+
+/** Why [form] cannot be saved, as a string resource, or null when it can. */
+@StringRes
+fun formError(form: EditorForm): Int? =
+ when {
+ form.name.isBlank() -> R.string.file_toast_text
+ '/' in form.name -> R.string.invalid_filename_text
+ form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text
+ else -> null
+ }
+
+/** Where [form] is saved in the store at [root]. */
+fun entryFile(root: File, form: EditorForm): File =
+ File(File(root, form.dir), "${form.name.trim()}.gpg").toPath().normalize().toFile()
+
+/** True when saving to [target] would replace a file other than the [original] being edited. */
+fun overwrites(target: File, original: File?): Boolean =
+ target.exists() && target.canonicalPath != original?.canonicalPath
+
+/**
+ * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a
+ * failed write never truncates an existing entry.
+ */
+fun writeEntry(target: File, bytes: ByteArray) {
+ val dir = target.parentFile ?: throw IOException("No folder for $target")
+ dir.mkdirs()
+ val temp = File(dir, ".${target.name}.tmp")
+ try {
+ FileOutputStream(temp).use {
+ it.write(bytes)
+ it.fd.sync()
+ }
+ Files.move(
+ temp.toPath(),
+ target.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
+}
+
+/** [extra] with [line] added on a line of its own. */
+fun withLine(extra: String, line: String): String =
+ if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line"
+
+/**
+ * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces
+ * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret.
+ */
+fun totpUri(secret: String, issuer: String): String? {
+ val key = secret.filterNot(Char::isWhitespace)
+ if (key.isEmpty()) return null
+ fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20")
+ val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}"
+ return "otpauth://totp?secret=${encode(key)}$tail"
+}
+
+/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */
+@Immutable
+data class RandomSpec(
+ val numerals: Boolean = true,
+ val symbols: Boolean = false,
+ val uppercase: Boolean = true,
+ val lowercase: Boolean = true,
+ val ambiguous: Boolean = true,
+ val pronounceable: Boolean = false,
+ val length: Int = 20,
+)
+
+/** The generator's options for this spec, negated where the generator asks what to leave out. */
+fun RandomSpec.options(): List<PasswordOption> =
+ listOfNotNull(
+ PasswordOption.NoDigits.takeUnless { numerals },
+ PasswordOption.AtLeastOneSymbol.takeIf { symbols },
+ PasswordOption.NoUppercaseLetters.takeUnless { uppercase },
+ PasswordOption.NoLowercaseLetters.takeUnless { lowercase },
+ PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous },
+ PasswordOption.FullyRandom.takeUnless { pronounceable },
+ )
+
+/** A random password for [spec], or the string resource saying why there is none. */
+fun randomPassword(spec: RandomSpec): Result<String, Int> =
+ runCatching { PasswordGenerator.generate(spec.options(), spec.length) }
+ .mapError { e ->
+ when (e) {
+ is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded
+ is NoCharactersIncludedException -> R.string.pwgen_no_chars_error
+ is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error
+ else -> R.string.pwgen_some_error_occurred
+ }
+ }
+
+/** The diceware generator's options; defaults are the old dialog's. */
+@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-')
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt
new file mode 100644
index 00000000..bb8af01a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorScreen.kt
@@ -0,0 +1,314 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/** Which field takes focus when the editor first opens. */
+enum class EditorFocus {
+ None,
+ Name,
+ Username,
+}
+
+/**
+ * The editor: name, folder (breadcrumbs; they and "Change" open the picker), password with reveal
+ * and generator, username, free-text extra content (the pass format as text) and the OTP actions,
+ * shown while the extras hold no TOTP secret ([showOtpActions]). [onScanQr] is null on a device
+ * without a camera. Only the reveal toggle and the menus keep state here.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EditorScreen(
+ form: EditorForm,
+ editing: Boolean,
+ saving: Boolean,
+ showOtpActions: Boolean,
+ initiallyRevealed: Boolean,
+ focus: EditorFocus,
+ onFormChange: (EditorForm) -> Unit,
+ onClose: () -> Unit,
+ onSave: (copy: Boolean) -> Unit,
+ onChangeFolder: () -> Unit,
+ onGenerate: () -> Unit,
+ onScanQr: (() -> Unit)?,
+ onQrImage: () -> Unit,
+ onManualOtp: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(initiallyRevealed) }
+ var menu by remember { mutableStateOf(false) }
+ var qrMenu by remember { mutableStateOf(false) }
+ val nameFocus = remember { FocusRequester() }
+ val usernameFocus = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ when (focus) {
+ EditorFocus.Name -> nameFocus.requestFocus()
+ EditorFocus.Username -> usernameFocus.requestFocus()
+ EditorFocus.None -> Unit
+ }
+ }
+ val field = Modifier.fillMaxWidth().padding(horizontal = SpacingLarge)
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(if (editing) R.string.editor_edit_title else R.string.editor_new_title)
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Filled.Close, stringResource(R.string.editor_close))
+ }
+ },
+ actions = {
+ TextButton(onClick = { onSave(false) }, enabled = !saving) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.crypto_save_and_copy)) },
+ onClick = {
+ menu = false
+ onSave(true)
+ },
+ enabled = !saving,
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(bottom = SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ OutlinedTextField(
+ value = form.name,
+ onValueChange = { onFormChange(form.copy(name = it)) },
+ modifier = field.focusRequester(nameFocus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ Text(
+ stringResource(R.string.editor_folder),
+ field,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ BreadcrumbBar(
+ crumbsFor(form.dir),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { onChangeFolder() },
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(onClick = onChangeFolder, modifier = Modifier.padding(end = SpacingMedium)) {
+ Text(stringResource(R.string.editor_change_folder))
+ }
+ }
+ OutlinedTextField(
+ value = form.password,
+ onValueChange = { onFormChange(form.copy(password = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_pass_label)) },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ trailingIcon = {
+ Row {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ IconButton(onClick = onGenerate) {
+ Icon(
+ painterResource(R.drawable.ic_password_24px),
+ contentDescription = stringResource(R.string.pwgen_title),
+ )
+ }
+ }
+ },
+ )
+ OutlinedTextField(
+ value = form.username,
+ onValueChange = { onFormChange(form.copy(username = it)) },
+ modifier = field.focusRequester(usernameFocus),
+ label = { Text(stringResource(R.string.crypto_username_hint)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = form.extra,
+ onValueChange = { onFormChange(form.copy(extra = it)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.crypto_extra_label)) },
+ minLines = 3,
+ // A password keyboard does not learn what is typed, as the old textVisiblePassword.
+ keyboardOptions =
+ KeyboardOptions(autoCorrectEnabled = false, keyboardType = KeyboardType.Password),
+ )
+ if (showOtpActions)
+ Row(field, horizontalArrangement = Arrangement.spacedBy(SpacingMedium)) {
+ Box {
+ OutlinedButton(onClick = { qrMenu = true }) {
+ Icon(painterResource(R.drawable.ic_qr_code_scanner), contentDescription = null)
+ Text(stringResource(R.string.editor_otp_qr), Modifier.padding(start = SpacingMedium))
+ }
+ DropdownMenu(expanded = qrMenu, onDismissRequest = { qrMenu = false }) {
+ if (onScanQr != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_qr_code)) },
+ onClick = {
+ qrMenu = false
+ onScanQr()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.otp_import_from_file)) },
+ onClick = {
+ qrMenu = false
+ onQrImage()
+ },
+ )
+ }
+ }
+ OutlinedButton(onClick = onManualOtp) { Text(stringResource(R.string.editor_otp_manual)) }
+ }
+ }
+ }
+}
+
+/**
+ * Asks for a TOTP secret and an optional account (the URI's issuer), as the old manual OTP dialog
+ * did. Both live in [remember] only, and the window is FLAG_SECURE.
+ */
+@Composable
+fun OtpDialog(
+ onAdd: (secret: String, account: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var secret by remember { mutableStateOf("") }
+ var account by remember { mutableStateOf("") }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.add_otp)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = secret,
+ onValueChange = { secret = it },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.otp_import_manual_hint_secret)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = account,
+ onValueChange = { account = it },
+ label = { Text(stringResource(R.string.otp_import_manual_hint_account)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onAdd(secret, account) }, enabled = secret.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
new file mode 100644
index 00000000..6d2fbaec
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.graphics.Bitmap
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import app.passwordstore.R
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.PasswordGeneratorPreferences
+import app.passwordstore.passgen.diceware.DicewarePassphraseGenerator
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.ui.list.moveHistory
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.get
+import com.github.michaelbull.result.runCatching
+import com.google.zxing.BinaryBitmap
+import com.google.zxing.RGBLuminanceSource
+import com.google.zxing.common.HybridBinarizer
+import com.google.zxing.qrcode.QRCodeReader
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** What a save did. */
+sealed interface SaveResult {
+
+ /**
+ * Written to [file] from [form]. [staleOriginal] is the old file of a rename or move, left behind
+ * because it could not be deleted.
+ */
+ data class Saved(val file: File, val form: EditorForm, val staleOriginal: File?) : SaveResult
+
+ /** Nothing was written; [message] (a string resource, formatted with [arg]) says why. */
+ data class Failed(@StringRes val message: Int, val arg: String = "") : SaveResult
+}
+
+/**
+ * The editor's state and work: the [form] (plaintext, in memory only: never in saved state, reset
+ * when this ViewModel is cleared), the generators and their stored options, QR decoding, and the
+ * save (encrypt, write, replace the original). Holds no Context; calls take one and use the
+ * application context.
+ */
+@HiltViewModel
+class EditorViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val diceware: dagger.Lazy<DicewarePassphraseGenerator>,
+ @PasswordGeneratorPreferences private val pwgenPrefs: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ /** The fields being edited. */
+ var form by mutableStateOf(EditorForm())
+
+ /**
+ * Set while a save runs, and after one succeeded (the editor is leaving): saves and closes wait.
+ */
+ var saving by mutableStateOf(false)
+ private set
+
+ /**
+ * The successful save, kept until the editor has committed it and left ([left]), so an editor
+ * recreated in the middle finishes the job. Memory only: it holds the plaintext form.
+ */
+ var saved by mutableStateOf<SaveResult.Saved?>(null)
+ private set
+
+ /** Whether [saved] is to be copied to the clipboard ("Save and copy"). */
+ var copyOnSave = false
+ private set
+
+ private var announced = false
+
+ /** Whether [start] filled the form. */
+ var started = false
+ private set
+
+ /** Fills the form the first time only and returns true then; a recreated activity keeps edits. */
+ fun start(initial: EditorForm): Boolean {
+ if (started) return false
+ started = true
+ form = initial
+ return true
+ }
+
+ /**
+ * True when [extra] already holds a TOTP secret (the OTP actions hide), as the old editor did.
+ */
+ fun hasOtp(extra: String): Boolean =
+ entryFactory.create("PLACEHOLDER\n$extra".encodeToByteArray()).hasTotp()
+
+ /** The random generator's options as the old dialog stored them. */
+ fun randomSpec(context: Context): RandomSpec {
+ val prefs = context.randomPrefs()
+ return RandomSpec(
+ numerals = !prefs.getBoolean(PasswordOption.NoDigits.key, false),
+ symbols = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false),
+ uppercase = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false),
+ lowercase = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false),
+ ambiguous = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false),
+ pronounceable = !prefs.getBoolean(PasswordOption.FullyRandom.key, true),
+ length = prefs.getInt(PreferenceKeys.LENGTH, 20),
+ )
+ }
+
+ /**
+ * Stores [spec], as the old dialog did on every generation, then generates off the main thread.
+ */
+ suspend fun generateRandom(spec: RandomSpec, context: Context): Result<String, Int> {
+ val options = spec.options()
+ context.randomPrefs().edit {
+ PasswordOption.entries.forEach { putBoolean(it.key, it in options) }
+ putInt(PreferenceKeys.LENGTH, spec.length)
+ }
+ return withContext(dispatcherProvider.default()) { randomPassword(spec) }
+ }
+
+ /** The diceware generator's options as the old dialog stored them. */
+ fun dicewareSpec(): DicewareSpec =
+ DicewareSpec(
+ words = pwgenPrefs.getInt(PreferenceKeys.DICEWARE_LENGTH, 5),
+ separator = pwgenPrefs.getString(PreferenceKeys.DICEWARE_SEPARATOR)?.firstOrNull() ?: '-',
+ )
+
+ /** Stores [spec] and generates a passphrase; the word list is read on first use, off main. */
+ suspend fun generateDiceware(spec: DicewareSpec): String {
+ pwgenPrefs.edit {
+ putInt(PreferenceKeys.DICEWARE_LENGTH, spec.words)
+ putString(PreferenceKeys.DICEWARE_SEPARATOR, spec.separator.toString())
+ }
+ return withContext(dispatcherProvider.io()) {
+ diceware.get().generatePassphrase(spec.words, spec.separator)
+ }
+ }
+
+ /** The text of the QR code in the image at [uri], or null when it has none. Off main. */
+ suspend fun readQr(uri: Uri, context: Context): String? {
+ val resolver = context.applicationContext.contentResolver
+ return withContext(dispatcherProvider.io()) {
+ runCatching {
+ val bitmap =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri))
+ .copy(Bitmap.Config.ARGB_8888, false)
+ } else {
+ @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(resolver, uri)
+ }
+ val pixels = IntArray(bitmap.width * bitmap.height)
+ bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
+ val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
+ QRCodeReader().decode(BinaryBitmap(HybridBinarizer(source))).text
+ }
+ .get()
+ }
+ }
+
+ /**
+ * Encrypts the form to [ids] and writes it to [target], replacing [original] when the entry was
+ * renamed or moved. Nothing is written (not even the folder) when [target] would replace another
+ * entry or when encryption fails; a recipient whose key is missing fails it (see
+ * CryptoRepository.encrypt). The caller checked [formError] and that [target] is inside the
+ * store. A success lands in [saved], with [copy] in [copyOnSave]. Returns null when a save
+ * already ran or is running.
+ */
+ suspend fun save(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ context: Context,
+ copy: Boolean,
+ ): SaveResult? {
+ if (saving) return null
+ saving = true
+ copyOnSave = copy
+ try {
+ return write(ids, target, original, context.applicationContext)
+ } finally {
+ // Stays set after a success (the editor is leaving); cleared on failure or cancellation.
+ if (saved == null) saving = false
+ }
+ }
+
+ /** True the first time only: the copy and notices of [saved] run once, across recreation. */
+ fun announce(): Boolean = !announced.also { announced = true }
+
+ /** The editor committed [saved] and left: nothing is left to finish. */
+ fun left() {
+ saved = null
+ }
+
+ override fun onCleared() {
+ form = EditorForm()
+ saved = null
+ }
+
+ private suspend fun write(
+ ids: List<PGPIdentifier>,
+ target: File,
+ original: File?,
+ app: Context,
+ ): SaveResult {
+ val form = form
+ if (withContext(dispatcherProvider.io()) { overwrites(target, original) })
+ return SaveResult.Failed(R.string.password_creation_duplicate_error)
+ val out = ByteArrayOutputStream()
+ val encrypted = repository.encrypt(ids, passContent(form).byteInputStream(), out)
+ if (!encrypted.isOk) {
+ logcat(ERROR) { encrypted.error.asLog() }
+ return SaveResult.Failed(
+ R.string.password_creation_encrypt_fail_message,
+ encrypted.error.message.orEmpty(),
+ )
+ }
+ // NonCancellable: a recreated activity must not stop between writing the target and deleting
+ // the original, which would leave a rename that cannot be retried ("duplicate").
+ return withContext(NonCancellable + dispatcherProvider.io()) {
+ try {
+ writeEntry(target, out.toByteArray())
+ } catch (e: IOException) {
+ logcat(ERROR) { e.asLog() }
+ return@withContext SaveResult.Failed(R.string.password_creation_file_write_fail_message)
+ }
+ var stale: File? = null
+ if (original != null && original.canonicalPath != target.canonicalPath) {
+ if (original.delete() || !original.exists()) {
+ AutofillMatcher.updateMatches(app, moveFromTo = mapOf(original to target))
+ app.moveHistory(original, target)
+ } else {
+ stale = original
+ }
+ }
+ // Set here, inside NonCancellable: a written entry is never forgotten by a cancelled caller.
+ SaveResult.Saved(target, form, stale).also { saved = it }
+ }
+ }
+
+ private fun Context.randomPrefs(): SharedPreferences =
+ getSharedPreferences(RANDOM_PREFS, Context.MODE_PRIVATE)
+
+ private companion object {
+ /** The old random generator dialog's preferences file. */
+ const val RANDOM_PREFS = "PasswordGenerator"
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt
new file mode 100644
index 00000000..67a4963a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/editor/GeneratorSheet.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.window.SecureFlagPolicy
+import app.passwordstore.R
+import app.passwordstore.passgen.random.PasswordGenerator
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.compose.theme.SpacingSmall
+import com.github.michaelbull.result.Result
+
+private const val RANDOM_TAB = 0
+private const val DICEWARE_TAB = 1
+
+/**
+ * The password generator as a bottom sheet: a random tab and a diceware tab with the old dialogs'
+ * options, starting from [random] and [diceware]. [classicFirst] opens the random tab (the
+ * "Password generator" setting). Every change generates again; [onUse] takes the password shown.
+ * The password lives in [remember] only, and the sheet's window is FLAG_SECURE.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GeneratorSheet(
+ classicFirst: Boolean,
+ random: RandomSpec,
+ diceware: DicewareSpec,
+ onRandom: suspend (RandomSpec) -> Result<String, Int>,
+ onDiceware: suspend (DicewareSpec) -> String,
+ onUse: (String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var tab by remember { mutableIntStateOf(if (classicFirst) RANDOM_TAB else DICEWARE_TAB) }
+ var randomSpec by remember { mutableStateOf(random) }
+ var dicewareSpec by remember { mutableStateOf(diceware) }
+ var length by remember { mutableStateOf(random.length.toString()) }
+ var words by remember { mutableStateOf(diceware.words.toString()) }
+ var separator by remember { mutableStateOf(diceware.separator.toString()) }
+ var rolls by remember { mutableIntStateOf(0) }
+ var password by remember { mutableStateOf("") }
+ var problem by remember { mutableStateOf<Int?>(null) }
+ LaunchedEffect(tab, randomSpec, dicewareSpec, rolls) {
+ if (tab == RANDOM_TAB) {
+ val result = onRandom(randomSpec)
+ password = if (result.isOk) result.value else ""
+ problem = if (result.isOk) null else result.error
+ } else {
+ password = onDiceware(dicewareSpec)
+ problem = null
+ }
+ }
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ properties = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ ) {
+ PrimaryTabRow(selectedTabIndex = tab) {
+ Tab(
+ selected = tab == RANDOM_TAB,
+ onClick = { tab = RANDOM_TAB },
+ text = { Text(stringResource(R.string.generator_random)) },
+ )
+ Tab(
+ selected = tab == DICEWARE_TAB,
+ onClick = { tab = DICEWARE_TAB },
+ text = { Text(stringResource(R.string.generator_diceware)) },
+ )
+ }
+ Column(
+ Modifier.verticalScroll(rememberScrollState()).padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingSmall),
+ ) {
+ val shown = problem
+ Text(
+ if (shown == null) password else stringResource(shown),
+ style = MaterialTheme.typography.titleMedium.copy(fontFamily = FontFamily.Monospace),
+ color =
+ if (shown == null) MaterialTheme.colorScheme.onSurface
+ else MaterialTheme.colorScheme.error,
+ )
+ if (tab == RANDOM_TAB) {
+ CheckRow(R.string.pwgen_numerals, randomSpec.numerals) {
+ randomSpec = randomSpec.copy(numerals = it)
+ }
+ CheckRow(R.string.pwgen_symbols, randomSpec.symbols) {
+ randomSpec = randomSpec.copy(symbols = it)
+ }
+ CheckRow(R.string.pwgen_uppercase, randomSpec.uppercase) {
+ randomSpec = randomSpec.copy(uppercase = it)
+ }
+ CheckRow(R.string.pwgen_lowercase, randomSpec.lowercase) {
+ randomSpec = randomSpec.copy(lowercase = it)
+ }
+ CheckRow(R.string.pwgen_pronounceable, randomSpec.pronounceable) {
+ randomSpec = randomSpec.copy(pronounceable = it)
+ }
+ CheckRow(R.string.pwgen_ambiguous, randomSpec.ambiguous) {
+ randomSpec = randomSpec.copy(ambiguous = it)
+ }
+ OutlinedTextField(
+ value = length,
+ onValueChange = { text ->
+ // ponytail: at most 999 characters; the generator has no upper bound of its own.
+ val digits = text.filter(Char::isDigit).take(3)
+ length = digits
+ randomSpec =
+ randomSpec.copy(length = digits.toIntOrNull() ?: PasswordGenerator.DEFAULT_LENGTH)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ } else {
+ OutlinedTextField(
+ value = words,
+ onValueChange = { text ->
+ // ponytail: at most 99 words.
+ val digits = text.filter(Char::isDigit).take(2)
+ words = digits
+ dicewareSpec = dicewareSpec.copy(words = digits.toIntOrNull()?.takeIf { it > 0 } ?: 5)
+ },
+ label = { Text(stringResource(R.string.pwgen_length)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ OutlinedTextField(
+ value = separator,
+ onValueChange = { text ->
+ separator = text.take(1)
+ dicewareSpec = dicewareSpec.copy(separator = separator.firstOrNull() ?: '-')
+ },
+ label = { Text(stringResource(R.string.pwgen_separator)) },
+ singleLine = true,
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(SpacingMedium, Alignment.End),
+ ) {
+ TextButton(onClick = { rolls++ }) { Text(stringResource(R.string.pwgen_generate)) }
+ Button(onClick = { onUse(password) }, enabled = password.isNotEmpty() && problem == null) {
+ Text(stringResource(R.string.generator_use))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CheckRow(@StringRes label: Int, checked: Boolean, onChange: (Boolean) -> Unit) {
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(value = checked, role = Role.Checkbox, onValueChange = onChange),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = checked, onCheckedChange = null)
+ Text(stringResource(label), Modifier.padding(start = SpacingMedium))
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt
new file mode 100644
index 00000000..02b112f4
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.app.Activity
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.window.SecureFlagPolicy
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.copyPasswordToClipboard
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.crypto.getParentPath
+import app.passwordstore.ui.main.LaunchActivity
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+
+/**
+ * One entry: wires [EntryScreen] to the [viewModel], the biometric and passphrase prompts and the
+ * clipboard. [onClose] leaves the destination; [onEdit] opens the editor on the decrypted entry.
+ * The host keeps FLAG_SECURE on while this is composed.
+ */
+@Composable
+fun EntryDestination(
+ activity: MainActivity,
+ file: File,
+ onClose: () -> Unit,
+ onEdit: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: EntryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+
+ // Routes come from the app's own listing or from LaunchActivity's check; check once more.
+ val inRepo = remember(file) { LaunchActivity.isFileInRepo(file.path, root) }
+ LaunchedEffect(inRepo) { if (!inRepo) onClose() }
+ if (!inRepo) return
+
+ fun unlock() {
+ val ids =
+ activity.getPGPIdentifiers(getParentPath(file.absolutePath, root.absolutePath)) ?: return
+ if (viewModel.cacheEnabled && BiometricAuthenticator.canAuthenticate(activity)) {
+ BiometricAuthenticator.authenticate(
+ activity,
+ R.string.biometric_prompt_title_gpg_passphrase_cache,
+ ) { result ->
+ viewModel.unlock(file, ids, result, activity)
+ }
+ } else {
+ viewModel.unlock(file, ids, BiometricResult.CanceledByUser, activity)
+ }
+ }
+
+ // ponytail: after process death during key import, the unlock flow can start
+ // twice or a prompt can be left over the list; rare, no secret exposed. Fix by tracking
+ // whether the flow already started if it shows up.
+ val keyImport =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) unlock() else onClose()
+ }
+
+ // ponytail: a theme or locale change while the biometric prompt is up loses that prompt's
+ // callback (the flow already counts as started); back out and reopen the entry.
+ LaunchedEffect(Unit) { if (viewModel.start() && viewModel.checkKeys()) unlock() }
+ LaunchedEffect(viewModel.closed) { if (viewModel.closed) onClose() }
+
+ val entry = viewModel.entry
+ LaunchedEffect(entry) {
+ val password = entry?.password
+ if (viewModel.takeCopyOnDecrypt() && !password.isNullOrBlank()) {
+ activity.copyPasswordToClipboard(password)
+ }
+ }
+ val fields = remember(entry) { entry?.let(::entryFields) }
+ val totp by
+ produceState<Totp?>(null, entry) {
+ if (entry != null && entry.hasTotp()) entry.totp.collect { value = it }
+ }
+ val folder =
+ remember(file) { file.canonicalFile.parentFile?.relativeTo(root.canonicalFile)?.path.orEmpty() }
+ val showPassword = remember {
+ activity.sharedPrefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
+ }
+
+ Box(modifier) {
+ EntryScreen(
+ name = file.nameWithoutExtension,
+ folder = folder,
+ fields = fields,
+ otp = totp?.value,
+ otpSecondsLeft = totp?.remainingTime?.inWholeSeconds ?: 0L,
+ otpFractionLeft = totp?.fractionLeft() ?: 0f,
+ showPassword = showPassword,
+ onBack = onClose,
+ onEdit = { if (entry != null) onEdit() },
+ onShare = { fields?.password?.let { activity.startActivity(shareIntent(activity, it)) } },
+ onCopyName = { activity.copyTextToClipboard(file.nameWithoutExtension) },
+ onCopySecret = { activity.copyPasswordToClipboard(it) },
+ onCopy = { activity.copyTextToClipboard(it) },
+ )
+
+ when (viewModel.prompt) {
+ EntryPrompt.None -> Unit
+ EntryPrompt.NoKeys ->
+ AlertDialog(
+ onDismissRequest = viewModel::cancel,
+ title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) },
+ text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.dismissPrompt()
+ keyImport.launch(MainActivity.setupIntent(activity, SetupStart.PgpImport))
+ }
+ ) {
+ Text(stringResource(R.string.button_label_import))
+ }
+ },
+ )
+ EntryPrompt.Passphrase,
+ EntryPrompt.WrongPassphrase ->
+ key(viewModel.retries) {
+ PassphraseDialog(
+ wrong = viewModel.prompt == EntryPrompt.WrongPassphrase,
+ cacheEnabled = viewModel.cacheEnabled,
+ clearCache = viewModel.clearCache,
+ onSubmit = { passphrase, clear -> viewModel.submit(passphrase, clear, activity) },
+ onCancel = viewModel::cancel,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember`
+ * and the dialog's window is FLAG_SECURE.
+ */
+@Composable
+internal fun PassphraseDialog(
+ wrong: Boolean,
+ cacheEnabled: Boolean,
+ clearCache: Boolean,
+ onSubmit: (passphrase: String, clearCache: Boolean) -> Unit,
+ onCancel: () -> Unit,
+) {
+ // remember, never rememberSaveable: the passphrase must not reach saved state.
+ var passphrase by remember { mutableStateOf("") }
+ var clear by remember { mutableStateOf(clearCache) }
+ var error by remember { mutableStateOf(wrong) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onCancel,
+ properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
+ title = { Text(stringResource(R.string.password)) },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = passphrase,
+ onValueChange = {
+ passphrase = it
+ error = false
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.password)) },
+ supportingText =
+ if (error) {
+ { Text(stringResource(R.string.git_operation_wrong_password)) }
+ } else null,
+ isError = error,
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { onSubmit(passphrase, clear) }),
+ )
+ if (cacheEnabled)
+ Row(
+ Modifier.toggleable(
+ value = clear,
+ role = Role.Checkbox,
+ onValueChange = { clear = it },
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = clear, onCheckedChange = null)
+ Text(stringResource(R.string.clear_cached_password_on_screen_off))
+ }
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(onClick = { onSubmit(passphrase, clear) }) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ )
+}
+
+/** Always a chooser, so the user can still cancel. */
+private fun shareIntent(activity: Activity, password: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_TEXT, password).setType("text/plain"),
+ activity.getText(R.string.send_plaintext_password_to),
+ )
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt
new file mode 100644
index 00000000..8dbc9eb9
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.runtime.Immutable
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * What the entry view shows. [password] and [username] open the first group (the one-time code
+ * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file
+ * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values
+ * are null, so their rows are not shown.
+ */
+@Immutable
+data class EntryFields(
+ val password: String?,
+ val username: String?,
+ val extras: ImmutableList<Pair<String, String>>,
+)
+
+fun entryFields(entry: PasswordEntry): EntryFields =
+ EntryFields(
+ password = entry.password?.takeIf { it.isNotBlank() },
+ username = entry.username?.takeIf { it.isNotBlank() },
+ extras = entry.extraContent.toList().toImmutableList(),
+ )
+
+/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */
+fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat()
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt
new file mode 100644
index 00000000..d4860819
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryScreen.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import app.passwordstore.R
+import app.passwordstore.ui.compose.FieldRow
+import app.passwordstore.ui.compose.FieldRowLabels
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.LetterAvatar
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+
+/**
+ * One entry: header (avatar, name, [folder] relative to the store root, "" at the root), then the
+ * password, username and one-time code, then the extra fields. [fields] is null until decrypted;
+ * [otp] is null when the entry has no TOTP. [onCopySecret] (password, one-time code) is separate
+ * from [onCopy] because only those copies start the clipboard clear timer.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EntryScreen(
+ name: String,
+ folder: String,
+ fields: EntryFields?,
+ otp: String?,
+ otpSecondsLeft: Long,
+ otpFractionLeft: Float,
+ showPassword: Boolean,
+ onBack: () -> Unit,
+ onEdit: () -> Unit,
+ onShare: () -> Unit,
+ onCopyName: () -> Unit,
+ onCopySecret: (String) -> Unit,
+ onCopy: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var menu by remember { mutableStateOf(false) }
+ val labels =
+ FieldRowLabels(
+ copy = stringResource(R.string.copy_label),
+ show = stringResource(R.string.entry_show_password),
+ hide = stringResource(R.string.entry_hide_password),
+ )
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (fields != null)
+ IconButton(onClick = onEdit) {
+ Icon(
+ painterResource(R.drawable.ic_edit_24dp),
+ contentDescription = stringResource(R.string.edit_password),
+ )
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.entry_copy_name)) },
+ onClick = {
+ menu = false
+ onCopyName()
+ },
+ )
+ if (fields?.password != null)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.share_as_plaintext)) },
+ onClick = {
+ menu = false
+ onShare()
+ },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_share_24dp), contentDescription = null)
+ },
+ )
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ LetterAvatar(name)
+ Column(Modifier.weight(1f).padding(start = SpacingLarge)) {
+ Text(name, style = MaterialTheme.typography.titleLarge)
+ Text(
+ if (folder.isEmpty()) stringResource(R.string.list_store_root) else folder,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (fields != null) {
+ if (fields.password != null || fields.username != null || otp != null)
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ if (fields.password != null)
+ FieldRow(
+ label = stringResource(R.string.password),
+ value = fields.password,
+ labels = labels.copy(copy = stringResource(R.string.copy_password)),
+ masked = true,
+ initiallyRevealed = showPassword,
+ onCopy = { onCopySecret(fields.password) },
+ )
+ if (fields.username != null)
+ FieldRow(
+ label = stringResource(R.string.username),
+ value = fields.username,
+ labels = labels,
+ onCopy = { onCopy(fields.username) },
+ )
+ if (otp != null) {
+ FieldRow(
+ label = stringResource(R.string.otp_label_format, otpSecondsLeft),
+ value = otp,
+ labels = labels,
+ onCopy = { onCopySecret(otp) },
+ )
+ LinearProgressIndicator(
+ progress = { otpFractionLeft },
+ modifier =
+ Modifier.fillMaxWidth()
+ .padding(start = SpacingLarge, end = SpacingLarge, bottom = SpacingMedium),
+ )
+ }
+ }
+ if (fields.extras.isNotEmpty())
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ fields.extras.forEach { (label, value) ->
+ key(label) { FieldRow(label, value, labels, onCopy = { onCopy(value) }) }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt
new file mode 100644
index 00000000..6296a9b6
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/entry/EntryViewModel.kt
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.Application.Companion.screenWasOff
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.autofill.cachesPassphrase
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache
+import app.passwordstore.util.features.Features
+import app.passwordstore.util.settings.Constants
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.ByteArrayOutputStream
+import java.io.File
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+
+/** The dialog the entry destination shows, if any. */
+enum class EntryPrompt {
+ None,
+ NoKeys,
+ Passphrase,
+ WrongPassphrase,
+}
+
+/**
+ * Decrypts one entry as the old decrypt screen did: passphrase cache, passphrase prompt with
+ * retries, then the auto-close timer. The decrypted [entry] lives only in memory: never in saved
+ * state or a route argument. The timer drops it, and so does popping the destination (its back
+ * stack entry clears this ViewModel). Holds no Context; calls take one and use the application
+ * context.
+ */
+@HiltViewModel
+class EntryViewModel
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val entryFactory: PasswordEntry.Factory,
+ private val passphraseCache: PGPPassphraseCache,
+ private val features: Features,
+ @SettingsPreferences private val settings: SharedPreferences,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var entry by mutableStateOf<PasswordEntry?>(null)
+ private set
+
+ var prompt by mutableStateOf(EntryPrompt.None)
+ private set
+
+ /** Set when the destination must close: timer, cancelled prompt, too many wrong passphrases. */
+ var closed by mutableStateOf(false)
+ private set
+
+ /** Initial state of the passphrase dialog's "clear on screen off" box. */
+ var clearCache by mutableStateOf(true)
+ private set
+
+ val cacheEnabled: Boolean
+ get() = features.isEnabled(EnablePGPPassphraseCache)
+
+ private var started = false
+ private var copyOnDecrypt = false
+ /** Failed-or-asked passphrase prompts so far; keys the dialog so each attempt starts fresh. */
+ var retries = 0
+ private set
+
+ private var auth: BiometricResult = BiometricResult.CanceledByUser
+ private var ids: List<PGPIdentifier> = emptyList()
+ private var file: File? = null
+
+ /** True the first time only: a recreated activity must not run the unlock flow again. */
+ fun start(): Boolean {
+ if (started) return false
+ started = true
+ return true
+ }
+
+ /** False, with the "no keys" prompt up, while no PGP key is imported. */
+ suspend fun checkKeys(): Boolean {
+ val hasKeys = repository.hasKeys()
+ if (!hasKeys) prompt = EntryPrompt.NoKeys
+ return hasKeys
+ }
+
+ /** Continues after the biometric prompt (or without one) with its [auth] result. */
+ fun unlock(file: File, ids: List<PGPIdentifier>, auth: BiometricResult, context: Context) {
+ this.file = file
+ this.ids = ids
+ this.auth = auth
+ val app = context.applicationContext
+ viewModelScope.launch { next(isError = false, app) }
+ }
+
+ fun submit(passphrase: String, clearCache: Boolean, context: Context) {
+ if (prompt == EntryPrompt.None) return
+ prompt = EntryPrompt.None
+ this.clearCache = clearCache
+ val app = context.applicationContext
+ viewModelScope.launch {
+ if (decrypt(passphrase, app) && cachesPassphrase(cacheEnabled, auth)) {
+ runCatching {
+ passphraseCache.cachePassphrase(app, ids.first(), passphrase)
+ settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) }
+ }
+ .onFailure { e -> logcat { e.asLog() } }
+ }
+ }
+ }
+
+ fun dismissPrompt() {
+ prompt = EntryPrompt.None
+ }
+
+ fun cancel() {
+ prompt = EntryPrompt.None
+ closed = true
+ }
+
+ /** True once after a decryption when "Automatically copy password" is on. */
+ fun takeCopyOnDecrypt(): Boolean = copyOnDecrypt.also { copyOnDecrypt = false }
+
+ override fun onCleared() {
+ entry = null
+ }
+
+ private suspend fun next(isError: Boolean, context: Context) {
+ when (auth) {
+ // Handled inside the biometric prompt.
+ BiometricResult.Retry -> Unit
+ is BiometricResult.Success -> {
+ // Clear the cache on first use after app start or after the screen was off; this also
+ // purges a stale cache after caching was disabled in the PGP settings.
+ clearCache = settings.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true)
+ if (screenWasOff && clearCache) {
+ passphraseCache.clearAllCachedPassphrases(context)
+ screenWasOff = false
+ }
+ // A cached passphrase that just failed is not tried again (the old screen looped on it).
+ val cached =
+ if (isError) null else passphraseCache.retrieveCachedPassphrase(context, ids.first())
+ if (cached != null) decrypt(cached, context) else ask(isError, context)
+ }
+ else -> ask(isError, context)
+ }
+ }
+
+ private suspend fun ask(isError: Boolean, context: Context) {
+ if (retries >= MAX_RETRIES) {
+ closed = true
+ return
+ }
+ retries++
+ if (withContext(dispatcherProvider.io()) { repository.isPasswordProtected(ids) }) {
+ prompt = if (isError) EntryPrompt.WrongPassphrase else EntryPrompt.Passphrase
+ } else {
+ decrypt(passphrase = "", context)
+ }
+ }
+
+ /** Decrypts off the main thread; a failure goes back to [next] with an error. */
+ private suspend fun decrypt(passphrase: String, context: Context): Boolean {
+ val source = file ?: return false
+ val bytes = withContext(dispatcherProvider.io()) { source.readBytes() }
+ val out = ByteArrayOutputStream()
+ val result = repository.decrypt(passphrase, ids, bytes.inputStream(), out)
+ if (!result.isOk) {
+ logcat(ERROR) { result.error.stackTraceToString() }
+ next(isError = true, context)
+ return false
+ }
+ copyOnDecrypt = settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)
+ entry = withContext(dispatcherProvider.io()) { entryFactory.create(out.toByteArray()) }
+ startTimer()
+ return true
+ }
+
+ /** Drops the entry and closes after the "Password copy timeout" setting (0 = never). */
+ private fun startTimer() {
+ val timeout =
+ settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull()
+ ?: Constants.DEFAULT_DECRYPTION_TIMEOUT
+ if (timeout == 0) return
+ viewModelScope.launch {
+ delay(timeout.seconds)
+ entry = null
+ closed = true
+ }
+ }
+
+ private companion object {
+ const val MAX_RETRIES = 3
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt
deleted file mode 100644
index cfeb6206..00000000
--- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.folderselect
-
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG
-import app.passwordstore.ui.passwords.PasswordStore
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
-
- private lateinit var passwordList: SelectFolderFragment
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- passwordList = SelectFolderFragment()
- val args = Bundle()
- args.putString(
- PasswordStore.REQUEST_ARG_PATH,
- PasswordRepository.getRepositoryDirectory().absolutePath,
- )
-
- passwordList.arguments = args
-
- supportActionBar?.show()
-
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
-
- supportFragmentManager.commit {
- replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressedDispatcher.onBackPressed()
- }
- R.id.crypto_select -> selectFolder()
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun selectFolder() {
- intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
- setResult(RESULT_OK, intent)
- finish()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt
deleted file mode 100644
index 3cf12fdd..00000000
--- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.folderselect
-
-import android.content.Context
-import android.os.Bundle
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.databinding.PasswordRecyclerViewBinding
-import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter
-import app.passwordstore.ui.passwords.PasswordStore
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.viewmodel.ListMode
-import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-@AndroidEntryPoint
-class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
-
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
- private lateinit var listener: OnFragmentInteractionListener
-
- private val model: SearchableRepositoryViewModel by activityViewModels()
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.fab.hide()
- recyclerAdapter =
- PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider).onItemClicked { _, item ->
- listener.onFragmentInteraction(item)
- }
- binding.passRecycler.apply {
- layoutManager = LinearLayoutManager(requireContext())
- itemAnimator = null
- adapter = recyclerAdapter
- }
-
- FastScrollerBuilder(binding.passRecycler).build()
- registerForContextMenu(binding.passRecycler)
-
- val path =
- requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
- "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
- }
- model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
- lifecycleScope.launch {
- model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
- recyclerAdapter.submitList(result.passwordItems)
- }
- }
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- runCatching {
- listener =
- object : OnFragmentInteractionListener {
- override fun onFragmentInteraction(item: PasswordItem) {
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
- (requireActivity() as AppCompatActivity)
- .supportActionBar
- ?.setDisplayHomeAsUpEnabled(true)
- }
- }
- }
- }
- .onFailure {
- throw ClassCastException("$context must implement OnFragmentInteractionListener")
- }
- }
-
- val currentDir: File
- get() = model.currentDir.value
-
- interface OnFragmentInteractionListener {
-
- fun onFragmentInteraction(item: PasswordItem)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt
deleted file mode 100644
index 695029d4..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.git.config
-
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.util.Patterns
-import android.view.MenuItem
-import androidx.core.os.postDelayed
-import androidx.fragment.app.setFragmentResultListener
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.ActivityGitConfigBinding
-import app.passwordstore.ui.dialogs.TextInputDialog
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.git.log.GitLogActivity
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.viewBinding
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import kotlinx.coroutines.launch
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.lib.Constants
-import org.eclipse.jgit.lib.Repository
-import org.eclipse.jgit.lib.RepositoryState
-
-class GitConfigActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitConfigBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- if (gitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
- else binding.gitUserName.setText(gitSettings.authorName)
- binding.gitUserEmail.setText(gitSettings.authorEmail)
- setupTools()
- binding.saveButton.setOnClickListener {
- val email = binding.gitUserEmail.text.toString().trim()
- val name = binding.gitUserName.text.toString().trim()
- if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
- MaterialAlertDialogBuilder(this)
- .setMessage(getString(R.string.invalid_email_dialog_text))
- .setPositiveButton(getString(R.string.dialog_ok), null)
- .show()
- } else {
- gitSettings.authorEmail = email
- gitSettings.authorName = name
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_success),
- Snackbar.LENGTH_SHORT,
- )
- .show()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- /** Sets up the UI components of the tools section. */
- private fun setupTools() {
- val repo = PasswordRepository.repository
- if (repo != null) {
- binding.gitHeadStatus.text = headStatusMsg(repo)
- // enable the abort button only if we're rebasing or merging
- val needsAbort =
- repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
- binding.gitAbortRebase.isEnabled = needsAbort
- binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
- }
- binding.gitLog.setOnClickListener {
- runCatching { launchActivity(GitLogActivity::class.java) }
- .onFailure { ex -> logcat(ERROR) { ex.asLog("Failed to start GitLogActivity") } }
- }
- binding.gitAbortRebase.setOnClickListener { abortRebase() }
- binding.gitResetToRemote.setOnClickListener { resetToRemote() }
- binding.gitGc.setOnClickListener {
- lifecycleScope.launch {
- launchGitOperation(GitOp.GC)
- .fold(
- success = ::finishOnSuccessHandler,
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- }
-
- private fun resetToRemote() {
- val dialog =
- TextInputDialog.newInstance(getString(R.string.git_utils_reset_remote_branch_title))
- dialog.show(supportFragmentManager, "BRANCH_INPUT_DIALOG")
- dialog.setFragmentResultListener(TextInputDialog.REQUEST_KEY) { _, bundle ->
- val result = bundle.getString(TextInputDialog.BUNDLE_KEY_TEXT)
- if (!result.isNullOrEmpty()) {
- remoteBranch = result
- lifecycleScope.launch {
- launchGitOperation(GitOp.RESET)
- .fold(
- success = ::finishOnSuccessHandler,
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- }
- }
-
- private fun abortRebase() {
- lifecycleScope.launch {
- launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
- .fold(
- success = {
- val branch = PasswordRepository.getCurrentBranch()
- MaterialAlertDialogBuilder(this@GitConfigActivity).run {
- setTitle(resources.getString(R.string.git_abort_and_push_title))
- setMessage(
- resources.getString(
- R.string.git_break_out_of_detached_success,
- branch,
- "conflicting-$branch-...",
- )
- )
- setOnDismissListener { finish() }
- setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
- show()
- }
- },
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
-
- /**
- * Returns a user-friendly message about the current state of HEAD.
- *
- * The state is recognized to be either pointing to a branch or detached.
- */
- private fun headStatusMsg(repo: Repository): String {
- return runCatching {
- val headRef = repo.findRef(Constants.HEAD)
- if (headRef.isSymbolic) {
- val branchName = headRef.target.name
- val shortBranchName = Repository.shortenRefName(branchName)
- getString(R.string.git_head_on_branch, shortBranchName)
- } else {
- val commitHash = headRef.objectId.abbreviate(8).name()
- getString(R.string.git_head_detached, commitHash)
- }
- }
- .getOrElse { ex ->
- logcat(ERROR) { "Error getting HEAD reference\n${ex}" }
- getString(R.string.git_head_missing)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt
deleted file mode 100644
index 2a1d3ebd..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt
+++ /dev/null
@@ -1,304 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.git.config
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.view.MenuItem
-import android.view.View
-import androidx.core.os.postDelayed
-import androidx.core.view.isVisible
-import androidx.core.widget.doOnTextChanged
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.ActivityGitCloneBinding
-import app.passwordstore.ui.dialogs.BasicBottomSheet
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.AuthMode
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.Protocol
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.snackbar.Snackbar
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-/**
- * Activity that encompasses both the initial clone as well as editing the server config for future
- * changes.
- */
-class GitServerConfigActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitCloneBinding::inflate)
-
- private lateinit var newAuthMode: AuthMode
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val isClone = intent?.extras?.getBoolean("cloning") ?: false
- if (isClone) {
- binding.saveButton.text = getString(R.string.clone_button)
- }
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- newAuthMode = gitSettings.authMode
-
- binding.authModeGroup.apply {
- when (newAuthMode) {
- AuthMode.SshKey -> check(binding.authModeSshKey.id)
- AuthMode.Password -> check(binding.authModePassword.id)
- AuthMode.None -> clearChecked()
- }
- addOnButtonCheckedListener { _, _, _ ->
- if (checkedButtonIds.isEmpty()) {
- newAuthMode = AuthMode.None
- } else {
- when (checkedButtonId) {
- binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
- binding.authModePassword.id -> newAuthMode = AuthMode.Password
- View.NO_ID -> newAuthMode = AuthMode.None
- }
- }
- }
- }
-
- binding.serverUrl.setText(
- gitSettings.url.also {
- if (it.isNullOrEmpty()) return@also
- setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
- }
- )
-
- binding.serverUrl.doOnTextChanged { text, _, _, _ ->
- if (text.isNullOrEmpty()) return@doOnTextChanged
- setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
- }
-
- binding.clearHostKeyButton.isVisible = gitSettings.hasSavedHostKey()
- binding.clearHostKeyButton.setOnClickListener {
- gitSettings.clearSavedHostKey()
- Snackbar.make(
- binding.root,
- getString(R.string.clear_saved_host_key_success),
- Snackbar.LENGTH_LONG,
- )
- .show()
- it.isVisible = false
- }
- binding.saveButton.setOnClickListener {
- val newUrl = binding.serverUrl.text.toString().trim()
- // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
- // in the beginning will cause the port to be seen as part of the path. Let users know
- // about it and offer a quickfix.
- if (newUrl.contains(PORT_REGEX)) {
- if (newUrl.startsWith("https://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.https_scheme_with_port_title)
- .setMessageRes(R.string.https_scheme_with_port_message)
- .setPositiveButtonClickListener {
- binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
- }
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- } else if (!newUrl.startsWith("ssh://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.ssh_scheme_needed_message)
- .setPositiveButtonClickListener {
- @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl")
- }
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- }
- }
- if (newUrl.startsWith("git://")) {
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.git_scheme_disallowed_title)
- .setMessageRes(R.string.git_scheme_disallowed_message)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "SSH_SCHEME_WARNING")
- return@setOnClickListener
- }
- when (
- val updateResult =
- gitSettings.updateConnectionSettingsIfValid(
- newAuthMode = newAuthMode,
- newUrl = binding.serverUrl.text.toString().trim(),
- )
- ) {
- GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_error),
- Snackbar.LENGTH_LONG,
- )
- .show()
- }
- is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
- when (updateResult.newProtocol) {
- Protocol.Https ->
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.git_server_config_save_missing_username_https)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
- Protocol.Ssh ->
- BasicBottomSheet.Builder(this)
- .setTitleRes(R.string.ssh_scheme_needed_title)
- .setMessageRes(R.string.git_server_config_save_missing_username_ssh)
- .setPositiveButtonClickListener {}
- .build()
- .show(supportFragmentManager, "SSH_MISSING_USERNAME")
- }
- }
- GitSettings.UpdateConnectionSettingsResult.Valid -> {
- if (isClone && PasswordRepository.repository == null) PasswordRepository.initialize()
- if (!isClone) {
- Snackbar.make(
- binding.root,
- getString(R.string.git_server_config_save_success),
- Snackbar.LENGTH_SHORT,
- )
- .show()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- } else {
- cloneRepository()
- }
- }
- is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
- val message =
- getString(
- R.string.git_server_config_save_auth_mode_mismatch,
- updateResult.newProtocol,
- updateResult.validModes.joinToString(", "),
- )
- Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
- }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun setAuthModes(isHttps: Boolean) =
- with(binding) {
- if (isHttps) {
- authModeSshKey.isVisible = false
- authModePassword.isVisible = true
- if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked()
- } else {
- authModeSshKey.isVisible = true
- authModePassword.isVisible = true
- if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
- }
- }
-
- /** Clones the repository, the directory exists, deletes it */
- private fun cloneRepository() {
- val localDir =
- requireNotNull(PasswordRepository.getRepositoryDirectory()) {
- "Repository directory must be set before cloning"
- }
- val localDirFiles = localDir.listFiles() ?: emptyArray()
- // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
- if (
- localDir.exists() &&
- localDirFiles.isNotEmpty() &&
- !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
- ) {
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.dialog_delete_title)
- .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
- runCatching {
- lifecycleScope.launch {
- val snackbar =
- snackbar(
- message = getString(R.string.delete_directory_progress_text),
- length = Snackbar.LENGTH_INDEFINITE,
- )
- withContext(dispatcherProvider.io()) {
- localDir.deleteRecursively()
- localDir.mkdirs()
- }
- snackbar.dismiss()
- launchGitOperation(GitOp.CLONE)
- .fold(
- success = {
- setResult(RESULT_OK)
- finish()
- },
- failure = { err -> promptOnErrorHandler(err) { finish() } },
- )
- }
- }
- .onFailure { e ->
- e.printStackTrace()
- MaterialAlertDialogBuilder(this).setMessage(e.message).show()
- }
- dialog.cancel()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
- .show()
- } else {
- runCatching {
- // Silently delete & replace the lone .git folder if it exists
- if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
- localDir.deleteRecursively()
- }
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog() }
- MaterialAlertDialogBuilder(this).setMessage(e.message).show()
- }
- lifecycleScope.launch {
- launchGitOperation(GitOp.CLONE)
- .fold(
- success = {
- setResult(RESULT_OK)
- finish()
- },
- failure = { promptOnErrorHandler(it) },
- )
- }
- }
- }
-
- companion object {
-
- private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
-
- fun createCloneIntent(context: Context): Intent {
- return Intent(context, GitServerConfigActivity::class.java).apply {
- putExtra("cloning", true)
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt
deleted file mode 100644
index a72881d1..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogActivity.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.git.log
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.databinding.ActivityGitLogBinding
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.util.extensions.viewBinding
-
-/**
- * Displays the repository's git commits in git-log fashion.
- *
- * It provides basic information about each commit by way of a non-interactive RecyclerView.
- */
-class GitLogActivity : BaseGitActivity() {
-
- private val binding by viewBinding(ActivityGitLogBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- createRecyclerView()
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- finish()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private fun createRecyclerView() {
- binding.gitLogRecyclerView.apply {
- setHasFixedSize(true)
- addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
- adapter = GitLogAdapter()
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt b/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt
deleted file mode 100644
index 95186188..00000000
--- a/app/src/main/java/app/passwordstore/ui/git/log/GitLogAdapter.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.git.log
-
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
-import app.passwordstore.databinding.GitLogRowLayoutBinding
-import app.passwordstore.util.git.GitCommit
-import app.passwordstore.util.git.GitLogModel
-import java.time.Instant
-import java.time.ZoneId
-import java.time.format.DateTimeFormatter
-import java.time.format.FormatStyle
-import java.util.Locale
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-private fun shortHash(hash: String): String {
- return hash.substring(0 until 8)
-}
-
-private fun stringFrom(date: Instant): String {
- return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
- .withLocale(Locale.getDefault())
- .withZone(ZoneId.systemDefault())
- .format(date)
-}
-
-/** @see GitLogActivity */
-class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
-
- private val model = GitLogModel()
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
- val inflater = LayoutInflater.from(parent.context)
- val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
- return ViewHolder(binding)
- }
-
- override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
- val commit = model.get(position)
- if (commit == null) {
- logcat(ERROR) { "There is no git commit for view holder at position $position." }
- return
- }
- viewHolder.bind(commit)
- }
-
- override fun getItemCount() = model.size
-
- class ViewHolder(private val binding: GitLogRowLayoutBinding) :
- RecyclerView.ViewHolder(binding.root) {
-
- fun bind(commit: GitCommit) =
- with(binding) {
- gitLogRowMessage.text = commit.shortMessage
- gitLogRowHash.text = shortHash(commit.hash)
- gitLogRowTime.text = stringFrom(commit.time)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
new file mode 100644
index 00000000..bd5b66f3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.FolderRow
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PreferenceKeys
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Picks a folder of the store at [root], starting at [start] (relative to the root, "" is the
+ * root): breadcrumbs jump up, rows open subfolders, "New folder" creates one inside the folder
+ * shown (the list's validation) and opens it, Select reports the folder shown, relative to the
+ * root. Used by the list's move action and by the editor. Shows no secrets; its window inherits
+ * FLAG_SECURE from the screen below.
+ */
+@Composable
+fun FolderPicker(
+ root: File,
+ start: String,
+ onPick: (relativePath: String) -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ var path by rememberSaveable { mutableStateOf(start) }
+ var naming by rememberSaveable { mutableStateOf(false) }
+ val showHidden = remember {
+ context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+ }
+ val rows by
+ produceState<ImmutableList<ListRow>>(persistentListOf(), path, naming) {
+ val dir = File(root, path)
+ // ponytail: a composable has no injected DispatcherProvider; plain IO for a directory read.
+ @Suppress("RawDispatchersUse")
+ value = withContext(Dispatchers.IO) { listRows(subfolders(dir, showHidden), dir).folders }
+ }
+ if (naming) {
+ // The list's own dialog and checks: blank, already a file or folder, outside the store
+ // (component-wise isInsideRepository).
+ val dir = File(root, path)
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ val folder = File(dir, name).canonicalFile
+ naming = false
+ if (folder.mkdirs() || folder.isDirectory) {
+ path = folder.relativeTo(root.canonicalFile).path
+ } else {
+ Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show()
+ }
+ },
+ onDismiss = { naming = false },
+ )
+ }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.folder_picker_title)) },
+ text = {
+ Column {
+ BreadcrumbBar(
+ crumbsFor(path),
+ stringResource(R.string.list_store_root),
+ onCrumbClick = { path = it },
+ modifier = Modifier.fillMaxWidth().padding(bottom = SpacingMedium),
+ )
+ // ponytail: a plain scrolling column, fine for the folders of one level; a LazyColumn
+ // if a store ever has thousands of subfolders in one folder.
+ Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) {
+ rows.forEach { row ->
+ key(row.path) {
+ FolderRow(
+ name = row.label,
+ count = row.count,
+ onClick = { path = File(row.path).relativeTo(root).path },
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onPick(path) }) { Text(stringResource(R.string.crypto_select)) }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { naming = true }) { Text(stringResource(R.string.folder_picker_new)) }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
new file mode 100644
index 00000000..a9228484
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -0,0 +1,368 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.PreferenceKeys
+import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
+import java.io.File
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.launch
+
+private enum class ListDialog {
+ None,
+ Create,
+ NewFolder,
+ Rename,
+ Delete,
+ Move,
+ Clone,
+ NotInitialized,
+}
+
+/**
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * folder picker and the store operations. [onOpenFolder] takes a path relative to the root,
+ * [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, [onClone] the git
+ * server screen for a clone, [onSettings] the settings; [onNoStore] runs when the store is gone.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ onOpenEntry: (File) -> Unit,
+ onNewEntry: () -> Unit,
+ onClone: () -> Unit,
+ onSettings: () -> Unit,
+ onNoStore: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SearchableRepositoryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val dir = File(root, path)
+ val prefs = activity.sharedPrefs
+ var query by rememberSaveable { mutableStateOf("") }
+ var selected by rememberSaveable { mutableStateOf(listOf<String>()) }
+ var dialog by rememberSaveable { mutableStateOf(ListDialog.None) }
+ var deleteCount by rememberSaveable { mutableIntStateOf(0) }
+ var refreshing by remember { mutableStateOf(false) }
+ var resumes by remember { mutableIntStateOf(0) }
+
+ // Declared before the collection below on purpose: effects start in order, so the ViewModel
+ // already points at this folder when the first rows are built (no flash of the store root).
+ LaunchedEffect(path, query) {
+ viewModel.navigateTo(dir)
+ if (query.isNotBlank()) viewModel.search(query.trim())
+ }
+ val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows())
+ val gitOps = remember(resumes) { activity.gitOps() }
+ val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) }
+
+ fun refresh() {
+ if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh()
+ }
+
+ // Every return to this folder (back from a child, or from another activity) re-reads it.
+ LifecycleResumeEffect(Unit) {
+ if (activity.checkStore()) refresh() else onNoStore()
+ resumes++
+ onPauseOrDispose {}
+ }
+ BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() }
+ BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" }
+
+ fun ifInitialized(action: () -> Unit) {
+ if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized
+ }
+
+ fun runGit(op: GitOp) {
+ refreshing = true
+ activity.lifecycleScope.launch {
+ activity.runGit(op)
+ refreshing = false
+ refresh()
+ }
+ }
+
+ Box(modifier) {
+ ListScreen(
+ path = path,
+ query = query,
+ rows = rows,
+ selected = selected.toImmutableSet(),
+ refreshing = refreshing,
+ canSync = canSync,
+ gitOps = gitOps,
+ onQueryChange = { query = it },
+ onCrumb = onOpenFolder,
+ onOpenFolder = { folder ->
+ activity.recordUse(File(folder))
+ query = ""
+ onOpenFolder(File(folder).relativeTo(root).path)
+ },
+ onOpenEntry = { entry ->
+ activity.recordUse(File(entry))
+ onOpenEntry(File(entry))
+ },
+ onToggle = { p -> selected = if (p in selected) selected - p else selected + p },
+ onClearSelection = { selected = emptyList() },
+ onSync = {
+ when {
+ root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh()
+ !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone
+ activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL)
+ else -> runGit(GitOp.SYNC)
+ }
+ },
+ onGitOp = { op -> ifInitialized { runGit(op) } },
+ onSettings = onSettings,
+ onNew = { dialog = ListDialog.Create },
+ onMove = { dialog = ListDialog.Move },
+ onDelete = {
+ val files = selected.map(::File)
+ val count = countFiles(files)
+ if (count == 0) {
+ files.forEach { it.deleteWithoutFollowingLinks() }
+ selected = emptyList()
+ refresh()
+ } else {
+ deleteCount = count
+ dialog = ListDialog.Delete
+ }
+ },
+ onRename = { dialog = ListDialog.Rename },
+ onPin = {
+ activity.pinEntry(File(selected.single()))
+ selected = emptyList()
+ },
+ focusSearch = path.isEmpty() && prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ )
+
+ when (dialog) {
+ ListDialog.None -> Unit
+ ListDialog.Create ->
+ CreateSheet(
+ onDismiss = { dialog = ListDialog.None },
+ onNewEntry = {
+ dialog = ListDialog.None
+ ifInitialized(onNewEntry)
+ },
+ onNewFolder = {
+ dialog = ListDialog.None
+ ifInitialized { dialog = ListDialog.NewFolder }
+ },
+ )
+ ListDialog.NewFolder ->
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ val folder = File(dir, name).canonicalFile
+ folder.mkdirs()
+ onOpenFolder(folder.relativeTo(root.canonicalFile).path)
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ ListDialog.Rename -> {
+ val folder = File(selected.single())
+ FolderNameDialog(
+ title = stringResource(R.string.title_rename_folder),
+ message = stringResource(R.string.message_rename_folder, folder.name),
+ confirm = stringResource(R.string.dialog_ok),
+ validate = { folderNameError(it, File(folder.parentFile, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.renameFolder(folder, File(folder.parentFile, name))
+ refresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ ListDialog.Delete ->
+ Prompt(
+ text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ val files = selected.map(::File)
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.deleteItems(files)
+ refresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ ListDialog.Move ->
+ FolderPicker(
+ root = root,
+ start = path,
+ onPick = { target ->
+ // One move per pick: a double tap on Select must not move twice.
+ val destination = File(root, target)
+ val files = selected.map(::File)
+ val canonical = destination.canonicalFile
+ if (
+ !destination.isInsideRepository() ||
+ files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) }
+ ) {
+ Toast.makeText(
+ activity,
+ R.string.message_error_destination_outside_repo,
+ Toast.LENGTH_LONG,
+ )
+ .show()
+ } else if (dialog == ListDialog.Move) {
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.moveItems(files, destination)
+ refresh()
+ }
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ ListDialog.Clone ->
+ Prompt(
+ text = stringResource(R.string.clone_git_repo),
+ confirm = stringResource(R.string.clone_button),
+ onConfirm = {
+ dialog = ListDialog.None
+ onClone()
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ ListDialog.NotInitialized ->
+ Prompt(
+ text = stringResource(R.string.creation_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = ListDialog.None },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ }
+}
+
+/**
+ * Asks for a folder name; [validate] returns an error string resource or null. Shared with the
+ * folder picker's "New folder".
+ */
+@Composable
+internal fun FolderNameDialog(
+ title: String,
+ message: String?,
+ confirm: String,
+ validate: (String) -> Int?,
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf("") }
+ var error by remember { mutableStateOf<Int?>(null) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ val err = error
+ Column {
+ if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium))
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ error = null
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ supportingText =
+ if (err != null) {
+ { Text(stringResource(err)) }
+ } else null,
+ isError = err != null,
+ singleLine = true,
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val e = validate(name)
+ error = e
+ if (e == null) onConfirm(name)
+ }
+ ) {
+ Text(confirm)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/** A one-message dialog; [dismiss] adds a second button that only closes it. */
+@Composable
+private fun Prompt(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ dismiss: String? = null,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismiss) { Text(dismiss) } }
+ } else null,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
new file mode 100644
index 00000000..96b7ab4b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import app.passwordstore.ui.compose.crumbsFor
+import java.io.File
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+
+/**
+ * One list row. [path] is the absolute path and the row's key; [count] is a folder's number of
+ * children, 0 for entries.
+ */
+data class ListRow(val path: String, val label: String, val subtitle: String?, val count: Int)
+
+/** The rows of one folder, or of a search inside it: folders above entries. */
+data class ListRows(
+ val folders: ImmutableList<ListRow> = persistentListOf(),
+ val entries: ImmutableList<ListRow> = persistentListOf(),
+)
+
+/**
+ * Splits [files] (already sorted or ranked) into folder and entry rows for [dir], keeping their
+ * order. Labels and subtitles are relative to [dir]: browsing shows plain names, search results
+ * show where they are. Entries get no username or OTP subtitle: both live inside the encrypted
+ * file, and the list never decrypts.
+ */
+fun listRows(files: List<File>, dir: File): ListRows {
+ val (folders, entries) = files.partition { it.isDirectory }
+ return ListRows(
+ folders
+ .map { ListRow(it.absolutePath, it.relativeTo(dir).path, null, childCount(it)) }
+ .toImmutableList(),
+ entries
+ .map {
+ val parent = it.parentFile?.relativeTo(dir)?.path.orEmpty()
+ ListRow(it.absolutePath, it.name.removeSuffix(".gpg"), parent.ifEmpty { null }, 0)
+ }
+ .toImmutableList(),
+ )
+}
+
+/** Children counted for a folder: subfolders and .gpg files, as the old list counted them. */
+private fun childCount(folder: File): Int =
+ folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0
+
+/**
+ * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden
+ * folders only with "Show hidden contents" ([showHidden]), as the list filters them.
+ */
+fun subfolders(dir: File, showHidden: Boolean): List<File> =
+ dir
+ .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) }
+ .orEmpty()
+ .sortedBy { it.name.lowercase() }
+
+/**
+ * How to get from the list at [from] to the list at [to] (both relative to the store root) while
+ * the back stack holds one entry per folder level: how many entries to pop, then which paths to
+ * push. Breadcrumb jumps only pop; opening a folder from search results may pop and push.
+ */
+fun folderMoves(from: String, to: String): Pair<Int, List<String>> {
+ val current = crumbsFor(from)
+ val target = crumbsFor(to)
+ val shared = current.zip(target).takeWhile { (a, b) -> a == b }.size
+ return (current.size - shared) to target.drop(shared).map { it.path }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
new file mode 100644
index 00000000..720cfc0a
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
@@ -0,0 +1,340 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
+import androidx.compose.material3.pulltorefresh.pullToRefresh
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.R
+import app.passwordstore.ui.compose.BreadcrumbBar
+import app.passwordstore.ui.compose.EntryRow
+import app.passwordstore.ui.compose.FolderRow
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.crumbsFor
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+
+/**
+ * The password list: search bar, breadcrumbs, folders above entries, pull to sync, long-press
+ * selection and the "New entry" button. Row callbacks take absolute paths; [onCrumb] takes a path
+ * relative to the store root, like [path].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ListScreen(
+ path: String,
+ query: String,
+ rows: ListRows,
+ selected: ImmutableSet<String>,
+ refreshing: Boolean,
+ canSync: Boolean,
+ gitOps: ImmutableList<GitOp>,
+ onQueryChange: (String) -> Unit,
+ onCrumb: (relativePath: String) -> Unit,
+ onOpenFolder: (path: String) -> Unit,
+ onOpenEntry: (path: String) -> Unit,
+ onToggle: (path: String) -> Unit,
+ onClearSelection: () -> Unit,
+ onSync: () -> Unit,
+ onGitOp: (GitOp) -> Unit,
+ onSettings: () -> Unit,
+ onNew: () -> Unit,
+ onMove: () -> Unit,
+ onDelete: () -> Unit,
+ onRename: () -> Unit,
+ onPin: () -> Unit,
+ modifier: Modifier = Modifier,
+ focusSearch: Boolean = false,
+) {
+ val selecting = selected.isNotEmpty()
+ val listState = rememberLazyListState()
+ val pullState = rememberPullToRefreshState()
+ val searchFocus = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ // Saveable, so it survives this destination leaving composition while a folder is open.
+ var launched by rememberSaveable { mutableStateOf(false) }
+ var returned by remember { mutableStateOf(false) }
+ LifecycleResumeEffect(Unit) {
+ returned = true
+ onPauseOrDispose {}
+ }
+ // Out of touch mode (keys, D-pad) the view system hands its default focus to the first
+ // focusable, the search field, which keeps it in touch mode too and brings the keyboard back
+ // when the list returns from a screen that had it up. So every return drops focus, except the
+ // first, where search on start asks for it. Done once the window has focus: before that the
+ // view still holds the touch mode it last saw, and out of it clearing focus refocuses the field.
+ val windowFocused = LocalWindowInfo.current.isWindowFocused
+ LaunchedEffect(returned, windowFocused) {
+ if (returned && windowFocused) {
+ returned = false
+ if (focusSearch && !launched) searchFocus.requestFocus() else focusManager.clearFocus()
+ launched = true
+ }
+ }
+ // Filtered results always start at the top, where the best match is.
+ LaunchedEffect(query) { if (query.isNotEmpty()) listState.scrollToItem(0) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ if (selecting) {
+ SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin)
+ } else {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus)
+ BreadcrumbBar(
+ crumbsFor(path),
+ stringResource(R.string.list_store_root),
+ onCrumb,
+ Modifier.fillMaxWidth().padding(vertical = SpacingMedium),
+ )
+ }
+ }
+ },
+ floatingActionButton = {
+ if (!selecting)
+ // M3 clears the label's semantics; the FAB takes its description from the icon.
+ ExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.list_new_entry)) },
+ icon = { Icon(Icons.Filled.Add, stringResource(R.string.list_new_entry)) },
+ onClick = onNew,
+ )
+ },
+ ) { padding ->
+ Box(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .pullToRefresh(refreshing, pullState, enabled = canSync && !selecting, onRefresh = onSync)
+ ) {
+ // ponytail: each group is one lazy item, fine for hundreds of rows; split rows into their
+ // own items (with per-row corner shapes) if a store ever has thousands in one folder.
+ LazyColumn(
+ state = listState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(bottom = 96.dp),
+ ) {
+ if (rows.folders.isNotEmpty())
+ item(key = "folders") {
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ rows.folders.forEach { row ->
+ key(row.path) {
+ FolderRow(
+ name = row.label,
+ count = row.count,
+ onClick = { if (selecting) onToggle(row.path) else onOpenFolder(row.path) },
+ selected = row.path in selected,
+ onLongClick = { onToggle(row.path) },
+ )
+ }
+ }
+ }
+ }
+ if (rows.entries.isNotEmpty())
+ item(key = "entries") {
+ GroupedSurface(Modifier.fillMaxWidth()) {
+ rows.entries.forEach { row ->
+ key(row.path) {
+ EntryRow(
+ name = row.label,
+ subtitle = row.subtitle,
+ onClick = { if (selecting) onToggle(row.path) else onOpenEntry(row.path) },
+ selected = row.path in selected,
+ onLongClick = { onToggle(row.path) },
+ )
+ }
+ }
+ }
+ }
+ }
+ PullToRefreshDefaults.Indicator(pullState, refreshing, Modifier.align(Alignment.TopCenter))
+ }
+ }
+}
+
+/** Bottom sheet behind the "New entry" button: a password entry or a folder. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CreateSheet(
+ onDismiss: () -> Unit,
+ onNewEntry: () -> Unit,
+ onNewFolder: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier) {
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_password)) },
+ modifier = Modifier.clickable(onClick = onNewEntry),
+ leadingContent = {
+ Icon(painterResource(R.drawable.ic_action_new_password), contentDescription = null)
+ },
+ )
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.bottom_sheet_create_new_folder)) },
+ modifier = Modifier.clickable(onClick = onNewFolder),
+ leadingContent = {
+ Icon(painterResource(R.drawable.ic_action_new_folder), contentDescription = null)
+ },
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SearchField(
+ query: String,
+ gitOps: ImmutableList<GitOp>,
+ onQueryChange: (String) -> Unit,
+ onGitOp: (GitOp) -> Unit,
+ onSettings: () -> Unit,
+ focus: FocusRequester,
+) {
+ val focusManager = LocalFocusManager.current
+ var menu by remember { mutableStateOf(false) }
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = { focusManager.clearFocus() },
+ expanded = false,
+ onExpandedChange = {},
+ modifier = Modifier.focusRequester(focus),
+ placeholder = { Text(stringResource(R.string.action_search)) },
+ leadingIcon = {
+ Icon(painterResource(R.drawable.ic_search_24dp), contentDescription = null)
+ },
+ trailingIcon = {
+ Row {
+ if (query.isNotEmpty())
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(Icons.Filled.Close, stringResource(R.string.list_clear_search))
+ }
+ Box {
+ IconButton(onClick = { menu = true }) {
+ Icon(Icons.Filled.MoreVert, stringResource(R.string.list_more_options))
+ }
+ DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
+ gitOps.forEach { op ->
+ DropdownMenuItem(
+ text = { Text(stringResource(op.label)) },
+ onClick = {
+ menu = false
+ onGitOp(op)
+ },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_settings)) },
+ onClick = {
+ menu = false
+ onSettings()
+ },
+ )
+ }
+ }
+ }
+ },
+ )
+ },
+ expanded = false,
+ onExpandedChange = {},
+ ) {}
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectionBar(
+ rows: ListRows,
+ selected: ImmutableSet<String>,
+ onClear: () -> Unit,
+ onMove: () -> Unit,
+ onDelete: () -> Unit,
+ onRename: () -> Unit,
+ onPin: () -> Unit,
+) {
+ val single = selected.singleOrNull()
+ TopAppBar(
+ title = { Text(pluralStringResource(R.plurals.delete_title, selected.size, selected.size)) },
+ navigationIcon = {
+ IconButton(onClick = onClear) {
+ Icon(Icons.Filled.Close, stringResource(R.string.dialog_cancel))
+ }
+ },
+ actions = {
+ ActionIcon(R.drawable.ic_move_folder_24dp, R.string.move, onMove)
+ ActionIcon(R.drawable.ic_delete_24dp, R.string.delete, onDelete)
+ // ponytail: rename one folder at a time; the old list queued one dialog per folder.
+ if (rows.folders.any { it.path == single })
+ ActionIcon(R.drawable.ic_edit_24dp, R.string.title_rename_folder, onRename)
+ if (rows.entries.any { it.path == single })
+ ActionIcon(R.drawable.ic_push_pin_24dp, R.string.place_shortcut_on_home_screen, onPin)
+ },
+ )
+}
+
+@Composable
+private fun ActionIcon(@DrawableRes icon: Int, @StringRes label: Int, onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ Icon(painterResource(icon), contentDescription = stringResource(label))
+ }
+}
+
+private val GitOp.label: Int
+ get() =
+ when (this) {
+ GitOp.SYNC -> R.string.git_sync
+ GitOp.PUSH -> R.string.git_push
+ else -> R.string.git_pull
+ }
diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
new file mode 100644
index 00000000..3bdede73
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.content.Context
+import androidx.core.content.edit
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.crypto.getLongName
+import app.passwordstore.ui.git.base.BaseGitActivity
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.extensions.base64
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.extensions.isInsideRepository
+import app.passwordstore.util.extensions.listFilesRecursively
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.File
+import java.nio.file.Files
+import kotlin.coroutines.resume
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.logcat
+
+/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */
+private const val HISTORY = "recent_password_history"
+
+/** Password files in [files], counting a folder by everything below it. */
+fun countFiles(files: List<File>): Int =
+ files.sumOf { if (it.isFile) 1 else it.listFilesRecursively().size }
+
+/** Why [name] cannot become the folder [target], as a string resource, or null when it can. */
+fun folderNameError(name: String, target: File): Int? =
+ when {
+ name.isBlank() -> R.string.message_category_error_empty_field
+ target.isFile -> R.string.folder_creation_err_file_exists
+ target.isDirectory -> R.string.folder_creation_err_folder_exists
+ !target.isInsideRepository() -> R.string.message_error_destination_outside_repo
+ else -> null
+ }
+
+/** Records that [file] was opened, for the "recently used" sort order. */
+fun Context.recordUse(file: File) {
+ if (sharedPrefs.getString(PreferenceKeys.SORT_ORDER) != PasswordSortOrder.RECENTLY_USED.name)
+ return
+ getSharedPreferences(HISTORY, Context.MODE_PRIVATE).edit {
+ putString(file.absolutePath.base64(), System.currentTimeMillis().toString())
+ }
+}
+
+/** Deletes this file or folder, never following symlinks; true when everything is gone. */
+internal fun File.deleteWithoutFollowingLinks(): Boolean {
+ if (isDirectory && !Files.isSymbolicLink(toPath())) {
+ // Evaluate every child, no short-circuit, so one failure leaves the rest deleted.
+ val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() }
+ if (!children.all { it }) return false
+ }
+ return delete()
+}
+
+/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */
+suspend fun BaseGitActivity.deleteItems(files: List<File>) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ withContext(dispatcherProvider.io()) {
+ val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) }
+ files.forEach { it.deleteWithoutFollowingLinks() }
+ AutofillMatcher.updateMatches(applicationContext, delete = deleted)
+ }
+ commitChange(
+ getString(
+ R.string.git_commit_remove_text,
+ files.joinToString(", ") { it.toRelativeString(root) },
+ )
+ )
+}
+
+/** Moves [files] into the folder [target], asking before overwriting, then commits. */
+suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) {
+ val repo = PasswordRepository.getRepositoryDirectory().absolutePath
+ for (source in files) {
+ if (!source.exists()) {
+ logcat(ERROR) { "Tried moving something that appears non-existent." }
+ continue
+ }
+ val destination = File(target, source.name)
+ val name = source.nameWithoutExtension
+ if (
+ destination.exists() &&
+ !confirm(
+ getString(R.string.password_exists_title),
+ getString(
+ R.string.password_exists_message,
+ getLongName(target.absolutePath, repo, name),
+ getLongName(source.parent.orEmpty(), repo, name),
+ ),
+ )
+ )
+ continue
+ moveFile(source, destination)
+ }
+ val message =
+ if (files.size == 1) {
+ val name = files[0].nameWithoutExtension
+ getString(
+ R.string.git_commit_move_text,
+ getLongName(files[0].parent.orEmpty(), repo, name),
+ getLongName(target.absolutePath, repo, name),
+ )
+ } else {
+ val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/")
+ getString(R.string.git_commit_move_multiple_text, relative)
+ }
+ commitChange(message)
+}
+
+/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */
+suspend fun BaseGitActivity.renameFolder(from: File, to: File) {
+ if (!moveFile(from, to)) return
+ moveHistory(from, to)
+ commitChange(getString(R.string.git_commit_move_text, from.name, to.name))
+}
+
+/** Carries the "recently used" time of [from] over to [to]. */
+fun Context.moveHistory(from: File, to: File) {
+ val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE)
+ val time = history.getString(from.absolutePath.base64()) ?: return
+ history.edit {
+ remove(from.absolutePath.base64())
+ putString(to.absolutePath.base64(), time)
+ }
+}
+
+private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean {
+ val moved =
+ withContext(dispatcherProvider.io()) {
+ val map =
+ if (source.isDirectory) {
+ destination.mkdirs()
+ source.listFilesRecursively().associateWith { destination.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destination)
+ }
+ source.renameTo(destination).also { ok ->
+ if (ok) AutofillMatcher.updateMatches(this@moveFile, map)
+ }
+ }
+ if (!moved) {
+ logcat(ERROR) { "Something went wrong while moving $source to $destination." }
+ // ponytail: View dialog like BaseGitActivity's git prompts; both move to Compose together.
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.password_move_error_title)
+ .setMessage(getString(R.string.password_move_error_message, source, destination))
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+ return moved
+}
+
+/** Shows [message] under [title] and suspends until answered; true when confirmed. */
+// ponytail: View dialog, see moveFile.
+private suspend fun Context.confirm(title: String, message: String): Boolean =
+ suspendCancellableCoroutine { cont ->
+ val dialog =
+ MaterialAlertDialogBuilder(this)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) }
+ .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) }
+ .setOnCancelListener { cont.resume(false) }
+ .show()
+ cont.invokeOnCancellation { dialog.dismiss() }
+ }
diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
index 1a5e8a89..c0af5742 100644
--- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt
@@ -12,9 +12,6 @@ import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.DecryptActivity
-import app.passwordstore.ui.passwords.PasswordStore
import app.passwordstore.util.auth.BiometricAuthenticator
import app.passwordstore.util.auth.BiometricAuthenticator.Result
import app.passwordstore.util.extensions.sharedPrefs
@@ -24,10 +21,21 @@ import java.io.File
@SuppressLint("CustomSplashScreen")
class LaunchActivity : AppCompatActivity() {
+ private var prompted = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (!sharedPrefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) startTargetActivity(true)
+ }
+
+ // The prompt waits for window focus: Android 14's SystemUI dismisses a prompt shown while its
+ // activity is not yet on top (shown from onCreate), with ERROR_CANCELED, closing the app at once.
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus || prompted || isFinishing) return
val prefs = sharedPrefs
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false)) {
+ prompted = true
BiometricAuthenticator.authenticate(this) { result ->
when (result) {
is Result.Success -> {
@@ -45,27 +53,20 @@ class LaunchActivity : AppCompatActivity() {
is Result.Retry -> {}
}
}
- } else {
- startTargetActivity(true)
}
}
- private fun getDecryptIntent(): Intent {
- return Intent(this, DecryptActivity::class.java)
- }
-
private fun startTargetActivity(noAuth: Boolean) {
- // This activity is exported, so the file path is untrusted input: only forward it to
- // DecryptActivity when it points at a file inside the password store.
+ // This activity is exported, so the file path is untrusted input: only forward it when it
+ // points at a file inside the password store. MainActivity is not exported and trusts it.
val repoDir = PasswordRepository.getRepositoryDirectory()
- val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH)
- val intentToStart =
- if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir))
- getDecryptIntent().apply {
- putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath)
- putExtra(BasePGPActivity.EXTRA_REPO_PATH, repoDir.absolutePath)
- }
- else Intent(this, PasswordStore::class.java).setAction(Intent.ACTION_VIEW)
+ val filePath = intent.getStringExtra(EXTRA_FILE_PATH)
+ val intentToStart = Intent(this, MainActivity::class.java)
+ if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir))
+ intentToStart.putExtra(EXTRA_FILE_PATH, filePath)
+ // A plain start replaces the task, so an instance already open (one whose store vanished, say)
+ // is not left below the new one. An entry opens on top, back returns to where the user was.
+ else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intentToStart)
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
@@ -75,6 +76,9 @@ class LaunchActivity : AppCompatActivity() {
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
+ /** The entry to open; home screen shortcuts carry this exact key, never change it. */
+ const val EXTRA_FILE_PATH = "FILE_PATH"
+
/** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */
fun isFileInRepo(path: String?, repoDir: File): Boolean {
if (path.isNullOrEmpty()) return false
diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
new file mode 100644
index 00000000..ce5fb1df
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.main
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.compose.setContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import app.passwordstore.data.password.PasswordItem
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.APSTheme
+import app.passwordstore.ui.editor.EditorDestination
+import app.passwordstore.ui.editor.EditorForm
+import app.passwordstore.ui.editor.editorForm
+import app.passwordstore.ui.entry.EntryDestination
+import app.passwordstore.ui.entry.EntryViewModel
+import app.passwordstore.ui.git.base.BaseGitActivity
+import app.passwordstore.ui.list.ListDestination
+import app.passwordstore.ui.list.folderMoves
+import app.passwordstore.ui.settings.SettingsRoute
+import app.passwordstore.ui.settings.settingsGraph
+import app.passwordstore.ui.setup.GitServerRoute
+import app.passwordstore.ui.setup.SetupStart
+import app.passwordstore.ui.setup.WelcomeRoute
+import app.passwordstore.ui.setup.setupGraph
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.PreferenceKeys
+import app.passwordstore.util.shortcuts.ShortcutHandler
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.onFailure
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.serialization.Serializable
+
+/**
+ * The password list of the folder at [path], relative to the store root ("" is the root). The back
+ * stack holds one entry per folder level.
+ */
+@Serializable data class ListRoute(val path: String = "")
+
+/**
+ * One entry, by absolute path. Paths from [LaunchActivity] passed [LaunchActivity.isFileInRepo];
+ * the entry destination checks again before decrypting.
+ */
+@Serializable data class EntryRoute(val filePath: String)
+
+/**
+ * The editor: a new entry in [dir] (relative to the store root, "" is the root), or the entry
+ * [name] (file name without `.gpg`) there. An edit takes its plaintext from the entry view below it
+ * on the back stack, never from the route.
+ */
+@Serializable data class EditRoute(val dir: String, val name: String? = null)
+
+/**
+ * Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity],
+ * after its biometric gate and path check, and by the app itself through [setupIntent]. Extends
+ * [BaseGitActivity] (an AppCompatActivity, so also the FragmentActivity BiometricPrompt needs)
+ * because the git operations require one; it hosts no fragments.
+ */
+@AndroidEntryPoint
+class MainActivity : BaseGitActivity() {
+
+ @Inject lateinit var shortcutHandler: ShortcutHandler
+
+ /** Composed destinations that show secrets; FLAG_SECURE is on while this is above zero. */
+ private var secureHolds = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val filePath = intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH)
+ val setup =
+ intent.getStringExtra(EXTRA_SETUP)?.let { name ->
+ SetupStart.entries.firstOrNull { it.name == name }
+ }
+ // A restored back stack wins over this choice, so a recreation stays where it was.
+ val start: Any =
+ when {
+ filePath != null -> EntryRoute(filePath)
+ setup != null -> setup.route
+ checkStore() -> ListRoute()
+ else -> WelcomeRoute
+ }
+ setContent {
+ APSTheme {
+ val nav = rememberNavController()
+ NavHost(nav, startDestination = start) {
+ composable<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().path
+ ListDestination(
+ activity = this@MainActivity,
+ path = path,
+ onOpenFolder = { target ->
+ // Only the folder on top may move the stack: a double tap must not push twice.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ val (pops, pushes) = folderMoves(path, target)
+ repeat(pops) { nav.popBackStack() }
+ pushes.forEach { nav.navigate(ListRoute(it)) }
+ }
+ },
+ onOpenEntry = { file ->
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ addShortcut(file)
+ nav.navigate(EntryRoute(file.absolutePath))
+ }
+ },
+ onNewEntry = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path))
+ },
+ onClone = {
+ if (nav.currentBackStackEntry?.id == entry.id)
+ nav.navigate(GitServerRoute(clone = true))
+ },
+ onSettings = {
+ if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute)
+ },
+ onNoStore = {
+ // The store is gone (deleted from Settings): setup starts over.
+ if (nav.currentBackStackEntry?.id == entry.id)
+ nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<EntryRoute> { entry ->
+ val file = File(entry.toRoute<EntryRoute>().filePath)
+ SecureWindow(this@MainActivity)
+ EntryDestination(
+ activity = this@MainActivity,
+ file = file,
+ onClose = {
+ // Only the entry on top closes: the auto-close timer can race a back press.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (nav.previousBackStackEntry == null) finish() else nav.popBackStack()
+ }
+ },
+ onEdit = {
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ // Canonical folder, link's own name: a symlinked entry keeps its name.
+ val root = PasswordRepository.getRepositoryDirectory().canonicalFile
+ val dir = file.parentFile?.canonicalFile?.relativeTo(root)?.path.orEmpty()
+ nav.navigate(EditRoute(dir, file.nameWithoutExtension))
+ }
+ },
+ )
+ }
+ composable<EditRoute> { entry ->
+ val route = entry.toRoute<EditRoute>()
+ val name = route.name
+ // An edit reads the decrypted entry from the entry view below it. After process
+ // death that ViewModel is new and empty, and the editor closes.
+ val decrypted =
+ if (name == null) null
+ else
+ remember(entry) {
+ // No entry view below (none on the stack): getBackStackEntry throws.
+ try {
+ nav.getBackStackEntry<EntryRoute>()
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+ ?.let { hiltViewModel<EntryViewModel>(it).entry }
+ SecureWindow(this@MainActivity)
+ EditorDestination(
+ activity = this@MainActivity,
+ initial =
+ if (name == null) EditorForm(dir = route.dir)
+ else decrypted?.let { editorForm(it, route.dir, name) },
+ original =
+ name?.let {
+ File(File(PasswordRepository.getRepositoryDirectory(), route.dir), "$it.gpg")
+ },
+ onClose = { if (nav.currentBackStackEntry?.id == entry.id) nav.popBackStack() },
+ onSaved = {
+ // Back to the list, which re-reads the store on resume. An entry opened from a
+ // shortcut has no list below it: the list root replaces the stack.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ if (!nav.popBackStack<ListRoute>(inclusive = false))
+ nav.navigate(ListRoute()) { popUpTo<EntryRoute> { inclusive = true } }
+ }
+ },
+ )
+ }
+ setupGraph(this@MainActivity, nav)
+ settingsGraph(this@MainActivity, nav)
+ }
+ }
+ }
+ }
+
+ /** Counts a destination that shows secrets; the first one turns FLAG_SECURE on. */
+ fun holdSecure() {
+ if (secureHolds++ == 0) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Ends a [holdSecure]; FLAG_SECURE goes off when no secret-showing destination is left. */
+ fun releaseSecure() {
+ if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
+ /** Re-reads the store; false when there is none yet and setup has to run. */
+ fun checkStore(): Boolean {
+ PasswordRepository.initialize()
+ return sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
+ }
+
+ /** Git actions offered in the list's menu, chosen as the old list chose its menu. */
+ fun gitOps(): ImmutableList<GitOp> =
+ when {
+ gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL)
+ PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH)
+ else -> persistentListOf()
+ }
+
+ /** Runs [op]; failures show the usual git error dialog. */
+ suspend fun runGit(op: GitOp) {
+ launchGitOperation(op).onFailure { promptOnErrorHandler(it) }
+ }
+
+ /** Runs [op] (a hard reset against [branch]) and returns its outcome; the caller reports it. */
+ suspend fun gitOperation(op: GitOp, branch: String = ""): Result<Unit, Throwable> {
+ remoteBranch = branch
+ return launchGitOperation(op)
+ }
+
+ /** Adds [file] to the launcher's dynamic shortcuts, as opening it from the list always did. */
+ fun addShortcut(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addDynamicShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun pinEntry(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ private fun entryItem(file: File) =
+ PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
+
+ companion object {
+
+ private const val EXTRA_SETUP = "SETUP"
+
+ /**
+ * Opens one setup screen in an instance of its own, for callers outside the graph (autofill,
+ * the git operations' "No SSH key" dialog) and for those waiting for a result (the entry view's
+ * missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK
+ * once a key was imported.
+ */
+ // ponytail: a second instance instead of in-graph navigation. Autofill's activities stay
+ // outside the graph for good (the autofill service starts them); the git operations' "No SSH
+ // key" dialog could navigate in-graph once it becomes a Compose dialog.
+ fun setupIntent(context: Context, start: SetupStart): Intent =
+ Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name)
+ }
+}
+
+/**
+ * FLAG_SECURE while composed. Counted in [MainActivity], because in a transition (entry view to
+ * editor and back) the entering destination is composed before the leaving one is disposed.
+ */
+@Composable
+private fun SecureWindow(activity: MainActivity) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt
deleted file mode 100644
index 709c2302..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/activity/OnboardingActivity.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.activity
-
-import android.os.Bundle
-import androidx.activity.addCallback
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.hide()
- val callback = onBackPressedDispatcher.addCallback(enabled = false) { finishAffinity() }
- supportFragmentManager.addOnBackStackChangedListener {
- if (supportFragmentManager.backStackEntryCount == 0) {
- callback.isEnabled = true
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt
deleted file mode 100644
index a9d933b6..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.os.Bundle
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.FragmentCloneBinding
-import app.passwordstore.ui.git.config.GitServerConfigActivity
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.performTransactionWithBackStack
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-class CloneFragment : Fragment(R.layout.fragment_clone) {
-
- private val binding by viewBinding(FragmentCloneBinding::bind)
-
- private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs }
-
- private val cloneAction =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == AppCompatActivity.RESULT_OK) {
- settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
- finish()
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.cloneRemote.setOnClickListener { cloneToHiddenDir() }
- binding.createLocal.setOnClickListener { createRepository() }
- }
-
- /** Clones a remote Git repository to the app's private directory */
- private fun cloneToHiddenDir() {
- cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
-
- private fun createRepository() {
- val localDir = PasswordRepository.getRepositoryDirectory()
- runCatching {
- check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
- PasswordRepository.createRepository(localDir)
- if (!PasswordRepository.isInitialized) {
- PasswordRepository.initialize()
- }
- parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
- }
- .onFailure { e ->
- logcat(ERROR) { e.asLog() }
- if (!localDir.delete()) {
- logcat { "Failed to delete local repository: $localDir" }
- }
- finish()
- }
- }
-
- companion object {
-
- fun newInstance(): CloneFragment = CloneFragment()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt
deleted file mode 100644
index 64c2e124..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.view.View
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.FragmentKeySelectionBinding
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.pgp.PGPKeyListActivity
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.finish
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.PreferenceKeys
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-@AndroidEntryPoint
-class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
-
- @Inject @SettingsPreferences lateinit var settings: SharedPreferences
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- private val binding by viewBinding(FragmentKeySelectionBinding::bind)
- private val gpgKeySelectAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == AppCompatActivity.RESULT_OK) {
- val data = result.data ?: return@registerForActivityResult
- val selectedKey =
- data.getStringExtra(PGPKeyListActivity.EXTRA_SELECTED_KEY)
- ?: return@registerForActivityResult
- lifecycleScope.launch {
- withContext(dispatcherProvider.io()) {
- val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
- gpgIdentifierFile.writeText(selectedKey)
- }
- settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
- requireActivity()
- .commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
- finish()
- }
- } else {
- requireActivity()
- .snackbar(
- message = getString(R.string.gpg_key_select_mandatory),
- length = Snackbar.LENGTH_LONG,
- )
- }
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.selectKey.setOnClickListener {
- gpgKeySelectAction.launch(PGPKeyListActivity.newSelectionActivity(requireContext()))
- }
- }
-
- companion object {
-
- fun newInstance() = KeySelectionFragment()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt
deleted file mode 100644
index 44881c64..00000000
--- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/WelcomeFragment.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.onboarding.fragments
-
-import android.os.Bundle
-import android.view.View
-import androidx.annotation.Keep
-import androidx.fragment.app.Fragment
-import app.passwordstore.R
-import app.passwordstore.databinding.FragmentWelcomeBinding
-import app.passwordstore.ui.settings.SettingsActivity
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.performTransactionWithBackStack
-import app.passwordstore.util.extensions.viewBinding
-
-@Keep
-class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
-
- private val binding by viewBinding(FragmentWelcomeBinding::bind)
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.letsGo.setOnClickListener {
- parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
- }
- binding.settingsButton.setOnClickListener {
- requireActivity().launchActivity(SettingsActivity::class.java)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
deleted file mode 100644
index 917bb91c..00000000
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
+++ /dev/null
@@ -1,388 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.passwords
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.os.Parcelable
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.view.animation.Animation
-import android.view.animation.AnimationUtils
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.appcompat.view.ActionMode
-import androidx.core.content.edit
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.DividerItemDecoration
-import androidx.recyclerview.widget.LinearLayoutManager
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.databinding.PasswordRecyclerViewBinding
-import app.passwordstore.injection.prefs.SettingsPreferences
-import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter
-import app.passwordstore.ui.dialogs.BasicBottomSheet
-import app.passwordstore.ui.dialogs.ItemCreationBottomSheet
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.git.config.GitServerConfigActivity
-import app.passwordstore.ui.util.OnOffItemAnimator
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.settings.AuthMode
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PasswordSortOrder
-import app.passwordstore.util.settings.PreferenceKeys
-import app.passwordstore.util.shortcuts.ShortcutHandler
-import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import me.zhanghai.android.fastscroll.FastScrollerBuilder
-
-@AndroidEntryPoint
-class PasswordFragment : Fragment(R.layout.password_recycler_view) {
-
- @Inject lateinit var gitSettings: GitSettings
- @Inject lateinit var shortcutHandler: ShortcutHandler
- @Inject lateinit var dispatcherProvider: DispatcherProvider
- @Inject @SettingsPreferences lateinit var prefs: SharedPreferences
- private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
- private lateinit var listener: OnFragmentInteractionListener
- private lateinit var settings: SharedPreferences
-
- private var recyclerViewStateToRestore: Parcelable? = null
- private var actionMode: ActionMode? = null
- private var scrollTarget: File? = null
-
- private val model: SearchableRepositoryViewModel by activityViewModels()
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private val swipeResult =
- registerForActivityResult(StartActivityForResult()) {
- binding.swipeRefresher.isRefreshing = false
- requireStore().refreshPasswordList()
- }
-
- val currentDir: File
- get() = model.currentDir.value
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- settings = requireContext().sharedPrefs
- initializePasswordList()
- binding.fab.setOnClickListener {
- ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
- }
- childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) {
- _,
- bundle ->
- when (bundle.getString(ACTION_KEY)) {
- ACTION_FOLDER -> requireStore().createFolder()
- ACTION_PASSWORD -> requireStore().createPassword()
- }
- }
- }
-
- private fun initializePasswordList() {
- val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
- val hasGitDir =
- gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
- binding.swipeRefresher.setOnRefreshListener {
- if (!hasGitDir) {
- requireStore().refreshPasswordList()
- binding.swipeRefresher.isRefreshing = false
- } else if (!PasswordRepository.isGitRepo()) {
- BasicBottomSheet.Builder(requireContext())
- .setMessageRes(R.string.clone_git_repo)
- .setPositiveButtonClickListener(getString(R.string.clone_button)) {
- swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
- .build()
- .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
- binding.swipeRefresher.isRefreshing = false
- } else {
- // When authentication is set to AuthMode.None then the only git operation we can
- // run is a pull, so automatically fallback to that.
- val operationId =
- when (gitSettings.authMode) {
- AuthMode.None -> BaseGitActivity.GitOp.PULL
- else -> BaseGitActivity.GitOp.SYNC
- }
- requireStore().apply {
- lifecycleScope.launch {
- launchGitOperation(operationId)
- .fold(
- success = {
- binding.swipeRefresher.isRefreshing = false
- refreshPasswordList()
- },
- failure = { err ->
- promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false }
- },
- )
- }
- }
- }
- }
-
- recyclerAdapter =
- PasswordItemRecyclerAdapter(lifecycleScope, dispatcherProvider)
- .onItemClicked { _, item -> listener.onFragmentInteraction(item) }
- .onSelectionChanged { selection ->
- // In order to not interfere with drag selection, we disable the
- // SwipeRefreshLayout
- // once an item is selected.
- binding.swipeRefresher.isEnabled = selection.isEmpty
-
- if (actionMode == null)
- actionMode =
- requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
-
- if (!selection.isEmpty) {
- actionMode!!.title =
- resources.getQuantityString(
- R.plurals.delete_title,
- selection.size(),
- selection.size(),
- )
- actionMode!!.invalidate()
- } else {
- actionMode!!.finish()
- }
- }
- val recyclerView = binding.passRecycler
- recyclerView.apply {
- addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
- layoutManager = LinearLayoutManager(requireContext())
- itemAnimator = OnOffItemAnimator()
- adapter = recyclerAdapter
- }
-
- FastScrollerBuilder(recyclerView).build()
- recyclerAdapter.makeSelectable(recyclerView)
- registerForContextMenu(recyclerView)
-
- val path =
- requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
- "Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
- }
- model.navigateTo(File(path), pushPreviousLocation = false)
- lifecycleScope.launch {
- model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
- // Only run animations when the new list is filtered, i.e., the user submitted a search,
- // and not on folder navigation since the latter leads to too many removal animations.
- (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
- recyclerAdapter.submitList(result.passwordItems) {
- when {
- result.isFiltered -> {
- // When the result is filtered, we always scroll to the top since that is
- // where the best fuzzy match appears.
- recyclerView.scrollToPosition(0)
- }
- scrollTarget != null -> {
- scrollTarget?.let {
- recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
- }
- scrollTarget = null
- }
- else -> {
- // When the result is not filtered and there is a saved scroll position for
- // it, we try to restore it.
- recyclerViewStateToRestore?.let {
- recyclerView.layoutManager!!.onRestoreInstanceState(it)
- }
- recyclerViewStateToRestore = null
- }
- }
- }
- }
- }
- }
-
- private val actionModeCallback =
- object : ActionMode.Callback {
- // Called when the action mode is created; startActionMode() was called
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- // Inflate a menu resource providing context menu items
- mode.menuInflater.inflate(R.menu.context_pass, menu)
- // hide the fab
- animateFab(false)
- return true
- }
-
- // Called each time the action mode is shown. Always called after onCreateActionMode,
- // but may be called multiple times if the mode is invalidated.
- override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- val selectedItems = recyclerAdapter.getSelectedItems()
- menu.findItem(R.id.menu_edit_password).isVisible =
- selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY }
- menu.findItem(R.id.menu_pin_password).isVisible =
- selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD
- return true
- }
-
- // Called when the user selects a contextual menu item
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.menu_delete_password -> {
- requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
- // Action picked, so close the CAB
- mode.finish()
- true
- }
- R.id.menu_move_password -> {
- requireStore().movePasswords(recyclerAdapter.getSelectedItems())
- false
- }
- R.id.menu_edit_password -> {
- requireStore().renameCategory(recyclerAdapter.getSelectedItems())
- mode.finish()
- false
- }
- R.id.menu_pin_password -> {
- val passwordItem = recyclerAdapter.getSelectedItems()[0]
- shortcutHandler.addPinnedShortcut(
- passwordItem,
- passwordItem.createAuthEnabledIntent(requireContext()),
- )
- false
- }
- else -> false
- }
- }
-
- // Called when the user exits the action mode
- override fun onDestroyActionMode(mode: ActionMode) {
- recyclerAdapter.requireSelectionTracker().clearSelection()
- actionMode = null
- // show the fab
- animateFab(true)
- }
-
- private fun animateFab(show: Boolean) =
- with(binding.fab) {
- val animation =
- AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
- animation.setAnimationListener(
- object : Animation.AnimationListener {
- override fun onAnimationRepeat(animation: Animation?) {}
-
- override fun onAnimationEnd(animation: Animation?) {
- if (!show) visibility = View.GONE
- }
-
- override fun onAnimationStart(animation: Animation?) {
- if (show) visibility = View.VISIBLE
- }
- }
- )
- animate()
- .rotationBy(if (show) -90f else 90f)
- .setStartDelay(if (show) 100 else 0)
- .setDuration(100)
- .start()
- startAnimation(animation)
- }
- }
-
- override fun onResume() {
- super.onResume()
- binding.swipeRefresher.isEnabled = !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false)
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- runCatching {
- listener =
- object : OnFragmentInteractionListener {
- override fun onFragmentInteraction(item: PasswordItem) {
- if (
- settings.getString(PreferenceKeys.SORT_ORDER) ==
- PasswordSortOrder.RECENTLY_USED.name
- ) {
- // save the time when password was used
- val preferences =
- context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- preferences.edit {
- putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
- }
- }
-
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- navigateTo(item.file)
- } else {
- if (requireArguments().getBoolean("matchWith", false)) {
- requireStore().matchPasswordWithApp(item)
- } else {
- requireStore().decryptPassword(item)
- }
- }
- }
- }
- }
- .onFailure {
- throw ClassCastException("$context must implement OnFragmentInteractionListener")
- }
- }
-
- private fun requireStore() = requireActivity() as PasswordStore
-
- /** Returns true if the back press was handled by the [Fragment]. */
- fun onBackPressedInActivity(): Boolean {
- if (!model.canNavigateBack) return false
- // The RecyclerView state is restored when the asynchronous update operation on the
- // adapter is completed.
- recyclerViewStateToRestore = model.navigateBack()
- if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
- return true
- }
-
- fun dismissActionMode() {
- actionMode?.finish()
- }
-
- companion object {
-
- const val ITEM_CREATION_REQUEST_KEY = "creation_key"
- const val ACTION_KEY = "action"
- const val ACTION_FOLDER = "folder"
- const val ACTION_PASSWORD = "password"
-
- fun newInstance(args: Bundle): PasswordFragment {
- val fragment = PasswordFragment()
- fragment.arguments = args
- return fragment
- }
- }
-
- fun navigateTo(file: File) {
- requireStore().clearSearch()
- model.navigateTo(
- file,
- recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState(),
- )
- requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
- }
-
- fun scrollToOnNextRefresh(file: File) {
- scrollTarget = file
- }
-
- interface OnFragmentInteractionListener {
-
- fun onFragmentInteraction(item: PasswordItem)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
deleted file mode 100644
index 059b398f..00000000
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
+++ /dev/null
@@ -1,621 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.passwords
-
-import android.content.ComponentName
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MenuItem.OnActionExpandListener
-import android.view.WindowManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.activity.viewModels
-import androidx.appcompat.widget.SearchView
-import androidx.appcompat.widget.SearchView.OnQueryTextListener
-import androidx.core.content.edit
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import androidx.lifecycle.flowWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.password.PasswordItem
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.crypto.BasePGPActivity
-import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName
-import app.passwordstore.ui.crypto.DecryptActivity
-import app.passwordstore.ui.crypto.PasswordCreationActivity
-import app.passwordstore.ui.dialogs.FolderCreationDialogFragment
-import app.passwordstore.ui.folderselect.SelectFolderActivity
-import app.passwordstore.ui.git.base.BaseGitActivity
-import app.passwordstore.ui.onboarding.activity.OnboardingActivity
-import app.passwordstore.ui.settings.SettingsActivity
-import app.passwordstore.util.autofill.AutofillMatcher
-import app.passwordstore.util.extensions.base64
-import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.contains
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.isInsideRepository
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.listFilesRecursively
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.settings.AuthMode
-import app.passwordstore.util.settings.PreferenceKeys
-import app.passwordstore.util.shortcuts.ShortcutHandler
-import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.google.android.material.textfield.TextInputEditText
-import dagger.hilt.android.AndroidEntryPoint
-import java.io.File
-import java.lang.Character.UnicodeBlock
-import javax.inject.Inject
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-
-const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
-
-@AndroidEntryPoint
-class PasswordStore : BaseGitActivity() {
-
- @Inject lateinit var shortcutHandler: ShortcutHandler
- private lateinit var searchItem: MenuItem
- private val settings by lazy { sharedPrefs }
-
- private val model: SearchableRepositoryViewModel by viewModels()
-
- private val listRefreshAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- refreshPasswordList()
- }
- }
-
- private val passwordMoveAction =
- registerForActivityResult(StartActivityForResult()) { result ->
- val intentData = result.data ?: return@registerForActivityResult
- val filesToMove =
- requireNotNull(intentData.getStringArrayExtra("Files")) {
- "'Files' intent extra must be set"
- }
- val target =
- File(
- requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")) {
- "'SELECTED_FOLDER_PATH' intent extra must be set"
- }
- )
- val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
- if (!target.isDirectory) {
- logcat(ERROR) { "Tried moving passwords to a non-existing folder." }
- return@registerForActivityResult
- }
-
- logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
- logcat { filesToMove.joinToString(", ") }
-
- lifecycleScope.launch(dispatcherProvider.io()) {
- for (file in filesToMove) {
- val source = File(file)
- if (!source.exists()) {
- logcat(ERROR) { "Tried moving something that appears non-existent." }
- continue
- }
- val destinationFile = File(target.absolutePath + "/" + source.name)
- val basename = source.nameWithoutExtension
- val sourceLongName =
- getLongName(
- requireNotNull(source.parent) { "$file has no parent" },
- repositoryPath,
- basename,
- )
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- if (destinationFile.exists()) {
- logcat(ERROR) { "Trying to move a file that already exists." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(resources.getString(R.string.password_exists_title))
- .setMessage(
- resources.getString(
- R.string.password_exists_message,
- destinationLongName,
- sourceLongName,
- )
- )
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .show()
- }
- } else {
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- }
- when (filesToMove.size) {
- 1 -> {
- val source = File(filesToMove[0])
- val basename = source.nameWithoutExtension
- val sourceLongName =
- getLongName(
- requireNotNull(source.parent) { "$basename has no parent" },
- repositoryPath,
- basename,
- )
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- sourceLongName,
- destinationLongName,
- )
- )
- }
- }
- else -> {
- val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
- val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(R.string.git_commit_move_multiple_text, relativePath)
- )
- }
- }
- }
- }
- refreshPasswordList()
- getPasswordFragment()?.dismissActionMode()
- }
-
- override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- // open search view on search key, or Ctr+F
- if (
- (keyCode == KeyEvent.KEYCODE_SEARCH ||
- keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded
- ) {
- searchItem.expandActionView()
- return true
- }
-
- // open search view on any printable character and query for it
- val c = event.unicodeChar.toChar()
- val printable = isPrintable(c)
- if (printable && !searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- (searchItem.actionView as SearchView).setQuery(c.toString(), true)
- return true
- }
- return super.onKeyDown(keyCode, event)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(R.layout.activity_pwdstore)
-
- lifecycleScope.launch {
- model.currentDir.flowWithLifecycle(lifecycle).collect { dir ->
- val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
- supportActionBar?.apply {
- if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
- }
- }
- }
- }
-
- override fun onStart() {
- super.onStart()
- refreshPasswordList()
- }
-
- override fun onResume() {
- super.onResume()
- checkLocalRepository()
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
- if (!searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- val menuRes =
- when {
- gitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
- PasswordRepository.isGitRepo() -> R.menu.main_menu_git
- else -> R.menu.main_menu_non_git
- }
- menuInflater.inflate(menuRes, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onPrepareOptionsMenu(menu: Menu): Boolean {
- // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
- // we can get by without any noticeable difference in performance.
- invalidateOptionsMenu()
- searchItem = menu.findItem(R.id.action_search)
- val searchView = searchItem.actionView as SearchView
- searchView.setOnQueryTextListener(
- object : OnQueryTextListener {
- override fun onQueryTextSubmit(s: String): Boolean {
- searchView.clearFocus()
- return true
- }
-
- override fun onQueryTextChange(s: String): Boolean {
- val filter = s.trim()
- // List the contents of the current directory if the user enters a blank
- // search term.
- if (filter.isEmpty())
- model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false)
- else model.search(filter)
- return true
- }
- }
- )
-
- // When using the support library, the setOnActionExpandListener() method is
- // static and accepts the MenuItem object as an argument
- searchItem.setOnActionExpandListener(
- object : OnActionExpandListener {
- override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
- refreshPasswordList()
- return true
- }
-
- override fun onMenuItemActionExpand(item: MenuItem): Boolean {
- return true
- }
- }
- )
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
- searchItem.expandActionView()
- }
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- val id = item.itemId
- val initBefore =
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- when (id) {
- R.id.user_pref -> {
- runCatching { launchActivity(SettingsActivity::class.java) }
- .onFailure { e -> e.printStackTrace() }
- }
- R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
- }
- R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
- }
- R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
- }
- R.id.refresh -> refreshPasswordList()
- android.R.id.home -> {
- @Suppress("DEPRECATION") onBackPressed()
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- @Deprecated("Deprecated in Java")
- @Suppress("DEPRECATION")
- override fun onBackPressed() {
- if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed()
- }
-
- private fun getPasswordFragment(): PasswordFragment? {
- return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
- }
-
- fun clearSearch() {
- if (searchItem.isActionViewExpanded) searchItem.collapseActionView()
- }
-
- private fun runGitOperation(operation: GitOp) =
- lifecycleScope.launch {
- launchGitOperation(operation)
- .fold(success = { refreshPasswordList() }, failure = { promptOnErrorHandler(it) })
- }
-
- private fun checkLocalRepository() {
- PasswordRepository.initialize()
- checkLocalRepository(PasswordRepository.getRepositoryDirectory())
- }
-
- private fun checkLocalRepository(localDir: File?) {
- if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
- // do not push the fragment if we already have it
- if (
- getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)
- ) {
- settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
- val args = Bundle()
- args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
-
- // if the activity was started from the autofill settings, the
- // intent is to match a clicked pwd with app. pass this to fragment
- if (intent.getBooleanExtra("matchWith", false)) {
- args.putBoolean("matchWith", true)
- }
- supportActionBar?.apply {
- show()
- setDisplayHomeAsUpEnabled(false)
- }
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- supportFragmentManager.commit {
- replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
- }
- }
- } else {
- launchActivity(OnboardingActivity::class.java)
- }
- }
-
- private fun getRelativePath(fullPath: String, repositoryPath: String): String {
- return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
- }
-
- fun decryptPassword(item: PasswordItem) {
- val authDecryptIntent = item.createAuthEnabledIntent(this)
- val decryptIntent =
- Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java))
-
- startActivity(decryptIntent)
-
- // Adds shortcut
- shortcutHandler.addDynamicShortcut(item, authDecryptIntent)
- }
-
- private fun validateState(): Boolean {
- if (!PasswordRepository.isInitialized) {
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- .show()
- return false
- }
- return true
- }
-
- fun createPassword() {
- if (!validateState()) return
- val currentDir = currentDir
- logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" }
- val intent = Intent(this, PasswordCreationActivity::class.java)
- intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePath)
- intent.putExtra(
- BasePGPActivity.EXTRA_REPO_PATH,
- PasswordRepository.getRepositoryDirectory().absolutePath,
- )
- listRefreshAction.launch(intent)
- }
-
- fun createFolder() {
- if (!validateState()) return
- FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
- }
-
- fun deletePasswords(selectedItems: List<PasswordItem>) {
- var size = 0
- selectedItems.forEach {
- if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size
- }
- if (size == 0) {
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- return
- }
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
- .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
- val filesToDelete = arrayListOf<File>()
- selectedItems.forEach { item ->
- if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
- else filesToDelete.add(item.file)
- }
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
- val fmt =
- selectedItems.joinToString(separator = ", ") { item ->
- item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
- }
- lifecycleScope.launch {
- commitChange(resources.getString(R.string.git_commit_remove_text, fmt))
- }
- }
- .setNegativeButton(resources.getString(R.string.dialog_no), null)
- .show()
- }
-
- fun movePasswords(values: List<PasswordItem>) {
- val intent = Intent(this, SelectFolderActivity::class.java)
- val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
- intent.putExtra("Files", fileLocations)
- passwordMoveAction.launch(intent)
- }
-
- enum class CategoryRenameError(val resource: Int) {
- None(0),
- EmptyField(R.string.message_category_error_empty_field),
- CategoryExists(R.string.message_category_error_category_exists),
- DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
- }
-
- /**
- * Prompt the user with a new category name to assign, if the new category forms/leads a path
- * (i.e. contains "/"), intermediate directories will be created and new category will be placed
- * inside.
- *
- * @param oldCategory The category to change its name
- * @param error Determines whether to show an error to the user in the alert dialog, this error
- * may be due to the new category the user entered already exists or the field was empty or the
- * destination path is outside the repository
- * @see [CategoryRenameError]
- * @see [isInsideRepository]
- */
- private fun renameCategory(
- oldCategory: PasswordItem,
- error: CategoryRenameError = CategoryRenameError.None,
- ) {
- val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
- val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
-
- if (error != CategoryRenameError.None) {
- newCategoryEditText.error = getString(error.resource)
- }
-
- val dialog =
- MaterialAlertDialogBuilder(this)
- .setTitle(R.string.title_rename_folder)
- .setView(view)
- .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
- when {
- newCategoryEditText.text.isNullOrBlank() ->
- renameCategory(oldCategory, CategoryRenameError.EmptyField)
- newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
- !newCategory.isInsideRepository() ->
- renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
- else ->
- lifecycleScope.launch(dispatcherProvider.io()) {
- moveFile(oldCategory.file, newCategory)
-
- // associate the new category with the last category's timestamp in
- // history
- val preference =
- getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
- if (timestamp != null) {
- preference.edit {
- remove(oldCategory.file.absolutePath.base64())
- putString(newCategory.absolutePath.base64(), timestamp)
- }
- }
-
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- oldCategory.name,
- newCategory.name,
- )
- )
- }
- }
- }
- }
- .setNegativeButton(R.string.dialog_skip, null)
- .create()
-
- dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- dialog.show()
- }
-
- fun renameCategory(categories: List<PasswordItem>) {
- for (oldCategory in categories) {
- renameCategory(oldCategory)
- }
- }
-
- /**
- * Refreshes the password list by re-executing the last navigation or search action, preserving
- * the navigation stack and scroll position. If the current directory no longer exists, navigation
- * is reset to the repository root. If the optional [target] argument is provided, it will be
- * entered if it is a directory or scrolled into view if it is a file (both inside the current
- * directory).
- */
- fun refreshPasswordList(target: File? = null) {
- val plist = getPasswordFragment()
- if (target?.isDirectory == true && model.currentDir.value.contains(target)) {
- plist?.navigateTo(target)
- } else if (target?.isFile == true && model.currentDir.value.contains(target)) {
- // Creating new passwords is handled by an activity, so we will refresh in onStart.
- plist?.scrollToOnNextRefresh(target)
- } else if (model.currentDir.value.isDirectory) {
- model.forceRefresh()
- } else {
- model.reset()
- supportActionBar?.setDisplayHomeAsUpEnabled(false)
- }
- }
-
- private val currentDir: File
- get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
-
- private suspend fun moveFile(source: File, destinationFile: File) {
- val sourceDestinationMap =
- if (source.isDirectory) {
- destinationFile.mkdirs()
- // Recursively list all files (not directories) below `source`, then
- // obtain the corresponding target file by resolving the relative path
- // starting at the destination folder.
- source.listFilesRecursively().associateWith {
- destinationFile.resolve(it.relativeTo(source))
- }
- } else {
- mapOf(source to destinationFile)
- }
- if (!source.renameTo(destinationFile)) {
- logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(R.string.password_move_error_title)
- .setMessage(getString(R.string.password_move_error_message, source, destinationFile))
- .setCancelable(true)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- }
- } else {
- AutofillMatcher.updateMatches(this, sourceDestinationMap)
- }
- }
-
- fun matchPasswordWithApp(item: PasswordItem) {
- val path =
- item.file.absolutePath
- .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
- .replace(".gpg", "")
- val data = Intent()
- data.putExtra("path", path)
- setResult(RESULT_OK, data)
- finish()
- }
-
- companion object {
-
- const val REQUEST_ARG_PATH = "PATH"
-
- private fun isPrintable(c: Char): Boolean {
- val block = UnicodeBlock.of(c)
- return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
deleted file mode 100644
index d276c552..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyImportActivity.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-@file:Suppress("BlockingMethodInNonBlockingContext")
-
-package app.passwordstore.ui.pgp
-
-import android.os.Bundle
-import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import app.passwordstore.crypto.KeyUtils.tryGetId
-import app.passwordstore.crypto.PGPKey
-import app.passwordstore.crypto.PGPKeyManager
-import app.passwordstore.crypto.errors.KeyAlreadyExistsException
-import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlinx.coroutines.runBlocking
-
-@AndroidEntryPoint
-class PGPKeyImportActivity : AppCompatActivity() {
-
- /**
- * A [ByteArray] containing the contents of the previously selected file. This is necessary for
- * the replacement case where we do not want users to have to pick the file again.
- */
- private var lastBytes: ByteArray? = null
- @Inject lateinit var keyManager: PGPKeyManager
-
- private val pgpKeyImportAction =
- registerForActivityResult(OpenDocument()) { uri ->
- runCatching {
- if (uri == null) {
- return@runCatching null
- }
- val keyInputStream =
- contentResolver.openInputStream(uri)
- ?: throw IllegalStateException("Failed to open selected file")
- val bytes = keyInputStream.use { `is` -> `is`.readBytes() }
- importKey(bytes, false)
- }
- .run(::handleImportResult)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- pgpKeyImportAction.launch(arrayOf("*/*"))
- }
-
- override fun onDestroy() {
- lastBytes = null
- super.onDestroy()
- }
-
- private fun importKey(bytes: ByteArray, replace: Boolean): PGPKey? {
- lastBytes = bytes
- val (key, error) = runBlocking { keyManager.addKey(PGPKey(bytes), replace = replace) }
- if (replace) {
- lastBytes = null
- }
- if (error != null) throw error
- return key
- }
-
- private fun handleImportResult(result: Result<PGPKey?, Throwable>) {
- if (result.isOk) {
- val key = result.value
- if (key == null) {
- setResult(RESULT_CANCELED)
- finish()
- // This return convinces Kotlin that the control flow for `key == null` definitely
- // terminates here and allows for a smart cast below.
- return
- }
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_succeeded))
- .setMessage(getString(R.string.pgp_key_import_succeeded_message, tryGetId(key)))
- .setPositiveButton(android.R.string.ok) { _, _ ->
- setResult(RESULT_OK)
- finish()
- }
- .setCancelable(false)
- .show()
- } else {
- if (result.error is KeyAlreadyExistsException && lastBytes != null) {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(getString(R.string.pgp_key_import_failed_replace_message))
- .setPositiveButton(R.string.dialog_yes) { _, _ ->
- handleImportResult(runCatching { importKey(lastBytes!!, replace = true) })
- }
- .setNegativeButton(R.string.dialog_no) { _, _ -> finish() }
- .setCancelable(false)
- .show()
- } else {
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.pgp_key_import_failed))
- .setMessage(result.error.message)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- .setCancelable(false)
- .show()
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt
deleted file mode 100644
index 312f5fb0..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyList.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.pgp
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import app.passwordstore.R
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.ui.compose.theme.SpacingLarge
-import app.passwordstore.util.extensions.conditional
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toPersistentList
-
-@Composable
-fun KeyList(
- identifiers: ImmutableList<PGPIdentifier>,
- onItemClick: (identifier: PGPIdentifier) -> Unit,
- modifier: Modifier = Modifier,
- onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null,
-) {
- if (identifiers.isEmpty()) {
- Column(
- modifier = modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_launcher_foreground),
- contentDescription = "Password Store logo",
- )
- Text(stringResource(R.string.pgp_key_manager_no_keys_guidance))
- }
- } else {
- LazyColumn(modifier = modifier) {
- items(identifiers) { identifier ->
- KeyItem(identifier = identifier, onItemClick = onItemClick, onKeySelected = onKeySelected)
- }
- }
- }
-}
-
-@Composable
-private fun KeyItem(
- identifier: PGPIdentifier,
- onItemClick: (identifier: PGPIdentifier) -> Unit,
- modifier: Modifier = Modifier,
- onKeySelected: ((identifier: PGPIdentifier) -> Unit)? = null,
-) {
- var isDeleting by remember { mutableStateOf(false) }
- DeleteConfirmationDialog(
- isDeleting = isDeleting,
- onDismiss = { isDeleting = false },
- onConfirm = {
- onItemClick(identifier)
- isDeleting = false
- },
- )
- val label =
- when (identifier) {
- is PGPIdentifier.KeyId -> identifier.id.toString()
- is PGPIdentifier.UserId -> identifier.email
- }
- Row(
- modifier =
- modifier.padding(SpacingLarge).fillMaxWidth().conditional(onKeySelected != null) {
- clickable { onKeySelected?.invoke(identifier) }
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = label,
- modifier = Modifier.weight(1f),
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- )
- if (onKeySelected == null) {
- IconButton(onClick = { isDeleting = true }) {
- Icon(
- painter = painterResource(R.drawable.ic_delete_24dp),
- stringResource(id = R.string.delete),
- )
- }
- }
- }
-}
-
-@Suppress("NOTHING_TO_INLINE")
-@Composable
-private inline fun DeleteConfirmationDialog(
- isDeleting: Boolean,
- noinline onDismiss: () -> Unit,
- noinline onConfirm: () -> Unit,
-) {
- if (isDeleting) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = {
- Text(text = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title))
- },
- confirmButton = {
- TextButton(onClick = onConfirm) { Text(text = stringResource(R.string.dialog_yes)) }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) { Text(text = stringResource(R.string.dialog_no)) }
- },
- )
- }
-}
-
-@Preview
-@Composable
-private fun KeyListPreview() {
- APSTheme {
- Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
- KeyList(
- identifiers =
- listOfNotNull(
- PGPIdentifier.fromString("ultramicroscopicsilicovolcanoconiosis@example.com"),
- PGPIdentifier.fromString("0xB950AE2813841585"),
- )
- .toPersistentList(),
- onItemClick = {},
- )
- }
- }
-}
-
-@Preview
-@Composable
-private fun EmptyKeyListPreview() {
- APSTheme {
- Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) {
- KeyList(identifiers = persistentListOf(), onItemClick = {})
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt b/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
deleted file mode 100644
index 77b9aaf1..00000000
--- a/app/src/main/java/app/passwordstore/ui/pgp/PGPKeyListActivity.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.pgp
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.activity.viewModels
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import app.passwordstore.R
-import app.passwordstore.ui.APSAppBar
-import app.passwordstore.ui.compose.theme.APSTheme
-import app.passwordstore.util.viewmodel.PGPKeyListViewModel
-import dagger.hilt.android.AndroidEntryPoint
-
-@AndroidEntryPoint
-class PGPKeyListActivity : ComponentActivity() {
-
- private val viewModel: PGPKeyListViewModel by viewModels()
- private val keyImportAction =
- registerForActivityResult(StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- viewModel.updateKeySet()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val isSelecting = intent.extras?.getBoolean(EXTRA_KEY_SELECTION) ?: false
- setContent {
- APSTheme {
- Scaffold(
- topBar = {
- APSAppBar(
- title = stringResource(R.string.activity_label_pgp_key_manager),
- navigationIcon = painterResource(R.drawable.ic_arrow_back_black_24dp),
- onNavigationIconClick = { finish() },
- backgroundColor = MaterialTheme.colorScheme.surface,
- )
- },
- floatingActionButton = {
- FloatingActionButton(
- onClick = { keyImportAction.launch(Intent(this, PGPKeyImportActivity::class.java)) }
- ) {
- Icon(
- painter = painterResource(R.drawable.ic_add_48dp),
- stringResource(R.string.pref_import_pgp_key_title),
- )
- }
- },
- ) { paddingValues ->
- KeyList(
- identifiers = viewModel.keys,
- onItemClick = viewModel::deleteKey,
- modifier = Modifier.padding(paddingValues),
- onKeySelected =
- if (isSelecting) {
- { identifier ->
- val result = Intent()
- result.putExtra(EXTRA_SELECTED_KEY, identifier.toString())
- setResult(RESULT_OK, result)
- finish()
- }
- } else null,
- )
- }
- }
- }
- }
-
- companion object {
- const val EXTRA_SELECTED_KEY = "SELECTED_KEY"
- private const val EXTRA_KEY_SELECTION = "KEY_SELECTION_MODE"
-
- fun newSelectionActivity(context: Context): Intent {
- val intent = Intent(context, PGPKeyListActivity::class.java)
- intent.putExtra(EXTRA_KEY_SELECTION, true)
- return intent
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt
deleted file mode 100644
index cb5fbf40..00000000
--- a/app/src/main/java/app/passwordstore/ui/proxy/ProxySelectorActivity.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.proxy
-
-import android.content.SharedPreferences
-import android.net.InetAddresses
-import android.os.Build
-import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
-import android.util.Patterns
-import android.view.MenuItem
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.core.os.postDelayed
-import androidx.core.widget.doOnTextChanged
-import app.passwordstore.R
-import app.passwordstore.databinding.ActivityProxySelectorBinding
-import app.passwordstore.injection.prefs.ProxyPreferences
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.proxy.ProxyUtils
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PreferenceKeys
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class ProxySelectorActivity : AppCompatActivity() {
-
- @Inject lateinit var gitSettings: GitSettings
- @ProxyPreferences @Inject lateinit var proxyPrefs: SharedPreferences
- @Inject lateinit var proxyUtils: ProxyUtils
- private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- with(binding) {
- proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
- proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
- proxyPrefs
- .getInt(PreferenceKeys.PROXY_PORT, -1)
- .takeIf { it != -1 }
- ?.let { proxyPort.setText("$it") }
- proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
- save.setOnClickListener { saveSettings() }
- proxyHost.doOnTextChanged { text, _, _, _ ->
- if (text != null) {
- proxyHost.error =
- if (isNumericAddress(text) || text.matches(WEB_ADDRESS_REGEX)) {
- null
- } else {
- getString(R.string.invalid_proxy_url)
- }
- }
- }
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
-
- private fun isNumericAddress(text: CharSequence): Boolean {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- InetAddresses.isNumericAddress(text as String)
- } else {
- @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
- }
- }
-
- private fun saveSettings() {
- proxyPrefs.edit {
- binding.proxyHost.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyHost = it }
- binding.proxyUser.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyUsername = it }
- binding.proxyPort.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- ?.let { gitSettings.proxyPort = it.toInt() }
- binding.proxyPassword.text
- ?.toString()
- ?.takeIf { it.isNotEmpty() }
- .let { gitSettings.proxyPassword = it }
- }
- proxyUtils.setDefaultProxy()
- Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
- }
-
- private companion object {
- private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt
deleted file mode 100644
index afb2a691..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.provider.Settings
-import androidx.appcompat.widget.AppCompatTextView
-import androidx.core.net.toUri
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleEventObserver
-import app.passwordstore.BuildConfig
-import app.passwordstore.R
-import app.passwordstore.util.extensions.autofillManager
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
-import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.SwitchPreference
-
-class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val isAutofillServiceEnabled: Boolean
- get() {
- return activity.autofillManager?.hasEnabledAutofillServices() == true
- }
-
- private fun showAutofillDialog(pref: SwitchPreference) {
- val observer = LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> {
- pref.checked = isAutofillServiceEnabled
- }
- else -> {}
- }
- }
- MaterialAlertDialogBuilder(activity).run {
- setTitle(R.string.pref_autofill_enable_title)
- @SuppressLint("InflateParams")
- val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
- val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
- supportedBrowsersTextView.text =
- getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
- val appLabel = it.first
- val supportDescription =
- when (it.second) {
- BrowserAutofillSupportLevel.None ->
- activity.getString(R.string.oreo_autofill_no_support)
- BrowserAutofillSupportLevel.FlakyFill ->
- activity.getString(R.string.oreo_autofill_flaky_fill_support)
- BrowserAutofillSupportLevel.PasswordFill ->
- activity.getString(R.string.oreo_autofill_password_fill_support)
- BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
- activity.getString(
- R.string.oreo_autofill_password_fill_and_conditional_save_support
- )
- BrowserAutofillSupportLevel.GeneralFill ->
- activity.getString(R.string.oreo_autofill_general_fill_support)
- BrowserAutofillSupportLevel.GeneralFillAndSave ->
- activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
- }
- "$appLabel: $supportDescription"
- }
- setView(layout)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val intent =
- Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
- data = "package:${BuildConfig.APPLICATION_ID}".toUri()
- }
- activity.startActivity(intent)
- }
- setNegativeButton(R.string.dialog_cancel, null)
- setOnDismissListener { pref.checked = isAutofillServiceEnabled }
- activity.lifecycle.addObserver(observer)
- show()
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- switch(PreferenceKeys.AUTOFILL_ENABLE) {
- titleRes = R.string.pref_autofill_enable_title
- defaultValue = isAutofillServiceEnabled
- onClick {
- if (isAutofillServiceEnabled) {
- activity.autofillManager?.disableAutofillServices()
- } else {
- showAutofillDialog(this)
- }
- false
- }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_default_username_title
- summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_custom_public_suffixes_title
- summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
- textInputHintRes = R.string.preference_custom_public_suffixes_hint
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt
deleted file mode 100644
index e3594269..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.pm.ShortcutManager
-import android.os.Build
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.settings.DirectoryStructure
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-
-class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
- val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
- val themeItems =
- themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.APP_THEME, themeItems) {
- initialSelection = activity.resources.getString(R.string.app_theme_def)
- titleRes = R.string.pref_app_theme_title
- }
-
- val values = activity.resources.getStringArray(R.array.directory_structure_values)
- val titles = activity.resources.getStringArray(R.array.directory_structure_entries)
- val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.DIRECTORY_STRUCTURE, items) {
- initialSelection = DirectoryStructure.DEFAULT.value
- titleRes = R.string.pref_directory_structure
- }
-
- val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
- val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
- val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
- initialSelection = sortValues[0]
- titleRes = R.string.pref_sort_order_title
- }
-
- switch(PreferenceKeys.DISABLE_SYNC_ACTION) {
- titleRes = R.string.pref_disable_sync_on_pull_title
- summaryRes = R.string.pref_disable_sync_on_pull_summary
- defaultValue = false
- }
-
- switch(PreferenceKeys.FILTER_RECURSIVELY) {
- titleRes = R.string.pref_recursive_filter_title
- summaryRes = R.string.pref_recursive_filter_summary
- defaultValue = true
- }
-
- switch(PreferenceKeys.SEARCH_ON_START) {
- titleRes = R.string.pref_search_on_start_title
- summaryRes = R.string.pref_search_on_start_summary
- defaultValue = false
- }
-
- switch(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
- titleRes = R.string.pref_show_hidden_title
- summaryRes = R.string.pref_show_hidden_summary
- defaultValue = false
- }
-
- // See https://github.com/android-password-store/Android-Password-Store/issues/2802
- val disableAuth = Build.VERSION.SDK_INT == Build.VERSION_CODES.UPSIDE_DOWN_CAKE
- val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
- switch(PreferenceKeys.BIOMETRIC_AUTH_2) {
- titleRes = R.string.pref_biometric_auth_title
- defaultValue = false
- enabled = !disableAuth
- summaryRes =
- if (disableAuth) R.string.pref_biometric_auth_summary_disabled_platform
- else if (canAuthenticate) R.string.pref_biometric_auth_summary
- else R.string.pref_biometric_auth_summary_error
- onClick {
- enabled = false
- val isChecked = checked
- activity.sharedPrefs.edit {
- BiometricAuthenticator.authenticate(activity) { result ->
- when (result) {
- is Result.Success -> {
- // Apply the changes
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, checked)
- enabled = true
- }
- is Result.Retry -> {}
- else -> {
- // If any error occurs, revert back to the previous
- // state. This
- // catch-all clause includes the cancellation case.
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, !checked)
- checked = !isChecked
- enabled = true
- }
- }
- }
- }
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
new file mode 100644
index 00000000..d008eea3
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt
@@ -0,0 +1,329 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Author name and email with Save, then the repository utilities: HEAD ([head], already a
+ * sentence), commit log, abort rebase ([canAbort]), hard reset, gc. [busy] disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitConfigScreen(
+ name: String,
+ email: String,
+ head: String,
+ canAbort: Boolean,
+ busy: Boolean,
+ onNameChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onLog: () -> Unit,
+ onAbort: () -> Unit,
+ onReset: () -> Unit,
+ onGc: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.pref_edit_git_config)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = name,
+ onValueChange = onNameChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_name_hint)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ modifier = field,
+ enabled = !busy,
+ label = { Text(stringResource(R.string.git_user_email)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = Modifier.align(Alignment.End), enabled = !busy) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ Text(stringResource(R.string.git_tools), style = MaterialTheme.typography.titleLarge)
+ Text(head)
+ if (busy) LinearProgressIndicator(field)
+ OutlinedButton(onClick = onLog, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_log))
+ }
+ OutlinedButton(onClick = onAbort, modifier = field, enabled = canAbort && !busy) {
+ Text(stringResource(R.string.abort_rebase))
+ }
+ OutlinedButton(onClick = onReset, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.reset_to_remote))
+ }
+ OutlinedButton(onClick = onGc, modifier = field, enabled = !busy) {
+ Text(stringResource(R.string.git_run_gc_job))
+ }
+ }
+ }
+}
+
+/** Asks before a hard reset, for the remote [branch] to reset to. */
+@Composable
+fun ResetDialog(
+ branch: String,
+ onBranchChange: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.reset_to_remote)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(SpacingLarge)) {
+ Text(stringResource(R.string.git_reset_confirm_message))
+ OutlinedTextField(
+ value = branch,
+ onValueChange = onBranchChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.git_utils_reset_remote_branch_title)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm, enabled = branch.isNotBlank()) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+private enum class GitConfigDialog {
+ InvalidEmail,
+ Reset,
+}
+
+/**
+ * The git config screen: wires [GitConfigScreen] to the [viewModel] and runs the utilities as git
+ * operations of [activity]. A successful reset or gc stays here with a short snackbar; an abort
+ * shows its report, which closes the screen; a failure shows the git error dialog and the screen
+ * stays.
+ */
+@Composable
+fun GitConfigDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onLog: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitConfigViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<GitConfigDialog?>(null) }
+ var branch by rememberSaveable { mutableStateOf("") }
+
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A running operation holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.busy) {}
+ // A finished reset or gc is announced once, also when the screen was recreated in between:
+ // the result stays in the ViewModel until the snackbar was shown.
+ val done = viewModel.done
+ LaunchedEffect(done) {
+ val label =
+ when (done) {
+ GitOp.RESET -> R.string.reset_to_remote
+ GitOp.GC -> R.string.git_run_gc_job
+ else -> return@LaunchedEffect
+ }
+ activity.snackbar(
+ message = activity.getString(R.string.git_tool_done, activity.getString(label)),
+ length = Snackbar.LENGTH_LONG,
+ )
+ viewModel.done = null
+ }
+
+ // ponytail: the activity's scope, like every git operation of the app: a theme or locale change
+ // cancels it, and the finally block frees the screen.
+ fun run(op: GitOp, remote: String = "") {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ activity
+ .gitOperation(op, remote)
+ .fold(
+ success = {
+ if (op == GitOp.BREAK_OUT_OF_DETACHED)
+ viewModel.abortedBranch = PasswordRepository.getCurrentBranch()
+ viewModel.done = op
+ },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.busy = false
+ viewModel.refresh()
+ }
+ }
+ }
+
+ val head =
+ when (val status = viewModel.head) {
+ is HeadStatus.Branch -> stringResource(R.string.git_head_on_branch, status.name)
+ is HeadStatus.Detached -> stringResource(R.string.git_head_detached, status.hash)
+ HeadStatus.Missing -> stringResource(R.string.git_head_missing)
+ null -> ""
+ }
+ Box(modifier) {
+ GitConfigScreen(
+ name = viewModel.name,
+ email = viewModel.email,
+ head = head,
+ canAbort = viewModel.canAbort,
+ busy = viewModel.busy,
+ onNameChange = { viewModel.name = it },
+ onEmailChange = { viewModel.email = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else dialog = GitConfigDialog.InvalidEmail
+ },
+ onLog = onLog,
+ onAbort = {
+ // Checked again: BreakOutOfDetached reports success without doing anything otherwise.
+ if (PasswordRepository.repository?.let(::needsAbort) == true)
+ run(GitOp.BREAK_OUT_OF_DETACHED)
+ else viewModel.refresh()
+ },
+ onReset = {
+ branch = PasswordRepository.getCurrentBranch().orEmpty()
+ dialog = GitConfigDialog.Reset
+ },
+ onGc = { run(GitOp.GC) },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ when (dialog) {
+ null -> Unit
+ GitConfigDialog.InvalidEmail ->
+ SetupDialog(
+ text = stringResource(R.string.invalid_email_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ GitConfigDialog.Reset ->
+ ResetDialog(
+ branch = branch,
+ onBranchChange = { branch = it },
+ onConfirm = {
+ dialog = null
+ run(GitOp.RESET, branch.trim())
+ },
+ onDismiss = { dialog = null },
+ )
+ }
+ if (done == GitOp.BREAK_OUT_OF_DETACHED) {
+ val aborted = viewModel.abortedBranch.orEmpty()
+ // BreakOutOfDetached names the pushed branch conflicting-<branch>-<millis>: the newest one.
+ val pushed =
+ PasswordRepository.repository
+ ?.refDatabase
+ ?.getRefsByPrefix("refs/heads/conflicting-$aborted-")
+ ?.maxOfOrNull { it.name.removePrefix("refs/heads/") } ?: "conflicting-$aborted-..."
+ SetupDialog(
+ title = stringResource(R.string.git_abort_and_push_title),
+ text = stringResource(R.string.git_break_out_of_detached_success, aborted, pushed),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt
new file mode 100644
index 00000000..8882ed87
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigViewModel.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.util.Patterns
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git config screen's state: author name and email being edited, HEAD, and the git operation
+ * that runs or succeeded. The operation itself runs in the destination (it needs the activity);
+ * this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitConfigViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var name by mutableStateOf(gitSettings.authorName)
+ var email by mutableStateOf(gitSettings.authorEmail)
+
+ var head by mutableStateOf<HeadStatus?>(null)
+ private set
+
+ var canAbort by mutableStateOf(false)
+ private set
+
+ /** A git operation runs; everything waits. */
+ var busy by mutableStateOf(false)
+
+ /**
+ * The operation that succeeded and is not reported yet: a reset or gc gets a snackbar (then this
+ * goes back to null), an abort its dialog.
+ */
+ var done by mutableStateOf<GitOp?>(null)
+
+ /** The branch an abort pushed from, for its report. */
+ var abortedBranch by mutableStateOf<String?>(null)
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads HEAD and the rebase state, off the main thread. */
+ fun refresh() {
+ viewModelScope.launch {
+ val repo = PasswordRepository.repository
+ val (status, abort) =
+ withContext(dispatcherProvider.io()) {
+ if (repo == null) HeadStatus.Missing to false else headStatus(repo) to needsAbort(repo)
+ }
+ head = status
+ canAbort = abort
+ }
+ }
+
+ /** Saves name and email; false, saving nothing, when the email is not an address. */
+ fun save(): Boolean {
+ val address = email.trim()
+ if (!Patterns.EMAIL_ADDRESS.matcher(address).matches()) return false
+ gitSettings.authorEmail = address
+ gitSettings.authorName = name.trim()
+ return true
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt
new file mode 100644
index 00000000..276c9c11
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/GitLogScreen.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.GitCommit
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+import java.util.Locale
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The store's commits, read once off the main thread. */
+@HiltViewModel
+class GitLogViewModel @Inject constructor(dispatcherProvider: DispatcherProvider) : ViewModel() {
+
+ /** Null while loading. */
+ var commits by mutableStateOf<ImmutableList<GitCommit>?>(null)
+ private set
+
+ init {
+ viewModelScope.launch {
+ commits =
+ withContext(dispatcherProvider.io()) {
+ gitLog(PasswordRepository.repository).toImmutableList()
+ }
+ }
+ }
+}
+
+/**
+ * The commit log, newest first: message, short hash, date. A progress bar while [commits] is null.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitLogScreen(
+ commits: ImmutableList<GitCommit>?,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val formatter = remember {
+ DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ .withLocale(Locale.getDefault())
+ .withZone(ZoneId.systemDefault())
+ }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_log)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ if (commits == null) LinearProgressIndicator(Modifier.padding(padding).fillMaxWidth())
+ else
+ LazyColumn(Modifier.padding(padding).fillMaxSize()) {
+ items(commits, key = GitCommit::hash) { commit ->
+ ListItem(
+ headlineContent = { Text(commit.shortMessage) },
+ overlineContent = { Text(commit.hash.take(8), fontFamily = FontFamily.Monospace) },
+ supportingContent = { Text(formatter.format(commit.time)) },
+ )
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+fun GitLogDestination(
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitLogViewModel = hiltViewModel(),
+) {
+ GitLogScreen(commits = viewModel.commits, onBack = onClose, modifier = modifier)
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt
deleted file mode 100644
index 9a5d1e3b..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/MiscSettings.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.documentfile.provider.DocumentFile
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.BuildConfig
-import app.passwordstore.R
-import app.passwordstore.util.services.PasswordExportService
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-
-class MiscSettings(activity: FragmentActivity) : SettingsProvider {
-
- private val storeExportAction =
- activity.registerForActivityResult(
- object : ActivityResultContracts.OpenDocumentTree() {
- override fun createIntent(context: Context, input: Uri?): Intent {
- return super.createIntent(context, input).apply {
- flags =
- Intent.FLAG_GRANT_READ_URI_PERMISSION or
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
- Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
- Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
- }
- }
- }
- ) { uri: Uri? ->
- if (uri == null) return@registerForActivityResult
- val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
-
- if (targetDirectory != null) {
- val service =
- Intent(activity.applicationContext, PasswordExportService::class.java).apply {
- action = PasswordExportService.ACTION_EXPORT_PASSWORD
- putExtra("uri", uri)
- }
-
- activity.startForegroundService(service)
- }
- }
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref(PreferenceKeys.EXPORT_PASSWORDS) {
- titleRes = R.string.prefs_export_passwords_title
- summaryRes = R.string.prefs_export_passwords_summary
- onClick {
- storeExportAction.launch(null)
- true
- }
- }
- switch(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
- defaultValue = false
- titleRes = R.string.pref_clear_clipboard_title
- summaryRes = R.string.pref_clear_clipboard_summary
- }
- switch(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
- defaultValue = false
- titleRes = R.string.pref_debug_logging_title
- summaryRes = R.string.pref_debug_logging_summary
- visible = !BuildConfig.DEBUG
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt
deleted file mode 100644
index d2e63779..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import androidx.core.content.edit
-import androidx.fragment.app.FragmentActivity
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.ui.pgp.PGPKeyListActivity
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.features.Feature
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-import kotlinx.coroutines.launch
-
-class PGPSettings(
- private val activity: FragmentActivity,
- private val passphraseCache: PGPPassphraseCache,
-) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref("_") {
- titleRes = R.string.pref_pgp_key_manager_title
- persistent = false
- onClick {
- activity.launchActivity(PGPKeyListActivity::class.java)
- false
- }
- }
- switch(PreferenceKeys.ASCII_ARMOR) {
- titleRes = R.string.pref_pgp_ascii_armor_title
- persistent = true
- }
- switch(Feature.EnablePGPPassphraseCache.configKey) {
- enabled = BiometricAuthenticator.canAuthenticate(activity)
- titleRes = R.string.pref_passphrase_cache_title
- summaryRes = R.string.pref_passphrase_cache_summary
- defaultValue = false
- onClick {
- if (BiometricAuthenticator.canAuthenticate(activity)) {
- val promptTitle =
- if (checked) R.string.pref_passphrase_cache_authenticate_enable
- else R.string.pref_passphrase_cache_authenticate_disable
-
- BiometricAuthenticator.authenticate(activity, promptTitle) { result ->
- when (result) {
- is BiometricAuthenticator.Result.Success -> {
- /* Any successful change of this setting clears the passphrase
- * cache for safety */
- activity.lifecycleScope.launch {
- passphraseCache.clearAllCachedPassphrases(activity)
- }
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked)
- if (!checked) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
- }
- }
- is BiometricAuthenticator.Result.Retry -> {}
- else -> {
- /* revert back to previous state in case of error or cancellation */
- checked = !checked
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, checked)
- }
- }
- }
- }
- } else {
- /* we may get here if device lock has been disabled while PGP settings
- * screen was left open */
- checked = false
- enabled = false
- activity.sharedPrefs.edit {
- putBoolean(Feature.EnablePGPPassphraseCache.configKey, false)
- }
- }
- false
- }
- }
- switch(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) {
- dependency = Feature.EnablePGPPassphraseCache.configKey
- titleRes = R.string.pref_passphrase_cache_auto_clear_title
- summaryRes = R.string.pref_passphrase_cache_auto_clear_summary
- defaultValue = true
- /* Clear the cache once when unchecking; this is to prevent a malicious user (someone
- * knowing the screen-lock pin, but not knowing the PGP passphrase) from bypassing cache
- * clearing via the settings. Disabling it requires authentication, and cache clearing
- * stays enabled in case of any authentication failure. */
- onClick {
- if (!checked) {
- if (
- BiometricAuthenticator.canAuthenticate(activity) &&
- activity.sharedPrefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false)
- ) {
- BiometricAuthenticator.authenticate(
- activity,
- R.string.pref_passphrase_cache_auto_clear_authenticate_disable,
- ) { result ->
- when (result) {
- is BiometricAuthenticator.Result.Success -> {
- activity.sharedPrefs.edit {
- putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
- }
- activity.lifecycleScope.launch {
- passphraseCache.clearAllCachedPassphrases(activity)
- }
- }
- is BiometricAuthenticator.Result.Retry -> {}
- else -> {
- activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
- checked = true
- }
- }
- }
- } else {
- activity.sharedPrefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
- checked = true
- enabled = false
- }
- }
- false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
deleted file mode 100644
index 9fe4cd42..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/PasswordSettings.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.text.InputType
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.util.settings.PreferenceKeys
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.editText
-import de.Maxr1998.modernpreferences.helpers.singleChoice
-import de.Maxr1998.modernpreferences.helpers.switch
-import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
-
-class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
- val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
- val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.PREF_KEY_PWGEN_TYPE, items) {
- initialSelection = "diceware"
- titleRes = R.string.pref_password_generator_type_title
- }
- editText(PreferenceKeys.GENERAL_SHOW_TIME) {
- titleRes = R.string.pref_clipboard_timeout_title
- summaryProvider = { timeout ->
- activity.getString(R.string.pref_clipboard_timeout_summary, timeout ?: "45")
- }
- textInputType = InputType.TYPE_CLASS_NUMBER
- }
- switch(PreferenceKeys.SHOW_PASSWORD) {
- titleRes = R.string.show_password_pref_title
- summaryRes = R.string.show_password_pref_summary
- defaultValue = true
- }
- switch(PreferenceKeys.COPY_ON_DECRYPT) {
- titleRes = R.string.pref_copy_title
- summaryRes = R.string.pref_copy_summary
- defaultValue = false
- }
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
new file mode 100644
index 00000000..cfaa741d
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/PreferenceRows.kt
@@ -0,0 +1,217 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemColors
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import kotlinx.collections.immutable.ImmutableList
+
+private const val DISABLED_ALPHA = 0.38f
+
+/** List item colors for rows inside a grouped surface: the surface shows through. */
+@Composable
+internal fun rowColors(): ListItemColors =
+ ListItemDefaults.colors(containerColor = Color.Transparent)
+
+private fun Modifier.dimmed(enabled: Boolean) = if (enabled) this else alpha(DISABLED_ALPHA)
+
+/** A row that runs [onClick]: opens a screen or a dialog. */
+@Composable
+fun PreferenceAction(
+ title: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick).dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A switch row showing [checked]. [onCheckedChange] gets the requested value; the caller decides
+ * whether it is stored (a screen lock prompt may come first), and the row shows what was stored.
+ */
+@Composable
+fun PreferenceSwitch(
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ enabled: Boolean = true,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ modifier =
+ modifier
+ .toggleable(
+ value = checked,
+ enabled = enabled,
+ role = Role.Switch,
+ onValueChange = onCheckedChange,
+ )
+ .dimmed(enabled),
+ supportingContent =
+ if (summary != null) {
+ { Text(summary) }
+ } else null,
+ trailingContent = { Switch(checked = checked, onCheckedChange = null, enabled = enabled) },
+ colors = rowColors(),
+ )
+}
+
+/**
+ * A single choice among [values], shown by their [labels] (same order); the summary is the selected
+ * label. Tapping opens a dialog of radio buttons; choosing one closes it.
+ */
+@Composable
+fun PreferenceChoice(
+ title: String,
+ values: ImmutableList<String>,
+ labels: ImmutableList<String>,
+ selected: String,
+ onSelect: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var open by rememberSaveable { mutableStateOf(false) }
+ PreferenceAction(
+ title = title,
+ onClick = { open = true },
+ modifier = modifier,
+ summary = labels.getOrNull(values.indexOf(selected)),
+ )
+ if (open)
+ AlertDialog(
+ onDismissRequest = { open = false },
+ title = { Text(title) },
+ text = {
+ Column(Modifier.selectableGroup()) {
+ values.forEachIndexed { index, value ->
+ Row(
+ Modifier.fillMaxWidth()
+ .selectable(
+ selected = value == selected,
+ role = Role.RadioButton,
+ onClick = {
+ open = false
+ onSelect(value)
+ },
+ )
+ .padding(vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = value == selected, onClick = null)
+ Text(labels[index], Modifier.padding(start = SpacingLarge))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { open = false }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/**
+ * A text value; tapping opens a dialog to edit it. The draft is kept across recreation in saved
+ * state: never use this row for a secret. [KeyboardType.Number] keeps digits only.
+ */
+@Composable
+fun PreferenceText(
+ title: String,
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ summary: String? = null,
+ hint: String? = null,
+ singleLine: Boolean = true,
+ keyboardType: KeyboardType = KeyboardType.Text,
+ enabled: Boolean = true,
+) {
+ var draft by rememberSaveable { mutableStateOf<String?>(null) }
+ PreferenceAction(
+ title = title,
+ onClick = { draft = value },
+ modifier = modifier,
+ summary = summary,
+ enabled = enabled,
+ )
+ draft?.let { text ->
+ AlertDialog(
+ onDismissRequest = { draft = null },
+ title = { Text(title) },
+ text = {
+ OutlinedTextField(
+ value = text,
+ onValueChange = { new ->
+ draft = if (keyboardType == KeyboardType.Number) new.filter(Char::isDigit) else new
+ },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder =
+ if (hint != null) {
+ { Text(hint) }
+ } else null,
+ singleLine = singleLine,
+ minLines = if (singleLine) 1 else 3,
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ draft = null
+ onValueChange(text)
+ }
+ ) {
+ Text(stringResource(R.string.dialog_ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { draft = null }) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt
new file mode 100644
index 00000000..b7116bb7
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyScreen.kt
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.hilt.navigation.compose.hiltViewModel
+import app.passwordstore.R
+import app.passwordstore.ui.compose.R as composeR
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+
+/** Proxy host, port, user name and password; [hostError] and [portError] block the save. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProxyScreen(
+ host: String,
+ port: String,
+ username: String,
+ password: String,
+ hostError: Boolean,
+ portError: Boolean,
+ onHostChange: (String) -> Unit,
+ onPortChange: (String) -> Unit,
+ onUsernameChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onSave: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var revealed by remember { mutableStateOf(false) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.activity_proxy_label)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ val field = Modifier.fillMaxWidth()
+ OutlinedTextField(
+ value = host,
+ onValueChange = onHostChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.proxy_hostname)) },
+ supportingText =
+ if (hostError) {
+ { Text(stringResource(R.string.invalid_proxy_url)) }
+ } else null,
+ isError = hostError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = port,
+ onValueChange = { onPortChange(it.filter(Char::isDigit)) },
+ modifier = field,
+ label = { Text(stringResource(R.string.port)) },
+ supportingText =
+ if (portError) {
+ { Text(stringResource(R.string.invalid_proxy_port)) }
+ } else null,
+ isError = portError,
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = username,
+ onValueChange = onUsernameChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.username)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(autoCorrectEnabled = false, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ modifier = field,
+ label = { Text(stringResource(R.string.password)) },
+ trailingIcon = {
+ IconButton(onClick = { revealed = !revealed }) {
+ Icon(
+ painterResource(
+ if (revealed) composeR.drawable.baseline_visibility_off_24
+ else composeR.drawable.baseline_visibility_24
+ ),
+ contentDescription =
+ stringResource(
+ if (revealed) R.string.entry_hide_password else R.string.entry_show_password
+ ),
+ )
+ }
+ },
+ visualTransformation =
+ if (revealed) VisualTransformation.None else PasswordVisualTransformation(),
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Button(onClick = onSave, modifier = field, enabled = !hostError && !portError) {
+ Text(stringResource(R.string.crypto_save))
+ }
+ }
+ }
+}
+
+/**
+ * The proxy screen. Holds FLAG_SECURE while shown: it shows the saved proxy password (masked, with
+ * a reveal toggle).
+ */
+@Composable
+fun ProxyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: ProxyViewModel = hiltViewModel(),
+) {
+ DisposableEffect(activity) {
+ activity.holdSecure()
+ onDispose { activity.releaseSecure() }
+ }
+ ProxyScreen(
+ host = viewModel.host,
+ port = viewModel.port,
+ username = viewModel.username,
+ password = viewModel.password,
+ hostError = viewModel.hostError,
+ portError = viewModel.portError,
+ onHostChange = { viewModel.host = it },
+ onPortChange = { viewModel.port = it },
+ onUsernameChange = { viewModel.username = it },
+ onPasswordChange = { viewModel.password = it },
+ onSave = {
+ if (viewModel.save()) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT).show()
+ onClose()
+ }
+ },
+ onBack = onClose,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
new file mode 100644
index 00000000..fb2bfe93
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.net.InetAddresses
+import android.os.Build
+import android.util.Patterns
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import app.passwordstore.util.proxy.ProxyUtils
+import app.passwordstore.util.settings.GitSettings
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+
+/** A numeric address or a plain host name: no scheme, port or path, which the port field holds. */
+fun isProxyHost(text: String): Boolean {
+ val numeric =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) InetAddresses.isNumericAddress(text)
+ else @Suppress("DEPRECATION") Patterns.IP_ADDRESS.matcher(text).matches()
+ return numeric ||
+ (!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.matcher(text).matches())
+}
+
+/**
+ * The proxy form, prefilled from the stored proxy. The password lives here in memory only, never in
+ * saved state; it is stored in `KeystorePreferences` through [GitSettings].
+ */
+@HiltViewModel
+class ProxyViewModel
+@Inject
+constructor(private val gitSettings: GitSettings, private val proxyUtils: ProxyUtils) :
+ ViewModel() {
+
+ var host by mutableStateOf(gitSettings.proxyHost.orEmpty())
+ var port by mutableStateOf(gitSettings.proxyPort.takeIf { it != -1 }?.toString().orEmpty())
+ var username by mutableStateOf(gitSettings.proxyUsername.orEmpty())
+ var password by mutableStateOf(gitSettings.proxyPassword.orEmpty())
+
+ val hostError: Boolean
+ get() = host.isNotBlank() && !isProxyHost(host.trim())
+
+ /** Invalid, or empty while a host is set: a proxy without a port is not used at all. */
+ val portError: Boolean
+ get() = proxyPort(port).let { it == null || (it == -1 && host.isNotBlank()) }
+
+ /** Stores the proxy and applies it at once; false, storing nothing, when a field is invalid. */
+ fun save(): Boolean {
+ if (hostError || portError) return false
+ val portValue = proxyPort(port) ?: return false
+ gitSettings.proxyHost = host.trim().ifEmpty { null }
+ gitSettings.proxyPort = portValue
+ gitSettings.proxyUsername = username.trim().ifEmpty { null }
+ gitSettings.proxyPassword = password.ifEmpty { null }
+ proxyUtils.setDefaultProxy()
+ return true
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
deleted file mode 100644
index fac95949..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.content.Intent
-import android.content.SharedPreferences
-import android.content.pm.ShortcutManager
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import app.passwordstore.R
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.injection.prefs.GitPreferences
-import app.passwordstore.ui.git.config.GitConfigActivity
-import app.passwordstore.ui.git.config.GitServerConfigActivity
-import app.passwordstore.ui.proxy.ProxySelectorActivity
-import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment
-import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
-import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
-import app.passwordstore.util.extensions.getString
-import app.passwordstore.util.extensions.launchActivity
-import app.passwordstore.util.extensions.sharedPrefs
-import app.passwordstore.util.extensions.snackbar
-import app.passwordstore.util.extensions.unsafeLazy
-import app.passwordstore.util.git.sshj.SshKey
-import app.passwordstore.util.settings.GitSettings
-import app.passwordstore.util.settings.PreferenceKeys
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferenceScreen
-import de.Maxr1998.modernpreferences.helpers.onClick
-import de.Maxr1998.modernpreferences.helpers.pref
-import de.Maxr1998.modernpreferences.helpers.switch
-
-class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
-
- private val generateSshKey =
- activity.registerForActivityResult(StartActivityForResult()) {
- showSshKeyPref?.visible = SshKey.canShowSshPublicKey
- }
-
- private val hiltEntryPoint by unsafeLazy {
- EntryPointAccessors.fromApplication(
- activity.applicationContext,
- RepositorySettingsEntryPoint::class.java,
- )
- }
-
- private var showSshKeyPref: Preference? = null
-
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- val encryptedPreferences = hiltEntryPoint.encryptedPreferences()
- val gitSettings = hiltEntryPoint.gitSettings()
-
- builder.apply {
- switch(PreferenceKeys.REBASE_ON_PULL) {
- titleRes = R.string.pref_rebase_on_pull_title
- summaryRes = R.string.pref_rebase_on_pull_summary
- summaryOnRes = R.string.pref_rebase_on_pull_summary_on
- defaultValue = true
- }
- pref(PreferenceKeys.GIT_SERVER_INFO) {
- titleRes = R.string.pref_edit_git_server_settings
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(GitServerConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.PROXY_SETTINGS) {
- titleRes = R.string.pref_edit_proxy_settings
- visible = gitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(ProxySelectorActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.GIT_CONFIG) {
- titleRes = R.string.pref_edit_git_config
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(GitConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEY) {
- titleRes = R.string.pref_import_ssh_key_title
- visible = PasswordRepository.isGitRepo()
- onClick {
- activity.launchActivity(SshKeyImportActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEYGEN) {
- titleRes = R.string.pref_ssh_keygen_title
- onClick {
- generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java))
- true
- }
- }
- showSshKeyPref =
- pref(PreferenceKeys.SSH_SEE_KEY) {
- titleRes = R.string.pref_ssh_see_key_title
- visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey
- onClick {
- ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
- true
- }
- }
- pref(PreferenceKeys.CLEAR_SAVED_PASS) {
- fun Preference.updatePref() {
- val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
- val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
- if (sshPass == null && httpsPass == null) {
- visible = false
- return
- }
- titleRes =
- when {
- httpsPass != null -> R.string.clear_saved_passphrase_https
- else -> R.string.clear_saved_passphrase_ssh
- }
- visible = true
- requestRebind()
- }
- onClick {
- updatePref()
- true
- }
- updatePref()
- }
- pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
- titleRes = R.string.pref_title_openkeystore_clear_keyid
- visible =
- activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
- ?: false
- onClick {
- activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
- visible = false
- true
- }
- }
- pref(PreferenceKeys.GIT_DELETE_REPO) {
- titleRes = R.string.pref_git_delete_repo_title
- summaryRes = R.string.pref_git_delete_repo_summary
- onClick {
- val repoDir = PasswordRepository.getRepositoryDirectory()
- MaterialAlertDialogBuilder(activity)
- .setTitle(R.string.pref_dialog_delete_title)
- .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
- runCatching {
- PasswordRepository.closeRepository()
- PasswordRepository.getRepositoryDirectory().let { dir ->
- dir.deleteRecursively()
- dir.mkdirs()
- }
- }
- .onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
-
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
- dialogInterface.cancel()
- activity.finish()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
- run { dialogInterface.cancel() }
- }
- .show()
- true
- }
- }
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface RepositorySettingsEntryPoint {
- fun gitSettings(): GitSettings
-
- @GitPreferences fun encryptedPreferences(): SharedPreferences
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
deleted file mode 100644
index 02addd84..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import android.os.Bundle
-import android.view.MenuItem
-import androidx.activity.OnBackPressedCallback
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.os.BundleCompat
-import app.passwordstore.R
-import app.passwordstore.data.crypto.PGPPassphraseCache
-import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding
-import app.passwordstore.util.extensions.viewBinding
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import de.Maxr1998.modernpreferences.Preference
-import de.Maxr1998.modernpreferences.PreferencesAdapter
-import de.Maxr1998.modernpreferences.helpers.screen
-import de.Maxr1998.modernpreferences.helpers.subScreen
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class SettingsActivity : AppCompatActivity() {
-
- @Inject lateinit var passphraseCache: PGPPassphraseCache
- private val miscSettings = MiscSettings(this)
- private val autofillSettings = AutofillSettings(this)
- private val passwordSettings = PasswordSettings(this)
- private val repositorySettings = RepositorySettings(this)
- private val generalSettings = GeneralSettings(this)
- private lateinit var pgpSettings: PGPSettings
-
- private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
- private val preferencesAdapter: PreferencesAdapter
- get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) }
- pgpSettings = PGPSettings(this, passphraseCache)
- val screen =
- screen(this) {
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_general_title
- iconRes = R.drawable.app_settings_alt_24px
- generalSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_autofill_title
- iconRes = R.drawable.ic_wysiwyg_24px
- autofillSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_passwords_title
- iconRes = R.drawable.ic_password_24px
- passwordSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_repository_title
- iconRes = R.drawable.ic_call_merge_24px
- repositorySettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_misc_title
- iconRes = R.drawable.ic_miscellaneous_services_24px
- miscSettings.provideSettings(this)
- }
- subScreen {
- collapseIcon = true
- titleRes = R.string.pref_category_pgp_title
- iconRes = R.drawable.ic_lock_open_24px
- pgpSettings.provideSettings(this)
- }
- }
- val backPressedCallback =
- object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- preferencesAdapter.goBack()
- }
- }
- onBackPressedDispatcher.addCallback(backPressedCallback)
- val adapter = PreferencesAdapter(screen)
- adapter.onScreenChangeListener =
- PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
- backPressedCallback.isEnabled = entering
- supportActionBar?.title =
- if (!entering) {
- getString(R.string.action_settings)
- } else {
- getString(subScreen.titleRes)
- }
- }
- if (savedInstanceState != null) {
- BundleCompat.getParcelable(
- savedInstanceState,
- "adapter",
- PreferencesAdapter.SavedState::class.java,
- )
- ?.let(adapter::loadSavedState)
- }
- binding.preferenceRecyclerView.adapter = adapter
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- outState.putParcelable("adapter", preferencesAdapter.getSavedState())
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home ->
- if (!preferencesAdapter.goBack()) {
- super.onOptionsItemSelected(item)
- } else {
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
new file mode 100644
index 00000000..c3809b36
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt
@@ -0,0 +1,304 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.core.content.getSystemService
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupDialog
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.auth.BiometricAuthenticator.Result
+import app.passwordstore.util.extensions.autofillManager
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.services.PasswordExportService
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel
+import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel
+
+private enum class SettingsDialog {
+ Autofill,
+ ClearSecret,
+ DeleteRepository,
+}
+
+/** The export's folder picker, with the persistable write grant `PasswordExportService` needs. */
+private class ExportFolder : ActivityResultContracts.OpenDocumentTree() {
+ override fun createIntent(context: Context, input: Uri?): Intent =
+ super.createIntent(context, input).apply {
+ flags =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ }
+}
+
+private fun MainActivity.autofillEnabled() = autofillManager?.hasEnabledAutofillServices() == true
+
+/**
+ * One settings category: wires [SettingsSectionScreen] to the [viewModel] and to what needs the
+ * activity: screen lock prompts, the autofill service, the export picker, confirmations. [onOpen]
+ * gets the actions that open another screen; [onDeleted] runs once the store was deleted.
+ */
+@Composable
+fun SettingsSectionDestination(
+ activity: MainActivity,
+ section: SettingsSection,
+ onBack: () -> Unit,
+ onOpen: (SettingsAction) -> Unit,
+ onDeleted: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SettingsViewModel = hiltViewModel(),
+) {
+ var dialog by rememberSaveable { mutableStateOf<SettingsDialog?>(null) }
+ var canAuthenticate by remember {
+ mutableStateOf(BiometricAuthenticator.canAuthenticate(activity))
+ }
+ var autofillEnabled by remember { mutableStateOf(activity.autofillEnabled()) }
+
+ fun refresh() {
+ viewModel.refresh()
+ canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
+ autofillEnabled = activity.autofillEnabled()
+ }
+
+ // Back from another screen (git server, SSH key, key generation, the system's autofill
+ // settings): rows that depend on it follow, e.g. "View generated public SSH key".
+ LifecycleResumeEffect(Unit) {
+ refresh()
+ onPauseOrDispose {}
+ }
+ // A finished deletion moves on, also when the screen was recreated in between.
+ val deleted = viewModel.deleted
+ LaunchedEffect(deleted) {
+ if (deleted == null) return@LaunchedEffect
+ if (!deleted) activity.snackbar(message = activity.getString(R.string.store_delete_failed))
+ onDeleted()
+ }
+ BackHandler(enabled = viewModel.deleting) {}
+
+ val export =
+ rememberLauncherForActivityResult(ExportFolder()) { uri ->
+ if (uri != null && DocumentFile.fromTreeUri(activity, uri) != null)
+ activity.startForegroundService(
+ Intent(activity, PasswordExportService::class.java)
+ .setAction(PasswordExportService.ACTION_EXPORT_PASSWORD)
+ .putExtra("uri", uri)
+ )
+ }
+
+ // One screen lock prompt at a time: a double tap must not open two. A recreation resets it
+ // (remember), and the old activity's prompt is gone with it.
+ var prompting by remember { mutableStateOf(false) }
+
+ fun authenticate(@StringRes title: Int, onResult: (Result) -> Unit) {
+ if (prompting) return
+ prompting = true
+ BiometricAuthenticator.authenticate(activity, title) { result ->
+ // Retry: a finger was not recognized, the prompt stays open. Errors close it and are
+ // terminal (Failure, Canceled*, HardwareUnavailableOrDisabled).
+ if (result !is Result.Retry) prompting = false
+ onResult(result)
+ }
+ }
+
+ // Values are written only once allowed: a failed or cancelled prompt leaves the stored value,
+ // which the switch shows.
+ fun setBoolean(key: String, value: Boolean) {
+ when (key) {
+ PreferenceKeys.AUTOFILL_ENABLE ->
+ if (value) dialog = SettingsDialog.Autofill
+ else {
+ activity.autofillManager?.disableAutofillServices()
+ // hasEnabledAutofillServices() still answers true right after this call.
+ autofillEnabled = false
+ }
+ PreferenceKeys.BIOMETRIC_AUTH_2 -> {
+ fun write() {
+ viewModel.putBoolean(key, value)
+ // Shortcuts were built for the old lock setting, as the old screen knew.
+ activity.getSystemService<ShortcutManager>()?.removeAllDynamicShortcuts()
+ }
+ // Without a screen lock no prompt can open: turning the app lock off needs none.
+ if (!value && !BiometricAuthenticator.canAuthenticate(activity)) write()
+ else
+ authenticate(R.string.biometric_prompt_title) { result ->
+ if (result is Result.Success) write()
+ }
+ }
+ Feature.EnablePGPPassphraseCache.configKey ->
+ if (!BiometricAuthenticator.canAuthenticate(activity)) viewModel.setPassphraseCache(false)
+ else
+ authenticate(
+ if (value) R.string.pref_passphrase_cache_authenticate_enable
+ else R.string.pref_passphrase_cache_authenticate_disable
+ ) { result ->
+ if (result is Result.Success) viewModel.setPassphraseCache(value)
+ }
+ PreferenceKeys.CLEAR_PASSPHRASE_CACHE ->
+ when {
+ value -> viewModel.putBoolean(key, true)
+ !BiometricAuthenticator.canAuthenticate(activity) -> viewModel.resetAutoClear()
+ else ->
+ authenticate(R.string.pref_passphrase_cache_auto_clear_authenticate_disable) { result ->
+ when (result) {
+ is Result.Success -> viewModel.disableAutoClear()
+ is Result.Retry -> Unit
+ else -> viewModel.resetAutoClear()
+ }
+ }
+ }
+ else -> viewModel.putBoolean(key, value)
+ }
+ }
+
+ Box(modifier) {
+ SettingsSectionScreen(
+ section = section,
+ state = viewModel.state,
+ canAuthenticate = canAuthenticate,
+ autofillEnabled = autofillEnabled,
+ deleting = viewModel.deleting,
+ // Nothing else starts while the store is being deleted.
+ onBoolean = { key, value -> if (!viewModel.deleting) setBoolean(key, value) },
+ onString = { key, value -> if (!viewModel.deleting) viewModel.putString(key, value) },
+ onAction = { action ->
+ if (!viewModel.deleting)
+ when (action) {
+ SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret
+ SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository
+ SettingsAction.Export -> export.launch(null)
+ else -> onOpen(action)
+ }
+ },
+ onBack = { if (!viewModel.deleting) onBack() },
+ )
+ when (dialog) {
+ null -> Unit
+ SettingsDialog.Autofill ->
+ AutofillDialog(
+ onConfirm = {
+ dialog = null
+ activity.startActivity(
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE)
+ .setData("package:${BuildConfig.APPLICATION_ID}".toUri())
+ )
+ },
+ onDismiss = { dialog = null },
+ )
+ SettingsDialog.ClearSecret ->
+ SetupDialog(
+ title = viewModel.state.savedSecret?.let { stringResource(it) },
+ text = stringResource(R.string.clear_saved_secret_message),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ viewModel.clearSavedSecret()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ SettingsDialog.DeleteRepository ->
+ SetupDialog(
+ title = stringResource(R.string.pref_dialog_delete_title),
+ text = stringResource(R.string.store_delete_confirm),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ viewModel.deleteRepository()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
+
+/** The old autofill instructions: what it does, how to enable it, and the installed browsers. */
+@Composable
+private fun AutofillDialog(
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val browsers = remember { getInstalledBrowsersWithAutofillSupportLevel(context) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title = { Text(stringResource(R.string.pref_autofill_enable_title)) },
+ text = {
+ Column(
+ Modifier.verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_description))
+ Text(stringResource(R.string.oreo_autofill_enable_dialog_instructions))
+ Column {
+ Text(
+ stringResource(R.string.oreo_autofill_enable_dialog_installed_browsers),
+ fontWeight = FontWeight.Bold,
+ )
+ browsers.forEach { (label, level) -> Text("$label: ${stringResource(level.label())}") }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_ok)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+@StringRes
+private fun BrowserAutofillSupportLevel.label(): Int =
+ when (this) {
+ BrowserAutofillSupportLevel.None -> R.string.oreo_autofill_no_support
+ BrowserAutofillSupportLevel.FlakyFill -> R.string.oreo_autofill_flaky_fill_support
+ BrowserAutofillSupportLevel.PasswordFill -> R.string.oreo_autofill_password_fill_support
+ BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
+ R.string.oreo_autofill_password_fill_and_conditional_save_support
+ BrowserAutofillSupportLevel.GeneralFill -> R.string.oreo_autofill_general_fill_support
+ BrowserAutofillSupportLevel.GeneralFillAndSave ->
+ R.string.oreo_autofill_general_fill_and_save_support
+ }
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
new file mode 100644
index 00000000..ae4e277b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.GitServerRoute
+import app.passwordstore.ui.setup.PgpKeysRoute
+import app.passwordstore.ui.setup.SshKeyGenRoute
+import app.passwordstore.ui.setup.SshKeyRoute
+import app.passwordstore.ui.setup.WelcomeRoute
+import kotlinx.serialization.Serializable
+
+/** The settings categories. */
+@Serializable data object SettingsRoute
+
+/** One settings category. */
+@Serializable data class SettingsSectionRoute(val section: SettingsSection)
+
+/** Author name and email, and the repository utilities. */
+@Serializable data object GitConfigRoute
+
+/** The store's commit log. */
+@Serializable data object GitLogRoute
+
+/** HTTP(S) proxy settings. */
+@Serializable data object ProxyRoute
+
+/** The settings destinations, and where their rows lead. */
+fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (onTop(entry)) nav.popBackStack()
+ }
+
+ composable<SettingsRoute> { entry ->
+ SettingsScreen(
+ onSection = { if (onTop(entry)) nav.navigate(SettingsSectionRoute(it)) },
+ onBack = { close(entry) },
+ )
+ }
+ composable<SettingsSectionRoute> { entry ->
+ SettingsSectionDestination(
+ activity = activity,
+ section = entry.toRoute<SettingsSectionRoute>().section,
+ onBack = { close(entry) },
+ onOpen = { action ->
+ if (onTop(entry))
+ when (action) {
+ SettingsAction.GitServer -> nav.navigate(GitServerRoute(clone = false))
+ SettingsAction.Proxy -> nav.navigate(ProxyRoute)
+ SettingsAction.GitConfig -> nav.navigate(GitConfigRoute)
+ SettingsAction.SshKey -> nav.navigate(SshKeyRoute)
+ SettingsAction.SshKeyGen -> nav.navigate(SshKeyGenRoute)
+ SettingsAction.KeyManager -> nav.navigate(PgpKeysRoute())
+ // Handled by the destination itself.
+ SettingsAction.ClearSecret,
+ SettingsAction.DeleteRepository,
+ SettingsAction.Export -> Unit
+ }
+ },
+ onDeleted = {
+ // No store any more: setup starts over, as after a deletion from the list's side.
+ if (onTop(entry)) nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } }
+ },
+ )
+ }
+ composable<GitConfigRoute> { entry ->
+ GitConfigDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onLog = { if (onTop(entry)) nav.navigate(GitLogRoute) },
+ )
+ }
+ composable<GitLogRoute> { entry -> GitLogDestination(onClose = { close(entry) }) }
+ composable<ProxyRoute> { entry ->
+ ProxyDestination(activity = activity, onClose = { close(entry) })
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
new file mode 100644
index 00000000..eca6770c
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import app.passwordstore.util.extensions.hash
+import app.passwordstore.util.extensions.time
+import app.passwordstore.util.git.GitCommit
+import java.io.IOException
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+import org.eclipse.jgit.lib.Constants
+import org.eclipse.jgit.lib.Repository
+import org.eclipse.jgit.lib.RepositoryState
+
+private const val TAG = "SettingsModel"
+
+/**
+ * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when
+ * it is not a port number from 1 to 65535.
+ */
+fun proxyPort(text: String): Int? {
+ val trimmed = text.trim()
+ if (trimmed.isEmpty()) return -1
+ return trimmed.toIntOrNull()?.takeIf { it in 1..65535 }
+}
+
+/** Where HEAD points, as the git config screen reports it. */
+sealed interface HeadStatus {
+ data class Branch(val name: String) : HeadStatus
+
+ data class Detached(val hash: String) : HeadStatus
+
+ data object Missing : HeadStatus
+}
+
+/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */
+fun headStatus(repo: Repository): HeadStatus =
+ try {
+ val head = repo.findRef(Constants.HEAD)
+ when {
+ head == null -> HeadStatus.Missing
+ head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name))
+ else ->
+ head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing
+ }
+ } catch (e: IOException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ HeadStatus.Missing
+ }
+
+/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */
+fun needsAbort(repo: Repository): Boolean =
+ repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING }
+
+/**
+ * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking:
+ * call it off the main thread.
+ */
+// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it
+// if a store's history ever makes this slow.
+fun gitLog(repo: Repository?): List<GitCommit> {
+ if (repo == null) return emptyList()
+ return try {
+ Git(repo).log().call().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
+ } catch (e: GitAPIException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ } catch (e: JGitInternalException) {
+ logcat(TAG, ERROR) { e.asLog() }
+ emptyList()
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt
deleted file mode 100644
index cacca80f..00000000
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsProvider.kt
+++ /dev/null
@@ -1,15 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.settings
-
-import de.Maxr1998.modernpreferences.PreferenceScreen
-
-/** Used to generate a uniform API for all settings UI classes. */
-interface SettingsProvider {
-
- /** Inserts the settings items for the class into the given [builder]. */
- fun provideSettings(builder: PreferenceScreen.Builder)
-}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
new file mode 100644
index 00000000..7d45ad74
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt
@@ -0,0 +1,349 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.Keep
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.input.KeyboardType
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.settings.PreferenceKeys
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.serialization.Serializable
+
+/** The settings categories, one screen each. A route argument: kept from minification. */
+@Keep
+@Serializable
+enum class SettingsSection(@StringRes val title: Int, @DrawableRes val icon: Int) {
+ General(R.string.pref_category_general_title, R.drawable.app_settings_alt_24px),
+ Autofill(R.string.pref_category_autofill_title, R.drawable.ic_wysiwyg_24px),
+ Passwords(R.string.pref_category_passwords_title, R.drawable.ic_password_24px),
+ Repository(R.string.pref_category_repository_title, R.drawable.ic_call_merge_24px),
+ Misc(R.string.pref_category_misc_title, R.drawable.ic_miscellaneous_services_24px),
+ Pgp(R.string.pref_category_pgp_title, R.drawable.ic_lock_open_24px),
+}
+
+/** What a row asks for beyond storing a value: another screen, a dialog or a picker. */
+enum class SettingsAction {
+ GitServer,
+ Proxy,
+ GitConfig,
+ SshKey,
+ SshKeyGen,
+ KeyManager,
+ ClearSecret,
+ DeleteRepository,
+ Export,
+}
+
+/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SettingsScaffold(
+ title: String,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(title) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ GroupedSurface(content = content)
+ }
+ }
+}
+
+/** The settings categories. */
+@Composable
+fun SettingsScreen(
+ onSection: (SettingsSection) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(R.string.action_settings), onBack, modifier) {
+ SettingsSection.entries.forEach { section ->
+ ListItem(
+ headlineContent = { Text(stringResource(section.title)) },
+ modifier = Modifier.clickable(role = Role.Button) { onSection(section) },
+ leadingContent = { Icon(painterResource(section.icon), contentDescription = null) },
+ colors = rowColors(),
+ )
+ }
+ }
+}
+
+/**
+ * One category's rows. Switches and values go out through [onBoolean] and [onString] with their
+ * preference key (the destination decides which need a prompt first); everything else through
+ * [onAction]. [canAuthenticate] and [autofillEnabled] come from the activity.
+ */
+@Composable
+fun SettingsSectionScreen(
+ section: SettingsSection,
+ state: SettingsState,
+ canAuthenticate: Boolean,
+ autofillEnabled: Boolean,
+ deleting: Boolean,
+ onBoolean: (key: String, value: Boolean) -> Unit,
+ onString: (key: String, value: String) -> Unit,
+ onAction: (SettingsAction) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(section.title), onBack, modifier) {
+ when (section) {
+ SettingsSection.General -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_app_theme_title),
+ values = persistentListOf(*stringArrayResource(R.array.app_theme_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.app_theme_options)),
+ selected = state.theme,
+ onSelect = { onString(PreferenceKeys.APP_THEME, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_directory_structure),
+ values = persistentListOf(*stringArrayResource(R.array.directory_structure_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.directory_structure_entries)),
+ selected = state.directoryStructure,
+ onSelect = { onString(PreferenceKeys.DIRECTORY_STRUCTURE, it) },
+ )
+ PreferenceChoice(
+ title = stringResource(R.string.pref_sort_order_title),
+ values = persistentListOf(*stringArrayResource(R.array.sort_order_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.sort_order_entries)),
+ selected = state.sortOrder,
+ onSelect = { onString(PreferenceKeys.SORT_ORDER, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_disable_sync_on_pull_title),
+ checked = state.disableSync,
+ onCheckedChange = { onBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, it) },
+ summary = stringResource(R.string.pref_disable_sync_on_pull_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_recursive_filter_title),
+ checked = state.filterRecursively,
+ onCheckedChange = { onBoolean(PreferenceKeys.FILTER_RECURSIVELY, it) },
+ summary = stringResource(R.string.pref_recursive_filter_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_search_on_start_title),
+ checked = state.searchOnStart,
+ onCheckedChange = { onBoolean(PreferenceKeys.SEARCH_ON_START, it) },
+ summary = stringResource(R.string.pref_search_on_start_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_show_hidden_title),
+ checked = state.showHidden,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, it) },
+ summary = stringResource(R.string.pref_show_hidden_summary),
+ )
+ // Enabled on Android 14 too (maintainer decision 4): upstream blocked it there for issue
+ // 2802; if that platform bug shows on the maintainer's Android 14 tablet, the block
+ // returns.
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_biometric_auth_title),
+ checked = state.biometric,
+ onCheckedChange = { onBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, it) },
+ summary =
+ stringResource(
+ if (canAuthenticate) R.string.pref_biometric_auth_summary
+ else R.string.pref_biometric_auth_summary_error
+ ),
+ )
+ }
+ SettingsSection.Autofill -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_autofill_enable_title),
+ checked = autofillEnabled,
+ onCheckedChange = { onBoolean(PreferenceKeys.AUTOFILL_ENABLE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_default_username_title),
+ value = state.defaultUsername,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME, it) },
+ summary = stringResource(R.string.preference_default_username_summary),
+ enabled = autofillEnabled,
+ )
+ PreferenceText(
+ title = stringResource(R.string.preference_custom_public_suffixes_title),
+ value = state.customSuffixes,
+ onValueChange = { onString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES, it) },
+ summary = stringResource(R.string.preference_custom_public_suffixes_summary),
+ hint = stringResource(R.string.preference_custom_public_suffixes_hint),
+ singleLine = false,
+ enabled = autofillEnabled,
+ )
+ }
+ SettingsSection.Passwords -> {
+ PreferenceChoice(
+ title = stringResource(R.string.pref_password_generator_type_title),
+ values = persistentListOf(*stringArrayResource(R.array.pwgen_provider_values)),
+ labels = persistentListOf(*stringArrayResource(R.array.pwgen_provider_labels)),
+ selected = state.pwgenType,
+ onSelect = { onString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, it) },
+ )
+ PreferenceText(
+ title = stringResource(R.string.pref_clipboard_timeout_title),
+ value = state.clipboardTimeout,
+ onValueChange = { onString(PreferenceKeys.GENERAL_SHOW_TIME, it) },
+ summary =
+ stringResource(
+ R.string.pref_clipboard_timeout_summary,
+ state.clipboardTimeout.ifEmpty { "45" },
+ ),
+ keyboardType = KeyboardType.Number,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.show_password_pref_title),
+ checked = state.showPassword,
+ onCheckedChange = { onBoolean(PreferenceKeys.SHOW_PASSWORD, it) },
+ summary = stringResource(R.string.show_password_pref_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_copy_title),
+ checked = state.copyOnDecrypt,
+ onCheckedChange = { onBoolean(PreferenceKeys.COPY_ON_DECRYPT, it) },
+ summary = stringResource(R.string.pref_copy_summary),
+ )
+ }
+ SettingsSection.Repository -> {
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_rebase_on_pull_title),
+ checked = state.rebaseOnPull,
+ onCheckedChange = { onBoolean(PreferenceKeys.REBASE_ON_PULL, it) },
+ summary =
+ stringResource(
+ if (state.rebaseOnPull) R.string.pref_rebase_on_pull_summary_on
+ else R.string.pref_rebase_on_pull_summary
+ ),
+ )
+ if (state.isGitRepo) {
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_server_settings),
+ onClick = { onAction(SettingsAction.GitServer) },
+ )
+ if (state.httpsRemote)
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_proxy_settings),
+ onClick = { onAction(SettingsAction.Proxy) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_edit_git_config),
+ onClick = { onAction(SettingsAction.GitConfig) },
+ )
+ PreferenceAction(
+ title = stringResource(R.string.pref_import_ssh_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_keygen_title),
+ onClick = { onAction(SettingsAction.SshKeyGen) },
+ )
+ if (state.isGitRepo && state.canShowSshKey)
+ PreferenceAction(
+ title = stringResource(R.string.pref_ssh_see_key_title),
+ onClick = { onAction(SettingsAction.SshKey) },
+ )
+ state.savedSecret?.let { title ->
+ PreferenceAction(
+ title = stringResource(title),
+ onClick = { onAction(SettingsAction.ClearSecret) },
+ )
+ }
+ PreferenceAction(
+ title = stringResource(R.string.pref_git_delete_repo_title),
+ onClick = { onAction(SettingsAction.DeleteRepository) },
+ summary = stringResource(R.string.pref_git_delete_repo_summary),
+ enabled = !deleting,
+ )
+ }
+ SettingsSection.Misc -> {
+ PreferenceAction(
+ title = stringResource(R.string.prefs_export_passwords_title),
+ onClick = { onAction(SettingsAction.Export) },
+ summary = stringResource(R.string.prefs_export_passwords_summary),
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_clear_clipboard_title),
+ checked = state.clearClipboardHistory,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, it) },
+ summary = stringResource(R.string.pref_clear_clipboard_summary),
+ )
+ if (!BuildConfig.DEBUG)
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_debug_logging_title),
+ checked = state.debugLogging,
+ onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) },
+ summary = stringResource(R.string.pref_debug_logging_summary),
+ )
+ }
+ SettingsSection.Pgp -> {
+ PreferenceAction(
+ title = stringResource(R.string.pref_pgp_key_manager_title),
+ onClick = { onAction(SettingsAction.KeyManager) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_pgp_ascii_armor_title),
+ checked = state.asciiArmor,
+ onCheckedChange = { onBoolean(PreferenceKeys.ASCII_ARMOR, it) },
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_title),
+ checked = state.passphraseCache,
+ onCheckedChange = { onBoolean(Feature.EnablePGPPassphraseCache.configKey, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_summary),
+ // Without a screen lock it can still be turned off.
+ enabled = canAuthenticate || state.passphraseCache,
+ )
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_passphrase_cache_auto_clear_title),
+ checked = state.autoClearCache,
+ onCheckedChange = { onBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, it) },
+ summary = stringResource(R.string.pref_passphrase_cache_auto_clear_summary),
+ enabled = state.passphraseCache,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
new file mode 100644
index 00000000..ef604f1f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.content.pm.ShortcutManager
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.edit
+import androidx.core.content.getSystemService
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.data.crypto.PGPPassphraseCache
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.injection.prefs.GitPreferences
+import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.getString
+import app.passwordstore.util.features.Feature
+import app.passwordstore.util.git.sshj.SshKey
+import app.passwordstore.util.settings.DirectoryStructure
+import app.passwordstore.util.settings.GitSettings
+import app.passwordstore.util.settings.PasswordSortOrder
+import app.passwordstore.util.settings.PreferenceKeys
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import javax.inject.Inject
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** What the settings sections show: stored values, and the state rows depend on. */
+data class SettingsState(
+ val theme: String,
+ val directoryStructure: String,
+ val sortOrder: String,
+ val disableSync: Boolean,
+ val filterRecursively: Boolean,
+ val searchOnStart: Boolean,
+ val showHidden: Boolean,
+ val biometric: Boolean,
+ val defaultUsername: String,
+ val customSuffixes: String,
+ val pwgenType: String,
+ val clipboardTimeout: String,
+ val showPassword: Boolean,
+ val copyOnDecrypt: Boolean,
+ val rebaseOnPull: Boolean,
+ val isGitRepo: Boolean,
+ val httpsRemote: Boolean,
+ val canShowSshKey: Boolean,
+ /** Title of the "clear saved credential" row, or null when none is saved. */
+ @StringRes val savedSecret: Int?,
+ val clearClipboardHistory: Boolean,
+ val debugLogging: Boolean,
+ val asciiArmor: Boolean,
+ val passphraseCache: Boolean,
+ val autoClearCache: Boolean,
+)
+
+/**
+ * One settings section's state over the existing preference keys. [state] is re-read on every
+ * change of the settings and on [refresh] (the destination calls it on resume, so changes made on
+ * other screens, such as a new SSH key, show at once).
+ */
+@HiltViewModel
+class SettingsViewModel
+@Inject
+constructor(
+ application: Application,
+ @SettingsPreferences private val prefs: SharedPreferences,
+ @GitPreferences private val gitPrefs: SharedPreferences,
+ private val gitSettings: GitSettings,
+ private val passphraseCache: PGPPassphraseCache,
+ private val dispatcherProvider: DispatcherProvider,
+) : AndroidViewModel(application) {
+
+ var state by mutableStateOf(read())
+ private set
+
+ /** True while "Delete repository" runs. */
+ var deleting by mutableStateOf(false)
+ private set
+
+ /** Null until "Delete repository" ran; then whether the whole store folder was deleted. */
+ var deleted by mutableStateOf<Boolean?>(null)
+ private set
+
+ // A field: SharedPreferences holds its listeners weakly.
+ private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> refresh() }
+
+ init {
+ prefs.registerOnSharedPreferenceChangeListener(listener)
+ }
+
+ override fun onCleared() {
+ prefs.unregisterOnSharedPreferenceChangeListener(listener)
+ }
+
+ fun refresh() {
+ state = read()
+ }
+
+ fun putBoolean(key: String, value: Boolean) {
+ prefs.edit { putBoolean(key, value) }
+ }
+
+ /** Stores [value] under [key]; an empty value removes the key, so readers use their default. */
+ fun putString(key: String, value: String) {
+ prefs.edit { if (value.isEmpty()) remove(key) else putString(key, value) }
+ }
+
+ /** Turns the passphrase cache on or off (after the screen lock prompt); it starts empty. */
+ fun setPassphraseCache(enabled: Boolean) {
+ prefs.edit {
+ putBoolean(Feature.EnablePGPPassphraseCache.configKey, enabled)
+ if (!enabled) remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE)
+ }
+ clearCachedPassphrases()
+ }
+
+ /** Stops clearing the cache when the screen goes off; the cache is emptied once now. */
+ fun disableAutoClear() {
+ putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, false)
+ clearCachedPassphrases()
+ }
+
+ /** Back to the default: the cache is cleared when the screen goes off. */
+ fun resetAutoClear() {
+ prefs.edit { remove(PreferenceKeys.CLEAR_PASSPHRASE_CACHE) }
+ }
+
+ /** Forgets the credential the "clear" row names: the HTTPS password, else the SSH passphrase. */
+ fun clearSavedSecret() {
+ val key =
+ if (gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD)) PreferenceKeys.HTTPS_PASSWORD
+ else PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
+ gitPrefs.edit { remove(key) }
+ refresh()
+ }
+
+ /**
+ * Empties the store folder and marks setup as not done, then sets [deleted]. In [viewModelScope]
+ * and [NonCancellable], so neither a recreation nor the ViewModel's end can stop it half way (a
+ * half-deleted store still marked as set up). Never follows a symlink: `deleteRecursively()`
+ * would, and a `store/link -> ..` link would take the PGP and SSH keys with it.
+ */
+ fun deleteRepository() {
+ if (deleting) return
+ deleting = true
+ viewModelScope.launch {
+ withContext(NonCancellable) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val gone =
+ withContext(dispatcherProvider.io()) {
+ PasswordRepository.closeRepository()
+ val ok =
+ Files.notExists(root.toPath(), LinkOption.NOFOLLOW_LINKS) ||
+ root.deleteWithoutFollowingLinks()
+ root.mkdirs()
+ ok
+ }
+ getApplication<Application>()
+ .getSystemService<ShortcutManager>()
+ ?.removeAllDynamicShortcuts()
+ prefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ deleted = gone
+ }
+ }
+ }
+
+ // NonCancellable: leaving the screen right after the switch must not skip the clearing.
+ private fun clearCachedPassphrases() {
+ viewModelScope.launch(NonCancellable) {
+ passphraseCache.clearAllCachedPassphrases(getApplication())
+ }
+ }
+
+ private fun read(): SettingsState {
+ val app = getApplication<Application>()
+ return SettingsState(
+ theme = prefs.getString(PreferenceKeys.APP_THEME) ?: app.getString(R.string.app_theme_def),
+ directoryStructure =
+ prefs.getString(PreferenceKeys.DIRECTORY_STRUCTURE) ?: DirectoryStructure.DEFAULT.value,
+ sortOrder = PasswordSortOrder.getSortOrder(prefs).name,
+ disableSync = prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false),
+ filterRecursively = prefs.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true),
+ searchOnStart = prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false),
+ showHidden = prefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false),
+ biometric = prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false),
+ defaultUsername = prefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME).orEmpty(),
+ customSuffixes =
+ prefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES).orEmpty(),
+ pwgenType = prefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: "diceware",
+ clipboardTimeout = prefs.getString(PreferenceKeys.GENERAL_SHOW_TIME).orEmpty(),
+ showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
+ copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
+ rebaseOnPull = gitSettings.rebaseOnPull,
+ isGitRepo = PasswordRepository.isGitRepo(),
+ httpsRemote = gitSettings.url?.startsWith("https") == true,
+ canShowSshKey = SshKey.canShowSshPublicKey,
+ // contains, never getString: the secret is not decrypted to decide whether to show a row.
+ savedSecret =
+ when {
+ gitPrefs.contains(PreferenceKeys.HTTPS_PASSWORD) -> R.string.clear_saved_passphrase_https
+ gitPrefs.contains(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) ->
+ R.string.clear_saved_passphrase_ssh
+ else -> null
+ },
+ clearClipboardHistory = prefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false),
+ debugLogging = prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false),
+ asciiArmor = prefs.getBoolean(PreferenceKeys.ASCII_ARMOR, false),
+ passphraseCache = prefs.getBoolean(Feature.EnablePGPPassphraseCache.configKey, false),
+ autoClearCache = prefs.getBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, true),
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
new file mode 100644
index 00000000..fd440996
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt
@@ -0,0 +1,345 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.git.base.BaseGitActivity.GitOp
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.snackbar
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.GitSettings.UpdateConnectionSettingsResult
+import app.passwordstore.util.settings.Protocol
+import com.github.michaelbull.result.fold
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.coroutines.launch
+
+/**
+ * Server URL and authentication mode; the SSH key's state ([sshKey], a string resource) with Manage
+ * while the mode is SSH key; "Clear saved host key" when one is saved. [progress] (a string
+ * resource) shows while a clone runs, and disables everything.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun GitServerScreen(
+ clone: Boolean,
+ url: String,
+ authMode: AuthMode,
+ @StringRes sshKey: Int,
+ hasHostKey: Boolean,
+ @StringRes progress: Int?,
+ onUrlChange: (String) -> Unit,
+ onAuthModeChange: (AuthMode) -> Unit,
+ onSshKey: () -> Unit,
+ onClearHostKey: () -> Unit,
+ onSubmit: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val idle = progress == null
+ val modes = remember(url) { authModes(url.trim()) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.title_activity_git_clone)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ OutlinedTextField(
+ value = url,
+ onValueChange = onUrlChange,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = idle,
+ label = { Text(stringResource(R.string.server_url)) },
+ singleLine = true,
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Done,
+ ),
+ )
+ Text(stringResource(R.string.connection_mode), style = MaterialTheme.typography.labelLarge)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ modes.forEachIndexed { index, mode ->
+ SegmentedButton(
+ selected = mode == authMode,
+ onClick = { onAuthModeChange(mode) },
+ shape = SegmentedButtonDefaults.itemShape(index, modes.size),
+ enabled = idle,
+ ) {
+ Text(stringResource(mode.label()))
+ }
+ }
+ }
+ if (authMode == AuthMode.SshKey)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.connection_mode_ssh_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(stringResource(sshKey))
+ }
+ TextButton(onClick = onSshKey, enabled = idle) {
+ Text(stringResource(R.string.ssh_key_manage))
+ }
+ }
+ if (hasHostKey)
+ OutlinedButton(onClick = onClearHostKey, enabled = idle) {
+ Text(stringResource(R.string.clear_saved_host_key))
+ }
+ if (progress != null) {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ Text(stringResource(progress))
+ }
+ Button(onClick = onSubmit, modifier = Modifier.fillMaxWidth(), enabled = idle) {
+ Text(stringResource(if (clone) R.string.clone_button else R.string.crypto_save))
+ }
+ }
+ }
+}
+
+@StringRes
+private fun AuthMode.label(): Int =
+ when (this) {
+ AuthMode.SshKey -> R.string.connection_mode_ssh_key
+ AuthMode.Password -> R.string.connection_mode_basic_authentication
+ AuthMode.None -> R.string.connection_mode_none
+ }
+
+private sealed interface ServerDialog {
+ data class Url(val problem: UrlProblem) : ServerDialog
+
+ data class Username(val protocol: Protocol) : ServerDialog
+
+ data object DeleteStore : ServerDialog
+}
+
+/**
+ * The git server screen: wires [GitServerScreen] to the [viewModel], the URL checks, the save and,
+ * with [clone], the clone into the store folder. [onCloned] gets whether the store still needs a
+ * PGP key. A failed clone stays here, so URL or SSH key can be fixed and the clone retried.
+ */
+@Composable
+fun GitServerDestination(
+ activity: MainActivity,
+ clone: Boolean,
+ onClose: () -> Unit,
+ onSshKey: () -> Unit,
+ onCloned: (keyNeeded: Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: GitServerViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val keyboard = LocalSoftwareKeyboardController.current
+ var dialog by remember { mutableStateOf<ServerDialog?>(null) }
+
+ // Back from the SSH key screen (or from an instance it opened) with another key.
+ LifecycleResumeEffect(Unit) {
+ viewModel.refresh()
+ onPauseOrDispose {}
+ }
+ // A finished clone moves on from here, also when the screen was recreated in between.
+ val cloned = viewModel.cloned
+ LaunchedEffect(cloned) { if (cloned != null) onCloned(cloned) }
+ // A running clone holds the screen: leaving would cancel it half way.
+ BackHandler(enabled = viewModel.progress != null) {}
+
+ fun say(message: String) {
+ activity.snackbar(message = message, length = Snackbar.LENGTH_LONG)
+ }
+
+ // ponytail: a theme or locale change cancels the clone with the activity's scope, like every
+ // git operation of the app; the finally block frees the screen, and a new clone first offers to
+ // delete what was left. Once the clone succeeded, the key check runs in the ViewModel's scope
+ // (finishClone), so a recreation from then on loses nothing.
+ fun startClone() {
+ viewModel.progress = R.string.git_clone_progress
+ activity.lifecycleScope.launch {
+ try {
+ // A folder that could not be emptied is reported as such, not as a vague clone error.
+ viewModel.clearStore(root)?.let { failure ->
+ say(activity.getString(failure))
+ return@launch
+ }
+ activity
+ .launchGitOperation(GitOp.CLONE)
+ .fold(
+ success = { viewModel.finishClone(root) },
+ failure = { activity.promptOnErrorHandler(it) },
+ )
+ } finally {
+ viewModel.cloneEnded()
+ }
+ }
+ }
+
+ fun submit() {
+ if (viewModel.progress != null) return
+ keyboard?.hide()
+ val problem = urlProblem(viewModel.url.trim())
+ if (problem != null) {
+ dialog = ServerDialog.Url(problem)
+ return
+ }
+ when (val result = viewModel.save()) {
+ UpdateConnectionSettingsResult.FailedToParseUrl ->
+ say(activity.getString(R.string.git_server_config_save_error))
+ is UpdateConnectionSettingsResult.MissingUsername ->
+ dialog = ServerDialog.Username(result.newProtocol)
+ is UpdateConnectionSettingsResult.AuthModeMismatch ->
+ say(
+ activity.getString(
+ R.string.git_server_config_save_auth_mode_mismatch,
+ result.newProtocol,
+ result.validModes.joinToString(", "),
+ )
+ )
+ UpdateConnectionSettingsResult.Valid ->
+ if (!clone) {
+ Toast.makeText(activity, R.string.git_server_config_save_success, Toast.LENGTH_SHORT)
+ .show()
+ onClose()
+ } else {
+ if (PasswordRepository.repository == null) PasswordRepository.initialize()
+ if (cloneTarget(root) == CloneTarget.Occupied) dialog = ServerDialog.DeleteStore
+ else startClone()
+ }
+ }
+ }
+
+ Box(modifier) {
+ GitServerScreen(
+ clone = clone,
+ url = viewModel.url,
+ authMode = viewModel.authMode,
+ sshKey = viewModel.sshKey,
+ hasHostKey = viewModel.hasHostKey,
+ progress = viewModel.progress,
+ onUrlChange = viewModel::changeUrl,
+ onAuthModeChange = { viewModel.authMode = it },
+ onSshKey = onSshKey,
+ onClearHostKey = {
+ viewModel.clearHostKey()
+ say(activity.getString(R.string.clear_saved_host_key_success))
+ },
+ onSubmit = ::submit,
+ onBack = { if (viewModel.progress == null) onClose() },
+ )
+ when (val current = dialog) {
+ null -> Unit
+ is ServerDialog.Url -> {
+ val (title, text) =
+ when (current.problem) {
+ UrlProblem.HttpsPort ->
+ R.string.https_scheme_with_port_title to R.string.https_scheme_with_port_message
+ UrlProblem.SshScheme ->
+ R.string.ssh_scheme_needed_title to R.string.ssh_scheme_needed_message
+ UrlProblem.GitScheme ->
+ R.string.git_scheme_disallowed_title to R.string.git_scheme_disallowed_message
+ }
+ val fixed = fixUrl(viewModel.url.trim(), current.problem)
+ SetupDialog(
+ title = stringResource(title),
+ text = stringResource(text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = {
+ dialog = null
+ fixed?.let(viewModel::changeUrl)
+ },
+ onDismiss = { dialog = null },
+ dismiss = if (fixed != null) stringResource(R.string.dialog_cancel) else null,
+ )
+ }
+ is ServerDialog.Username ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_scheme_needed_title),
+ text =
+ stringResource(
+ if (current.protocol == Protocol.Https)
+ R.string.git_server_config_save_missing_username_https
+ else R.string.git_server_config_save_missing_username_ssh
+ ),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = null },
+ onDismiss = { dialog = null },
+ )
+ ServerDialog.DeleteStore ->
+ SetupDialog(
+ title = stringResource(R.string.dialog_delete_title),
+ text = stringResource(R.string.dialog_delete_msg, root.toString()),
+ confirm = stringResource(R.string.dialog_delete),
+ onConfirm = {
+ dialog = null
+ startClone()
+ },
+ onDismiss = { dialog = null },
+ dismiss = stringResource(R.string.dialog_do_not_delete),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt
new file mode 100644
index 00000000..5995bf5b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.settings.GitSettings
+import com.github.michaelbull.result.get
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import javax.inject.Inject
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * The git server screen's state: URL and mode being edited, the clone's [progress] and outcome
+ * ([cloned]), the host key and SSH key state. The clone itself runs in the destination (it needs
+ * the activity); this ViewModel keeps what must survive a recreation.
+ */
+@HiltViewModel
+class GitServerViewModel
+@Inject
+constructor(
+ private val gitSettings: GitSettings,
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var url by mutableStateOf(gitSettings.url.orEmpty())
+ private set
+
+ var authMode by mutableStateOf(fitAuthMode(gitSettings.url.orEmpty(), gitSettings.authMode))
+
+ /** What a running clone does, as a string resource; null when idle. */
+ var progress by mutableStateOf<Int?>(null)
+
+ /** Null until a clone succeeded; then whether the store still needs a PGP key imported. */
+ var cloned by mutableStateOf<Boolean?>(null)
+ private set
+
+ var hasHostKey by mutableStateOf(false)
+ private set
+
+ var sshKey by mutableIntStateOf(R.string.ssh_key_state_none)
+ private set
+
+ init {
+ refresh()
+ }
+
+ /** Re-reads what other screens change: the saved host key and the SSH key. */
+ fun refresh() {
+ hasHostKey = gitSettings.hasSavedHostKey()
+ sshKey = sshKeySummary()
+ }
+
+ fun changeUrl(value: String) {
+ url = value
+ authMode = fitAuthMode(value.trim(), authMode)
+ }
+
+ fun clearHostKey() {
+ gitSettings.clearSavedHostKey()
+ hasHostKey = false
+ }
+
+ /** Validates and stores URL and mode (unchanged rules in [GitSettings]). */
+ fun save(): GitSettings.UpdateConnectionSettingsResult =
+ gitSettings.updateConnectionSettingsIfValid(newAuthMode = authMode, newUrl = url.trim())
+
+ /**
+ * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a
+ * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP
+ * keys with it. Returns null, or why it failed as a string resource: then the clone must not
+ * start.
+ */
+ suspend fun clearStore(root: File): Int? {
+ if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null
+ progress = R.string.delete_directory_progress_text
+ if (!withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() })
+ return R.string.git_clone_delete_failed
+ progress = R.string.git_clone_progress
+ return null
+ }
+
+ /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */
+ private var checking = false
+
+ /**
+ * After a successful clone into [root], checks in [viewModelScope] whether its key is missing and
+ * records the answer in [cloned]: no recipient of the root `.gpg-id` is an imported secret key
+ * (without recipients: no secret key is imported at all). A public key alone cannot decrypt. In
+ * [viewModelScope], so a recreation during the check cannot lose the answer.
+ */
+ fun finishClone(root: File) {
+ checking = true
+ viewModelScope.launch {
+ try {
+ cloned =
+ withContext(dispatcherProvider.io()) {
+ val ids = readGpgId(root)?.let(::storeRecipients).orEmpty()
+ if (ids.isEmpty()) keyManager.getAllKeys().get().orEmpty().none(KeyUtils::isSecretKey)
+ else
+ ids.none { id -> keyManager.getKeyById(id).get()?.let(KeyUtils::isSecretKey) == true }
+ }
+ } finally {
+ checking = false
+ progress = null
+ }
+ }
+ }
+
+ /** The clone's coroutine ended (done, failed or cancelled): free the screen, re-read state. */
+ fun cloneEnded() {
+ if (!checking) progress = null
+ refresh()
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
new file mode 100644
index 00000000..11fd979c
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.annotation.Keep
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.extensions.commitChange
+import app.passwordstore.util.extensions.snackbar
+import com.github.michaelbull.result.onFailure
+import com.google.android.material.snackbar.Snackbar
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+
+/** What the PGP keys screen is for. A route argument: kept from minification. */
+@Keep
+@Serializable
+enum class KeysMode {
+ /** Import and delete keys (Settings). */
+ Manage,
+ /** Pick the key a new local store is encrypted for. */
+ Choose,
+ /** A key is needed (after a clone, or by an entry): import it, or go on without. */
+ Need,
+}
+
+/**
+ * The imported [keys] with the import FAB. [mode] decides what a row does (select in
+ * [KeysMode.Choose], delete in [KeysMode.Manage]) and the guidance shown; [recipients] are the
+ * store's `.gpg-id` entries, listed in [KeysMode.Need].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun PgpKeysScreen(
+ mode: KeysMode,
+ keys: ImmutableList<KeyRow>,
+ recipients: ImmutableList<String>,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: (KeyRow) -> Unit,
+ onLater: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var deleting by remember { mutableStateOf<KeyRow?>(null) }
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ stringResource(
+ when (mode) {
+ KeysMode.Manage -> R.string.activity_label_pgp_key_manager
+ KeysMode.Choose -> R.string.gpg_key_select
+ KeysMode.Need -> R.string.pref_import_pgp_key_title
+ }
+ )
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ actions = {
+ if (mode == KeysMode.Need)
+ TextButton(onClick = onLater) { Text(stringResource(R.string.ssh_keygen_later)) }
+ },
+ )
+ },
+ floatingActionButton = {
+ // M3 clears the semantics of the FAB's content, label and icon alike: describe the FAB.
+ val label = stringResource(R.string.pref_import_pgp_key_title)
+ ExtendedFloatingActionButton(
+ text = { Text(label) },
+ icon = { Icon(Icons.Filled.Add, null) },
+ onClick = { if (!busy) onImport() },
+ modifier = Modifier.semantics { contentDescription = label },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ val message =
+ when (mode) {
+ KeysMode.Manage -> null
+ KeysMode.Choose -> R.string.select_gpg_key_message
+ KeysMode.Need -> R.string.pgp_key_needed_message
+ }
+ if (message != null)
+ Text(
+ stringResource(message),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (mode == KeysMode.Need && recipients.isNotEmpty())
+ GroupedSurface {
+ Column(Modifier.padding(SpacingLarge)) {
+ Text(
+ stringResource(R.string.pgp_key_needed_recipients),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ recipients.forEach { Text(it, fontFamily = FontFamily.Monospace) }
+ }
+ }
+ if (keys.isEmpty())
+ Text(
+ stringResource(R.string.pgp_key_manager_no_keys_guidance),
+ Modifier.padding(SpacingLarge),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ else
+ GroupedSurface {
+ keys.forEach { row ->
+ KeyItem(row = row, mode = mode, onSelect = onSelect, onDelete = { deleting = row })
+ }
+ }
+ // Room for the FAB below the last row.
+ Spacer(Modifier.height(88.dp))
+ }
+ }
+ deleting?.let { row ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_manager_delete_confirmation_dialog_title),
+ text = row.userId ?: row.keyId.toString(),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ deleting = null
+ onDelete(row)
+ },
+ onDismiss = { deleting = null },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ }
+}
+
+@Composable
+private fun KeyItem(
+ row: KeyRow,
+ mode: KeysMode,
+ onSelect: (KeyRow) -> Unit,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .then(if (mode == KeysMode.Choose) Modifier.clickable { onSelect(row) } else Modifier)
+ .padding(horizontal = SpacingLarge, vertical = SpacingMedium),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ row.userId ?: row.keyId.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (row.userId != null)
+ Text(
+ row.keyId.toString(),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (mode == KeysMode.Manage)
+ IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, stringResource(R.string.delete)) }
+ }
+}
+
+/**
+ * The PGP keys screen: wires [PgpKeysScreen] to the [viewModel], the document picker and the import
+ * dialogs. [onImported] runs after every successful import; [onDone] ends the flow: after an
+ * import, "Later" or back in [KeysMode.Need], after the new store is created in [KeysMode.Choose].
+ * [onClose] leaves the other modes.
+ */
+@Composable
+fun PgpKeysDestination(
+ activity: MainActivity,
+ mode: KeysMode,
+ onClose: () -> Unit,
+ onDone: () -> Unit,
+ onImported: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: PgpKeysViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ // Read off the main thread, symlink- and size-checked (readGpgId).
+ LaunchedEffect(mode) { if (mode == KeysMode.Need) viewModel.loadRecipients(root) }
+ // A created store moves on from here, also when the screen was recreated in between.
+ val done = viewModel.done
+ LaunchedEffect(done) { if (done) onDone() }
+
+ fun leave() {
+ if (viewModel.busy) return
+ if (mode == KeysMode.Need) onDone() else onClose()
+ }
+ BackHandler(onBack = ::leave)
+
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri ->
+ if (uri != null) viewModel.import(uri, activity)
+ }
+
+ // ponytail: a theme or locale change in the middle cancels the commit. Choosing the same key
+ // again finds the store already made for it (createdFor), commits the `.gpg-id` and moves on.
+ fun choose(row: KeyRow) {
+ if (viewModel.busy) return
+ viewModel.busy = true
+ activity.lifecycleScope.launch {
+ try {
+ val error = viewModel.createStore(root, row, activity.getString(R.string.store_not_empty))
+ if (error != null) {
+ activity.snackbar(
+ message = activity.getString(R.string.store_create_failed, error),
+ length = Snackbar.LENGTH_LONG,
+ )
+ } else {
+ // A failed commit is reported like any git error; the store exists either way, and
+ // the next commit picks up the `.gpg-id`.
+ activity
+ .commitChange(
+ activity.getString(R.string.git_commit_gpg_id, activity.getString(R.string.app_name))
+ )
+ .onFailure { activity.promptOnErrorHandler(it) }
+ viewModel.markDone()
+ }
+ } finally {
+ viewModel.busy = false
+ }
+ }
+ }
+
+ Box(modifier) {
+ PgpKeysScreen(
+ mode = mode,
+ keys = viewModel.keys,
+ recipients = viewModel.recipients,
+ busy = viewModel.busy,
+ onImport = { picker.launch(arrayOf("*/*")) },
+ onSelect = ::choose,
+ onDelete = viewModel::deleteKey,
+ onLater = ::leave,
+ onBack = ::leave,
+ )
+ when (val result = viewModel.importResult) {
+ null -> Unit
+ is KeyImport.Imported -> {
+ fun accept() {
+ viewModel.dismissImport()
+ onImported()
+ if (mode == KeysMode.Need) onDone()
+ }
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_succeeded),
+ text = stringResource(R.string.pgp_key_import_succeeded_message, result.keyId),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = ::accept,
+ onDismiss = ::accept,
+ )
+ }
+ KeyImport.Exists ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = stringResource(R.string.pgp_key_import_failed_replace_message),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = viewModel::replace,
+ onDismiss = viewModel::dismissImport,
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ is KeyImport.Failed ->
+ SetupDialog(
+ title = stringResource(R.string.pgp_key_import_failed),
+ text = result.message.orEmpty(),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissImport,
+ onDismiss = viewModel::dismissImport,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt
new file mode 100644
index 00000000..8f1b52ec
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.crypto.KeyUtils
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.crypto.PGPKey
+import app.passwordstore.crypto.PGPKeyManager
+import app.passwordstore.crypto.errors.KeyAlreadyExistsException
+import app.passwordstore.crypto.errors.KeyDowngradeException
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.list.deleteWithoutFollowingLinks
+import app.passwordstore.util.coroutines.DispatcherProvider
+import com.github.michaelbull.result.onSuccess
+import dagger.hilt.android.lifecycle.HiltViewModel
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import logcat.LogPriority.ERROR
+import logcat.asLog
+import logcat.logcat
+import org.eclipse.jgit.api.errors.GitAPIException
+import org.eclipse.jgit.api.errors.JGitInternalException
+
+/** One imported key: its first user ID, when it has one, and its long key ID. */
+@Immutable data class KeyRow(val userId: String?, val keyId: PGPIdentifier.KeyId)
+
+/** The outcome of a PGP key import, shown until dismissed. */
+sealed interface KeyImport {
+ data class Imported(val keyId: String) : KeyImport
+
+ /** A key with the same ID is imported already: the screen asks whether to replace it. */
+ data object Exists : KeyImport
+
+ data class Failed(val message: String?) : KeyImport
+}
+
+/**
+ * The PGP keys screen's state and work: the imported [keys], import with the replace question,
+ * deletion by key ID, and the creation of a new local store for a chosen key. Holds no Context;
+ * calls take one and use the application context.
+ */
+@HiltViewModel
+class PgpKeysViewModel
+@Inject
+constructor(
+ private val keyManager: PGPKeyManager,
+ private val dispatcherProvider: DispatcherProvider,
+) : ViewModel() {
+
+ var keys: ImmutableList<KeyRow> by mutableStateOf(persistentListOf())
+ private set
+
+ /** Set while an import or a store creation runs: further taps wait. */
+ var busy by mutableStateOf(false)
+
+ var importResult by mutableStateOf<KeyImport?>(null)
+ private set
+
+ /** Set once a new store is created and committed: the screen moves on. */
+ var done by mutableStateOf(false)
+ private set
+
+ /** The root `.gpg-id` recipients, for the `Need` screen; see [loadRecipients]. */
+ var recipients: ImmutableList<String> by mutableStateOf(persistentListOf())
+ private set
+
+ /**
+ * The picked file while the replace question is open. Memory only, zeroed as soon as it is not
+ * needed ([dropPending]): it may be a private key.
+ */
+ private var pending: ByteArray? = null
+
+ init {
+ updateKeySet()
+ }
+
+ /** Reads the recipients of the store at [root] off the main thread. */
+ fun loadRecipients(root: File) {
+ viewModelScope.launch {
+ recipients =
+ withContext(dispatcherProvider.io()) {
+ readGpgId(root)?.let(::storeRecipients).orEmpty().map(PGPIdentifier::toString)
+ }
+ .toPersistentList()
+ }
+ }
+
+ private fun dropPending() {
+ pending?.fill(0)
+ pending = null
+ }
+
+ fun updateKeySet() {
+ viewModelScope.launch {
+ keyManager.getAllKeys().onSuccess { all ->
+ keys =
+ withContext(dispatcherProvider.default()) { all.mapNotNull(::row) }.toPersistentList()
+ }
+ }
+ }
+
+ private fun row(key: PGPKey): KeyRow? {
+ val id = KeyUtils.tryGetId(key) ?: return null
+ return KeyRow(KeyUtils.tryGetEmail(key)?.email, id)
+ }
+
+ fun deleteKey(row: KeyRow) {
+ viewModelScope.launch {
+ keyManager.removeKey(row.keyId)
+ updateKeySet()
+ }
+ }
+
+ /** Reads the key file at [uri] (at most [MAX_KEY_BYTES]) and imports it. */
+ fun import(uri: Uri, context: Context) {
+ if (busy) return
+ busy = true
+ val resolver = context.applicationContext.contentResolver
+ val tooLarge = context.getString(R.string.pgp_key_import_too_large)
+ val downgrade = context.getString(R.string.pgp_key_import_downgrade)
+ viewModelScope.launch {
+ try {
+ val bytes =
+ withContext(dispatcherProvider.io()) {
+ resolver.openInputStream(uri)?.use {
+ readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge)
+ }
+ }
+ if (bytes == null) importResult = KeyImport.Failed(null)
+ else add(bytes, replace = false, downgrade)
+ } catch (e: IOException) {
+ importResult = KeyImport.Failed(e.message)
+ } catch (e: SecurityException) {
+ importResult = KeyImport.Failed(e.message)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Imports the key the replace question was about, over the one with its ID. */
+ fun replace() {
+ val bytes = pending ?: return
+ pending = null
+ importResult = null
+ busy = true
+ viewModelScope.launch {
+ try {
+ add(bytes, replace = true)
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ fun dismissImport() {
+ dropPending()
+ importResult = null
+ }
+
+ /**
+ * Imports [bytes]; they are zeroed afterwards, also on failure or cancellation, unless the
+ * replace question keeps them. [downgrade] is the message for a public key over a secret one.
+ */
+ private suspend fun add(bytes: ByteArray, replace: Boolean, downgrade: String? = null) {
+ var keep = false
+ try {
+ val result = keyManager.addKey(PGPKey(bytes), replace)
+ if (!replace && result.isErr && result.error is KeyAlreadyExistsException) {
+ // A question still open from an earlier pick is superseded: zero its bytes first.
+ dropPending()
+ pending = bytes
+ keep = true
+ importResult = KeyImport.Exists
+ return
+ }
+ // The ID is read before the bytes are zeroed: the returned key shares them.
+ importResult =
+ if (result.isOk) {
+ val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) }
+ KeyImport.Imported(id?.toString().orEmpty())
+ } else if (result.error is KeyDowngradeException) {
+ KeyImport.Failed(downgrade ?: result.error.message)
+ } else KeyImport.Failed(result.error.message)
+ if (result.isOk) updateKeySet()
+ } finally {
+ if (!keep) bytes.fill(0)
+ }
+ }
+
+ /**
+ * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by
+ * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed; [occupied] is the
+ * reason given when [root] already holds a store, unless that store was made for [row] already.
+ */
+ suspend fun createStore(root: File, row: KeyRow, occupied: String): String? =
+ withContext(dispatcherProvider.io()) {
+ if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) {
+ // Made by an earlier attempt for this key whose commit a recreation cancelled: done.
+ if (createdFor(root, row.keyId)) return@withContext null
+ // A restored, already-cloned store must keep its .gpg-id.
+ return@withContext occupied
+ }
+ try {
+ check(root.isDirectory || root.mkdirs()) { "Could not create $root" }
+ PasswordRepository.createRepository(root)
+ File(root, ".gpg-id").writeText(gpgIdText(listOf(row.keyId)))
+ null
+ } catch (e: IOException) {
+ failed(root, e)
+ } catch (e: GitAPIException) {
+ failed(root, e)
+ } catch (e: JGitInternalException) {
+ failed(root, e)
+ } catch (e: IllegalStateException) {
+ failed(root, e)
+ }
+ }
+
+ private fun failed(root: File, e: Exception): String {
+ logcat(ERROR) { e.asLog() }
+ // Removes what this attempt made, including a fresh .git, without following links.
+ root.deleteWithoutFollowingLinks()
+ return e.message.orEmpty()
+ }
+
+ fun markDone() {
+ done = true
+ }
+
+ override fun onCleared() {
+ dropPending()
+ }
+}
+
+/** The largest key file imported: far above any real key, small enough to hold in memory. */
+private const val MAX_KEY_BYTES = 1024 * 1024
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
new file mode 100644
index 00000000..40dfae74
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.app.Activity
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import app.passwordstore.ui.main.ListRoute
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.settings.SettingsRoute
+import kotlinx.serialization.Serializable
+
+/** The first screen without a store. */
+@Serializable data object WelcomeRoute
+
+/** Git server settings; with [clone], saving them clones the store. */
+@Serializable data class GitServerRoute(val clone: Boolean)
+
+/** The SSH key: state, public key, import. */
+@Serializable data object SshKeyRoute
+
+/** SSH key generation. */
+@Serializable data object SshKeyGenRoute
+
+/** The PGP keys, for [mode]. */
+@Serializable data class PgpKeysRoute(val mode: KeysMode = KeysMode.Manage)
+
+/** The setup screens [MainActivity.setupIntent] opens on their own. */
+enum class SetupStart(val route: Any) {
+ Ssh(SshKeyRoute),
+ SshGenerate(SshKeyGenRoute),
+ PgpImport(PgpKeysRoute(KeysMode.Need)),
+}
+
+/**
+ * The setup destinations. In an instance started for one of them ([MainActivity.setupIntent]),
+ * leaving its first screen finishes the activity.
+ */
+fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) {
+ fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id
+
+ fun close(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish() else nav.popBackStack()
+ }
+
+ // Setup is complete: the new store's list replaces the whole stack. An instance started for
+ // this screen alone just finishes.
+ fun complete(entry: NavBackStackEntry) {
+ if (!onTop(entry)) return
+ if (nav.previousBackStackEntry == null) activity.finish()
+ else nav.navigate(ListRoute()) { popUpTo(nav.graph.id) { inclusive = true } }
+ }
+
+ composable<WelcomeRoute> { entry ->
+ WelcomeScreen(
+ onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) },
+ onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) },
+ onSettings = { if (onTop(entry)) nav.navigate(SettingsRoute) },
+ )
+ }
+ composable<GitServerRoute> { entry ->
+ GitServerDestination(
+ activity = activity,
+ clone = entry.toRoute<GitServerRoute>().clone,
+ onClose = { close(entry) },
+ onSshKey = { if (onTop(entry)) nav.navigate(SshKeyRoute) },
+ onCloned = { keyNeeded ->
+ if (!keyNeeded) complete(entry)
+ else if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Need))
+ },
+ )
+ }
+ composable<SshKeyRoute> { entry ->
+ SshKeyDestination(
+ activity = activity,
+ onClose = { close(entry) },
+ onGenerate = { if (onTop(entry)) nav.navigate(SshKeyGenRoute) },
+ )
+ }
+ composable<SshKeyGenRoute> { entry ->
+ SshKeyGenDestination(activity = activity, onClose = { close(entry) })
+ }
+ composable<PgpKeysRoute> { entry ->
+ PgpKeysDestination(
+ activity = activity,
+ mode = entry.toRoute<PgpKeysRoute>().mode,
+ onClose = { close(entry) },
+ onDone = { complete(entry) },
+ onImported = { activity.setResult(Activity.RESULT_OK) },
+ )
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt
new file mode 100644
index 00000000..4e32505f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.nio.channels.Channels
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.StandardOpenOption
+import java.util.Locale
+
+/** Why a repository URL is questioned before it is saved, as the old server screen checked it. */
+enum class UrlProblem {
+ /** An HTTPS URL with a custom port, which the old screen did not support. */
+ HttpsPort,
+ /** An scp-style SSH URL with a port: without `ssh://` the port is read as part of the path. */
+ SshScheme,
+ /** The unencrypted `git://` protocol. */
+ GitScheme,
+}
+
+private val PORT = ":[0-9]{1,5}/".toRegex()
+
+private fun isHttp(url: String) = url.startsWith("http://") || url.startsWith("https://")
+
+/** What is wrong with [url], or null. `git://` is checked first: a port does not make it SSH. */
+fun urlProblem(url: String): UrlProblem? =
+ when {
+ url.startsWith("git://") -> UrlProblem.GitScheme
+ !url.contains(PORT) -> null
+ url.startsWith("https://") -> UrlProblem.HttpsPort
+ url.startsWith("http://") || url.startsWith("ssh://") -> null
+ else -> UrlProblem.SshScheme
+ }
+
+/** The old screen's fix for [problem] in [url], or null when there is none (`git://`). */
+fun fixUrl(url: String, problem: UrlProblem): String? =
+ when (problem) {
+ UrlProblem.HttpsPort -> url.replace(PORT, "/")
+ UrlProblem.SshScheme -> "ssh://$url"
+ UrlProblem.GitScheme -> null
+ }
+
+/** The authentication modes offered for [url], the default first. */
+fun authModes(url: String): List<AuthMode> =
+ if (isHttp(url)) listOf(AuthMode.None, AuthMode.Password)
+ else listOf(AuthMode.SshKey, AuthMode.Password)
+
+/** [current] when [url] still allows it, the default for [url] otherwise. */
+fun fitAuthMode(url: String, current: AuthMode): AuthMode =
+ authModes(url).let { modes -> if (current in modes) current else modes.first() }
+
+/** What a clone into the store folder finds there. */
+enum class CloneTarget {
+ /** Missing or empty. */
+ Empty,
+ /** Only a `.git` folder, from a store created and abandoned: deleted without asking. */
+ OnlyGit,
+ /** Anything else: deleted after a confirmation. */
+ Occupied,
+}
+
+fun cloneTarget(dir: File): CloneTarget {
+ val files = dir.listFiles().orEmpty()
+ return when {
+ files.isEmpty() -> CloneTarget.Empty
+ files.size == 1 && files[0].name == ".git" -> CloneTarget.OnlyGit
+ else -> CloneTarget.Occupied
+ }
+}
+
+/**
+ * The recipients named by a `.gpg-id` file's [text], read as pass reads it: a `#` starts a comment,
+ * and a trailing `!` (GnuPG's "this exact subkey") is dropped. Blank and unparsable lines are
+ * skipped.
+ */
+fun storeRecipients(text: String): List<PGPIdentifier> =
+ text
+ .lines()
+ .map { it.substringBefore('#').trim().removeSuffix("!") }
+ .filter(String::isNotEmpty)
+ .mapNotNull(PGPIdentifier::fromString)
+
+/**
+ * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format
+ * of a desktop store made with `pass init <key ID>`.
+ */
+fun gpgIdText(keys: List<PGPIdentifier.KeyId>): String =
+ keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" }
+
+/**
+ * Whether [dir] holds a store already made for [key]: its `.gpg-id` is exactly what creating one
+ * for [key] writes. Blocking: call it off the main thread.
+ */
+fun createdFor(dir: File, key: PGPIdentifier.KeyId): Boolean =
+ readGpgId(dir) == gpgIdText(listOf(key))
+
+private const val MAX_GPG_ID_BYTES = 64 * 1024
+
+/**
+ * The text of [dir]'s `.gpg-id`, or null when it is missing, not a regular file, a symlink, larger
+ * than 64 KiB or unreadable. Blocking: call it off the main thread.
+ */
+fun readGpgId(dir: File): String? {
+ val path = File(dir, ".gpg-id").toPath()
+ if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return null
+ // Opened without following a link too: one swapped in after the check is refused.
+ val options = setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)
+ return try {
+ Channels.newInputStream(Files.newByteChannel(path, options))
+ .use { readAtMost(it, MAX_GPG_ID_BYTES) }
+ ?.decodeToString()
+ } catch (_: IOException) {
+ null
+ }
+}
+
+/**
+ * At most [limit] bytes of [input], or null when it holds more. Reads at most [limit] + 1 bytes,
+ * whatever the stream's size, and zeroes its buffer: the bytes may be a private key.
+ */
+fun readAtMost(input: InputStream, limit: Int): ByteArray? {
+ val buffer = ByteArray(limit + 1)
+ try {
+ var size = 0
+ while (size < buffer.size) {
+ val read = input.read(buffer, size, buffer.size - size)
+ if (read < 0) break
+ size += read
+ }
+ return if (size > limit) null else buffer.copyOf(size)
+ } finally {
+ buffer.fill(0)
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
new file mode 100644
index 00000000..f93fc9be
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
@@ -0,0 +1,379 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.OpenDocument
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import app.passwordstore.R
+import app.passwordstore.ui.compose.GroupedSurface
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.util.auth.BiometricAuthenticator
+import app.passwordstore.util.extensions.keyguardManager
+import app.passwordstore.util.git.sshj.SshKey
+
+/**
+ * The SSH key: its state ([summary], a string resource), the public key with copy and share when
+ * the device generated it, import and generation.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyScreen(
+ @StringRes summary: Int,
+ publicKey: String?,
+ busy: Boolean,
+ onImport: () -> Unit,
+ onGenerate: () -> Unit,
+ onCopy: () -> Unit,
+ onShare: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.ssh_keygen_existing_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())) {
+ if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
+ GroupedSurface {
+ Text(
+ stringResource(summary),
+ Modifier.padding(SpacingLarge),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ if (publicKey != null)
+ GroupedSurface {
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Text(
+ stringResource(R.string.your_public_key),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ SelectionContainer {
+ Text(
+ publicKey,
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ )
+ }
+ Text(
+ stringResource(R.string.ssh_key_public_hint),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row {
+ TextButton(onClick = onCopy) { Text(stringResource(R.string.copy_label)) }
+ TextButton(onClick = onShare) { Text(stringResource(R.string.ssh_keygen_share)) }
+ }
+ }
+ }
+ Column(
+ Modifier.padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Button(onClick = onImport, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_import_ssh_key_title))
+ }
+ OutlinedButton(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.pref_ssh_keygen_title))
+ }
+ }
+ }
+ }
+}
+
+/** A chooser sharing [publicKey] as text. */
+private fun shareKey(publicKey: String): Intent =
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, publicKey),
+ null,
+ )
+
+/**
+ * The SSH key screen: wires [SshKeyScreen] to the [viewModel], the document picker and the replace
+ * confirmation. [onGenerate] opens the generation screen.
+ */
+@Composable
+fun SshKeyDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ onGenerate: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ var resumes by remember { mutableIntStateOf(0) }
+ // Back from the generation screen (or another instance) with a new key: read it again.
+ LifecycleResumeEffect(Unit) {
+ resumes++
+ onPauseOrDispose {}
+ }
+ val version = viewModel.version
+ val summary = remember(resumes, version) { sshKeySummary() }
+ val publicKey =
+ remember(resumes, version) { if (SshKey.canShowSshPublicKey) SshKey.sshPublicKey else null }
+ var replace by remember { mutableStateOf(false) }
+ val picker =
+ rememberLauncherForActivityResult(OpenDocument()) { uri ->
+ if (uri != null) viewModel.import(uri)
+ }
+
+ LaunchedEffect(viewModel.imported) {
+ if (viewModel.imported) {
+ Toast.makeText(activity, R.string.ssh_key_success_dialog_title, Toast.LENGTH_LONG).show()
+ viewModel.announced()
+ }
+ }
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyScreen(
+ summary = summary,
+ publicKey = publicKey,
+ busy = viewModel.busy,
+ onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) },
+ onGenerate = onGenerate,
+ onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } },
+ onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ picker.launch(arrayOf("*/*"))
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ )
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.ssh_key_error_dialog_title),
+ text = message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = viewModel::dismissFailure,
+ onDismiss = viewModel::dismissFailure,
+ )
+ }
+ }
+}
+
+/** Key type with its explanation, the screen lock switch and Generate. */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SshKeyGenScreen(
+ type: SshKeyType,
+ protect: Boolean,
+ canProtect: Boolean,
+ busy: Boolean,
+ onTypeChange: (SshKeyType) -> Unit,
+ onProtectChange: (Boolean) -> Unit,
+ onGenerate: () -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val types = SshKeyType.entries
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.pref_ssh_keygen_title)) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.entry_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLarge),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ ) {
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ types.forEachIndexed { index, entry ->
+ SegmentedButton(
+ selected = entry == type,
+ onClick = { onTypeChange(entry) },
+ shape = SegmentedButtonDefaults.itemShape(index, types.size),
+ enabled = !busy,
+ ) {
+ Text(stringResource(entry.label))
+ }
+ }
+ }
+ // ponytail: stringResource drops the explanations' bold title; plain text reads fine.
+ Text(stringResource(type.explanation), style = MaterialTheme.typography.bodyMedium)
+ Row(
+ Modifier.fillMaxWidth()
+ .toggleable(
+ value = protect,
+ enabled = canProtect && !busy,
+ role = Role.Switch,
+ onValueChange = onProtectChange,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(stringResource(R.string.ssh_keygen_require_authentication), Modifier.weight(1f))
+ Switch(checked = protect, onCheckedChange = null, enabled = canProtect && !busy)
+ }
+ Button(onClick = onGenerate, enabled = !busy, modifier = Modifier.fillMaxWidth()) {
+ Text(
+ stringResource(
+ if (busy) R.string.ssh_key_gen_generating_progress else R.string.ssh_keygen_generate
+ )
+ )
+ }
+ }
+ }
+}
+
+/**
+ * The key generation screen: replace confirmation, screen lock prompt when protected, then the
+ * generation in the [viewModel] and the public key (or error) dialog; both dialogs close it.
+ */
+@Composable
+fun SshKeyGenDestination(
+ activity: MainActivity,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SshKeyViewModel = hiltViewModel(),
+) {
+ val canProtect = remember { activity.keyguardManager.isDeviceSecure }
+ var replace by remember { mutableStateOf(false) }
+ // While the screen lock prompt is up. Plain remember: a recreation drops the prompt's callback,
+ // and the screen must not stay disabled for it.
+ var asking by remember { mutableStateOf(false) }
+
+ fun generate() {
+ if (!(canProtect && viewModel.protect)) return viewModel.generate(requireAuthentication = false)
+ asking = true
+ BiometricAuthenticator.authenticate(activity, R.string.biometric_prompt_title_ssh_keygen) {
+ result ->
+ // Failed attempts are handled by the prompt itself.
+ if (result is BiometricAuthenticator.Result.Retry) return@authenticate
+ asking = false
+ if (result is BiometricAuthenticator.Result.Success)
+ viewModel.generate(requireAuthentication = true)
+ else viewModel.fail(activity.getString(R.string.biometric_auth_generic_failure))
+ }
+ }
+
+ BackHandler(enabled = viewModel.busy) {}
+
+ Box(modifier) {
+ SshKeyGenScreen(
+ type = viewModel.type,
+ protect = canProtect && viewModel.protect,
+ canProtect = canProtect,
+ busy = viewModel.busy || asking,
+ onTypeChange = { viewModel.type = it },
+ onProtectChange = { viewModel.protect = it },
+ onGenerate = { if (SshKey.exists) replace = true else generate() },
+ onBack = { if (!viewModel.busy) onClose() },
+ )
+ if (replace)
+ SetupDialog(
+ title = stringResource(R.string.ssh_keygen_existing_title),
+ text = stringResource(R.string.ssh_keygen_existing_message),
+ confirm = stringResource(R.string.ssh_keygen_existing_replace),
+ onConfirm = {
+ replace = false
+ generate()
+ },
+ onDismiss = { replace = false },
+ dismiss = stringResource(R.string.ssh_keygen_existing_keep),
+ onDismissButton = onClose,
+ )
+ viewModel.generated?.let { key ->
+ SetupDialog(
+ title = stringResource(R.string.your_public_key),
+ text = stringResource(R.string.ssh_keygen_message, key),
+ confirm = stringResource(R.string.ssh_keygen_share),
+ onConfirm = {
+ activity.startActivity(shareKey(key))
+ onClose()
+ },
+ onDismiss = onClose,
+ dismiss = stringResource(R.string.ssh_keygen_later),
+ )
+ }
+ viewModel.failure?.let { message ->
+ SetupDialog(
+ title = stringResource(R.string.error_generate_ssh_key),
+ text = stringResource(R.string.ssh_key_error_dialog_text) + message,
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = onClose,
+ onDismiss = onClose,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt
new file mode 100644
index 00000000..bc3e9474
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import app.passwordstore.R
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.git.sshj.SshKey
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlin.coroutines.cancellation.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** The key types the generation screen offers, in the old screen's order. */
+enum class SshKeyType(@StringRes val label: Int, @StringRes val explanation: Int) {
+ Rsa(R.string.ssh_keygen_label_rsa, R.string.ssh_keygen_explanation_rsa),
+ Ecdsa(R.string.ssh_keygen_label_ecdsa, R.string.ssh_keygen_explanation_ecdsa),
+ Ed25519(R.string.ssh_keygen_label_ed25519, R.string.ssh_keygen_explanation_ed25519),
+}
+
+/** The current SSH key's state, as a string resource. */
+@StringRes
+fun sshKeySummary(): Int =
+ when {
+ !SshKey.exists -> R.string.ssh_key_state_none
+ SshKey.canShowSshPublicKey -> R.string.ssh_key_state_generated
+ else -> R.string.ssh_key_state_imported
+ }
+
+/**
+ * The SSH key screens' work: import a key file, generate a key. Both run in [viewModelScope] (no
+ * activity needed), so their outcome survives a recreation; the screens show it from [generated],
+ * [imported] and [failure]. [version] changes with the key, so screens re-read [SshKey].
+ */
+@HiltViewModel
+class SshKeyViewModel @Inject constructor(private val dispatcherProvider: DispatcherProvider) :
+ ViewModel() {
+
+ var type by mutableStateOf(SshKeyType.Ecdsa)
+
+ /** The "protect with screen lock" switch; only effective when the device is secure. */
+ var protect by mutableStateOf(true)
+
+ var busy by mutableStateOf(false)
+ private set
+
+ var version by mutableIntStateOf(0)
+ private set
+
+ /** Set after a successful import, until the screen showed its toast. */
+ var imported by mutableStateOf(false)
+ private set
+
+ /** The new public key after a generation. */
+ var generated by mutableStateOf<String?>(null)
+ private set
+
+ /** Why the last import or generation failed. */
+ var failure by mutableStateOf<String?>(null)
+ private set
+
+ /**
+ * Imports the private key at [uri]; [SshKey.import] reads it and keeps the old key on refusal.
+ */
+ fun import(uri: Uri) {
+ if (busy) return
+ busy = true
+ viewModelScope.launch {
+ try {
+ withContext(dispatcherProvider.io()) { SshKey.import(uri) }
+ imported = true
+ version++
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Any failure of a provider or of sshj, not only I/O: an uncaught one would crash the app.
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Generates a key of [type], replacing the current one; the prompt already ran if needed. */
+ fun generate(requireAuthentication: Boolean) {
+ if (busy) return
+ busy = true
+ val type = type
+ viewModelScope.launch {
+ try {
+ generated =
+ withContext(dispatcherProvider.io()) {
+ when (type) {
+ SshKeyType.Rsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
+ SshKeyType.Ecdsa ->
+ SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
+ SshKeyType.Ed25519 -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
+ }
+ SshKey.sshPublicKey.orEmpty()
+ }
+ version++
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // Keystore and provider failures come as many exception types; none may crash the app.
+ failure = e.message.orEmpty()
+ } finally {
+ busy = false
+ }
+ }
+ }
+
+ /** Reports a failure found before generating (the screen lock prompt). */
+ fun fail(message: String) {
+ failure = message
+ }
+
+ fun dismissFailure() {
+ failure = null
+ }
+
+ fun announced() {
+ imported = false
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt
new file mode 100644
index 00000000..b4197604
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/setup/WelcomeScreen.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingLarge
+import app.passwordstore.ui.compose.theme.SpacingLargest
+
+/** First start without a store: clone one, create a local one, or open the settings. */
+@Composable
+fun WelcomeScreen(
+ onClone: () -> Unit,
+ onCreate: () -> Unit,
+ onSettings: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(modifier = modifier) { padding ->
+ Column(
+ Modifier.padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(SpacingLargest),
+ verticalArrangement = Arrangement.spacedBy(SpacingLarge),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painterResource(R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ modifier = Modifier.size(144.dp),
+ )
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium)
+ Text(
+ stringResource(R.string.select_repo_type_text),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ Button(onClick = onClone, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.clone_remote_repo))
+ }
+ OutlinedButton(onClick = onCreate, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.create_local_repo))
+ }
+ TextButton(onClick = onSettings) { Text(stringResource(R.string.action_settings)) }
+ }
+ }
+}
+
+/**
+ * A plain dialog for the setup screens: optional [title], [text], [confirm] and, when [dismiss] is
+ * given, a second button running [onDismissButton] (by default the same as dismissing).
+ */
+@Composable
+internal fun SetupDialog(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ dismiss: String? = null,
+ onDismissButton: () -> Unit = onDismiss,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = modifier,
+ title =
+ if (title != null) {
+ { Text(title) }
+ } else null,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismissButton) { Text(dismiss) } }
+ } else null,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt
deleted file mode 100644
index 3f04e35e..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/ShowSshKeyFragment.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.sshkeygen
-
-import android.app.Dialog
-import android.content.Intent
-import android.os.Bundle
-import androidx.fragment.app.DialogFragment
-import app.passwordstore.R
-import app.passwordstore.util.git.sshj.SshKey
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class ShowSshKeyFragment : DialogFragment() {
-
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val activity = requireActivity()
- val publicKey = SshKey.sshPublicKey
- return MaterialAlertDialogBuilder(requireActivity()).run {
- setMessage(getString(R.string.ssh_keygen_message, publicKey))
- setTitle(R.string.your_public_key)
- setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
- (activity as? SshKeyGenActivity)?.finish()
- }
- setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
- val sendIntent =
- Intent().apply {
- action = Intent.ACTION_SEND
- type = "text/plain"
- putExtra(Intent.EXTRA_TEXT, publicKey)
- }
- startActivity(Intent.createChooser(sendIntent, null))
- (activity as? SshKeyGenActivity)?.finish()
- }
- create()
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt
deleted file mode 100644
index 618a4be0..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyGenActivity.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.sshkeygen
-
-import android.content.SharedPreferences
-import android.os.Bundle
-import android.security.keystore.UserNotAuthenticatedException
-import android.view.MenuItem
-import android.view.View
-import android.view.inputmethod.InputMethodManager
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.edit
-import androidx.core.content.getSystemService
-import androidx.lifecycle.lifecycleScope
-import app.passwordstore.R
-import app.passwordstore.databinding.ActivitySshKeygenBinding
-import app.passwordstore.injection.prefs.GitPreferences
-import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result
-import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.keyguardManager
-import app.passwordstore.util.extensions.viewBinding
-import app.passwordstore.util.git.sshj.SshKey
-import com.github.michaelbull.result.fold
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.android.AndroidEntryPoint
-import javax.inject.Inject
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
- Rsa({ requireAuthentication ->
- SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
- }),
- Ecdsa({ requireAuthentication ->
- SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
- }),
- Ed25519({ requireAuthentication ->
- SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
- }),
-}
-
-@AndroidEntryPoint
-class SshKeyGenActivity : AppCompatActivity() {
-
- private var keyGenType = KeyGenType.Ecdsa
- private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
- @GitPreferences @Inject lateinit var gitPrefs: SharedPreferences
- @Inject lateinit var dispatcherProvider: DispatcherProvider
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- with(binding) {
- generate.setOnClickListener {
- if (SshKey.exists) {
- MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
- setTitle(R.string.ssh_keygen_existing_title)
- setMessage(R.string.ssh_keygen_existing_message)
- setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
- lifecycleScope.launch { generate() }
- }
- setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
- setResult(RESULT_CANCELED)
- finish()
- }
- show()
- }
- } else {
- lifecycleScope.launch { generate() }
- }
- }
- keyTypeGroup.check(R.id.key_type_ecdsa)
- keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
- keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
- if (isChecked) {
- keyGenType =
- when (checkedId) {
- R.id.key_type_ed25519 -> KeyGenType.Ed25519
- R.id.key_type_ecdsa -> KeyGenType.Ecdsa
- R.id.key_type_rsa -> KeyGenType.Rsa
- else -> throw IllegalStateException("Impossible key type selection")
- }
- keyTypeExplanation.setText(
- when (keyGenType) {
- KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
- KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
- KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
- }
- )
- }
- }
- keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
- keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
- }
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
- }
-
- private suspend fun generate() {
- binding.generate.apply {
- text = getString(R.string.ssh_key_gen_generating_progress)
- isEnabled = false
- }
- binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
- val result = runCatching {
- withContext(dispatcherProvider.io()) {
- val requireAuthentication = binding.keyRequireAuthentication.isChecked
- if (requireAuthentication) {
- val result =
- withContext(dispatcherProvider.main()) {
- suspendCoroutine { cont ->
- BiometricAuthenticator.authenticate(
- this@SshKeyGenActivity,
- R.string.biometric_prompt_title_ssh_keygen,
- ) { result ->
- // Do not cancel on failed attempts as these are handled by the
- // authenticator UI.
- if (result !is Result.Retry) cont.resume(result)
- }
- }
- }
- if (result !is Result.Success)
- throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
- }
- keyGenType.generateKey(requireAuthentication)
- }
- }
- gitPrefs.edit { remove("ssh_key_local_passphrase") }
- binding.generate.apply {
- text = getString(R.string.ssh_keygen_generate)
- isEnabled = true
- }
- result.fold(
- success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") },
- failure = { e ->
- e.printStackTrace()
- MaterialAlertDialogBuilder(this)
- .setTitle(getString(R.string.error_generate_ssh_key))
- .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
- .setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
- setResult(RESULT_OK)
- finish()
- }
- .show()
- },
- )
- hideKeyboard()
- }
-
- private fun hideKeyboard() {
- val imm = getSystemService<InputMethodManager>() ?: return
- var view = currentFocus
- if (view == null) {
- view = View(this)
- }
- imm.hideSoftInputFromWindow(view.windowToken, 0)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt
deleted file mode 100644
index e4740741..00000000
--- a/app/src/main/java/app/passwordstore/ui/sshkeygen/SshKeyImportActivity.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.ui.sshkeygen
-
-import android.net.Uri
-import android.os.Bundle
-import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.appcompat.app.AppCompatActivity
-import app.passwordstore.R
-import app.passwordstore.util.git.sshj.SshKey
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-
-class SshKeyImportActivity : AppCompatActivity() {
-
- private val sshKeyImportAction =
- registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
- if (uri == null) {
- finish()
- return@registerForActivityResult
- }
- runCatching {
- SshKey.import(uri)
- Toast.makeText(
- this,
- resources.getString(R.string.ssh_key_success_dialog_title),
- Toast.LENGTH_LONG,
- )
- .show()
- setResult(RESULT_OK)
- finish()
- }
- .onFailure { e ->
- MaterialAlertDialogBuilder(this)
- .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
- .setMessage(e.message)
- .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
- .show()
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- if (SshKey.exists) {
- MaterialAlertDialogBuilder(this).run {
- setTitle(R.string.ssh_keygen_existing_title)
- setMessage(R.string.ssh_keygen_existing_message)
- setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
- setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
- setOnCancelListener { finish() }
- show()
- }
- } else {
- importSshKey()
- }
- }
-
- private fun importSshKey() {
- sshKeyImportAction.launch(arrayOf("*/*"))
- }
-}
diff --git a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt b/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt
deleted file mode 100644
index cd29f626..00000000
--- a/app/src/main/java/app/passwordstore/ui/util/OnOffItemAnimator.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package app.passwordstore.ui.util
-
-import androidx.recyclerview.widget.DefaultItemAnimator
-import androidx.recyclerview.widget.RecyclerView
-
-class OnOffItemAnimator : DefaultItemAnimator() {
-
- var isEnabled: Boolean = true
- set(value) {
- // Defer update until no animation is running anymore.
- isRunning { field = value }
- }
-
- private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
- dispatchAnimationFinished(viewHolder)
- return false
- }
-
- override fun animateAppearance(
- viewHolder: RecyclerView.ViewHolder,
- preLayoutInfo: ItemHolderInfo?,
- postLayoutInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-
- override fun animateChange(
- oldHolder: RecyclerView.ViewHolder,
- newHolder: RecyclerView.ViewHolder,
- preInfo: ItemHolderInfo,
- postInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animateChange(oldHolder, newHolder, preInfo, postInfo)
- } else {
- dontAnimate(oldHolder)
- }
- }
-
- override fun animateDisappearance(
- viewHolder: RecyclerView.ViewHolder,
- preLayoutInfo: ItemHolderInfo,
- postLayoutInfo: ItemHolderInfo?,
- ): Boolean {
- return if (isEnabled) {
- super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-
- override fun animatePersistence(
- viewHolder: RecyclerView.ViewHolder,
- preInfo: ItemHolderInfo,
- postInfo: ItemHolderInfo,
- ): Boolean {
- return if (isEnabled) {
- super.animatePersistence(viewHolder, preInfo, postInfo)
- } else {
- dontAnimate(viewHolder)
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
index 4aad4588..98f2229a 100644
--- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
+++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt
@@ -46,7 +46,7 @@ object BiometricAuthenticator {
/**
* An incorrect biometric was entered, but the prompt UI is offering the option to retry the
- * operation.
+ * operation. The prompt is still open; a terminal result follows.
*/
data object Retry : Result()
@@ -100,7 +100,8 @@ object BiometricAuthenticator {
when (errorCode) {
/** Keep in sync with [androidx.biometric.BiometricPrompt.AuthenticationError] */
BiometricPrompt.ERROR_HW_UNAVAILABLE -> callback(Result.HardwareUnavailableOrDisabled)
- BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> callback(Result.Retry)
+ // An error always closes the prompt, so none of them is a Retry.
+ BiometricPrompt.ERROR_UNABLE_TO_PROCESS,
BiometricPrompt.ERROR_TIMEOUT ->
callback(
Result.Failure(
diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
index ebe4bc7d..59fd5cca 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt
@@ -20,7 +20,7 @@ import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.passwords.PasswordStore
+import app.passwordstore.ui.main.LaunchActivity
import java.io.File
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
@@ -55,7 +55,7 @@ fun makeInlinePresentation(
PendingIntent.getActivity(
context,
0,
- Intent(context, PasswordStore::class.java),
+ Intent(context, LaunchActivity::class.java),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
index a13484c6..6528334d 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -8,7 +8,6 @@ package app.passwordstore.util.extensions
import android.app.KeyguardManager
import android.content.ClipboardManager
import android.content.Context
-import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
@@ -17,12 +16,8 @@ import android.content.pm.PackageManager.ApplicationInfoFlags
import android.content.pm.PackageManager.PackageInfoFlags
import android.os.Build
import android.util.Base64
-import android.util.TypedValue
import android.view.View
import android.view.autofill.AutofillManager
-import androidx.activity.ComponentActivity
-import androidx.compose.ui.Modifier
-import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.FragmentActivity
import app.passwordstore.BuildConfig
@@ -56,13 +51,6 @@ val Context.keyguardManager: KeyguardManager
val Context.sharedPrefs: SharedPreferences
get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0)
-/** Resolve [attr] from the [Context]'s theme */
-fun Context.resolveAttribute(attr: Int): Int {
- val typedValue = TypedValue()
- this.theme.resolveAttribute(attr, typedValue, true)
- return typedValue.data
-}
-
/**
* Commit changes to the store from a [FragmentActivity] using a custom implementation of
* [GitOperation]
@@ -90,32 +78,17 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab
.execute()
}
-/** Check if [permission] has been granted to the app. */
-fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
- return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
-}
-
-/**
- * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
- * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
- * [view]
- */
+/** Show a [Snackbar] in a [FragmentActivity]. */
fun FragmentActivity.snackbar(
view: View = findViewById(android.R.id.content),
message: String,
length: Int = Snackbar.LENGTH_SHORT,
): Snackbar {
val snackbar = Snackbar.make(view, message, length)
- snackbar.anchorView = findViewById(R.id.fab)
snackbar.show()
return snackbar
}
-/** Launch an activity denoted by [clazz]. */
-fun <T : ComponentActivity> ComponentActivity.launchActivity(clazz: Class<T>) {
- startActivity(Intent(this, clazz).setAction(Intent.ACTION_VIEW))
-}
-
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
fun SharedPreferences.getString(key: String): String? = getString(key, null)
@@ -139,12 +112,3 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): Ap
getApplicationInfo(packageName, flags)
}
}
-
-/** Allows conditionally applying the given [modifier] if [isEnabled] is `true`. */
-fun Modifier.conditional(isEnabled: Boolean, modifier: Modifier.() -> Modifier): Modifier {
- return if (isEnabled) {
- then(modifier())
- } else {
- this
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt
deleted file mode 100644
index 2dd3af0c..00000000
--- a/app/src/main/java/app/passwordstore/util/extensions/FragmentExtensions.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.extensions
-
-import androidx.annotation.IdRes
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import app.passwordstore.R
-
-/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
-fun Fragment.isPermissionGranted(permission: String): Boolean {
- return requireActivity().isPermissionGranted(permission)
-}
-
-/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
-fun Fragment.finish() = requireActivity().finish()
-
-/**
- * Perform a [commit] on this [FragmentManager] with custom animations and adding the
- * [destinationFragment] to the fragment backstack
- */
-fun FragmentManager.performTransactionWithBackStack(
- destinationFragment: Fragment,
- @IdRes containerViewId: Int = android.R.id.content,
-) {
- commit {
- addToBackStack(destinationFragment.tag)
- setCustomAnimations(
- R.animator.slide_in_left,
- R.animator.slide_out_left,
- R.animator.slide_in_right,
- R.animator.slide_out_right,
- )
- replace(containerViewId, destinationFragment)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt
deleted file mode 100644
index 0050f2e5..00000000
--- a/app/src/main/java/app/passwordstore/util/extensions/FragmentViewBindingDelegate.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.extensions
-
-import android.view.LayoutInflater
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.viewbinding.ViewBinding
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-/**
- * Imported from
- * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
- */
-class FragmentViewBindingDelegate<T : ViewBinding>(
- val fragment: Fragment,
- val viewBindingFactory: (View) -> T,
-) : ReadOnlyProperty<Fragment, T> {
-
- private var binding: T? = null
-
- init {
- fragment.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onCreate(owner: LifecycleOwner) {
- fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
- viewLifecycleOwner.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onDestroy(owner: LifecycleOwner) {
- binding = null
- }
- }
- )
- }
- }
- }
- )
- }
-
- override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
- val binding = binding
- if (binding != null) {
- return binding
- }
-
- val lifecycle = fragment.viewLifecycleOwner.lifecycle
- if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
- throw IllegalStateException(
- "Should not attempt to get bindings when Fragment views are destroyed."
- )
- }
-
- return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
- }
-}
-
-fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
- FragmentViewBindingDelegate(this, viewBindingFactory)
-
-inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
- crossinline bindingInflater: (LayoutInflater) -> T
-) = unsafeLazy { bindingInflater.invoke(layoutInflater) }
diff --git a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt b/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt
deleted file mode 100644
index 329db6d5..00000000
--- a/app/src/main/java/app/passwordstore/util/git/GitLogModel.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.git
-
-import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.util.extensions.asLog
-import app.passwordstore.util.extensions.hash
-import app.passwordstore.util.extensions.time
-import app.passwordstore.util.extensions.unsafeLazy
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.revwalk.RevCommit
-
-private val TAG = GitLogModel::class.java.simpleName
-
-private fun commits(): Iterable<RevCommit> {
- val repo = PasswordRepository.repository
- if (repo == null) {
- logcat(TAG, ERROR) { "Could not access git repository" }
- return listOf()
- }
- return runCatching { Git(repo).log().call() }
- .getOrElse { e ->
- logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") }
- listOf()
- }
-}
-
-/**
- * Provides [GitCommit] s from a git-log of the password git repository.
- *
- * All commits are acquired on the first request to this object.
- */
-class GitLogModel {
-
- // All commits are acquired here at once. Acquiring the commits in batches would not have been
- // entirely sensible because the amount of computation required to obtain commit number n from
- // the log includes the amount of computation required to obtain commit number n-1 from the log.
- // This is because the commit graph is walked from HEAD to the last commit to obtain.
- // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
- // user experience.
- private val cache: MutableList<GitCommit> by unsafeLazy {
- commits()
- .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
- .toMutableList()
- }
- val size = cache.size
-
- fun get(index: Int): GitCommit? {
- if (index >= size)
- logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." }
- return cache.getOrNull(index)
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
index d55d9970..4b4913d0 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt
@@ -57,9 +57,8 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call
.setMessage(
callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)
)
- .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
- callingActivity.finish()
- }
+ // Only closes the dialog: the calling activity is the app's main screen.
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null)
.show()
false
} else {
diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
index a3edac7d..25d99d6d 100644
--- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
+++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt
@@ -9,16 +9,13 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.FragmentActivity
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
-import app.passwordstore.ui.sshkeygen.SshKeyGenActivity
-import app.passwordstore.ui.sshkeygen.SshKeyImportActivity
+import app.passwordstore.ui.main.MainActivity
+import app.passwordstore.ui.setup.SetupStart
import app.passwordstore.util.auth.BiometricAuthenticator
-import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledBySystem
import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser
-import app.passwordstore.util.auth.BiometricAuthenticator.Result.Failure
import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry
import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success
import app.passwordstore.util.coroutines.DispatcherProvider
-import app.passwordstore.util.extensions.launchActivity
import app.passwordstore.util.git.GitCommandExecutor
import app.passwordstore.util.git.sshj.SshAuthMethod
import app.passwordstore.util.git.sshj.SshKey
@@ -27,8 +24,6 @@ import app.passwordstore.util.settings.AuthMode
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
@@ -37,9 +32,6 @@ import dagger.hilt.components.SingletonComponent
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
import net.schmizz.sshj.common.DisconnectReason
import net.schmizz.sshj.common.SSHException
import net.schmizz.sshj.userauth.password.PasswordFinder
@@ -106,12 +98,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
}
private fun getSshKey(generateKey: Boolean) {
- runCatching {
- callingActivity.launchActivity(
- if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java
- )
- }
- .onFailure { e -> logcat(ERROR) { e.asLog() } }
+ callingActivity.startActivity(
+ MainActivity.setupIntent(
+ callingActivity,
+ if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh,
+ )
+ )
}
private fun registerAuthProviders(
@@ -152,10 +144,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
) { _, _ ->
getSshKey(true)
}
- .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
- // Finish the blank GitActivity so user doesn't have to press back
- callingActivity.finish()
- }
+ // Cancel only closes the dialog: the calling activity is the app's own screen now.
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null)
.show()
}
@@ -171,7 +161,9 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
callingActivity,
R.string.biometric_prompt_title_ssh_auth,
) { result ->
- if (result !is Failure && result !is Retry) cont.resume(result)
+ // Retry: a failed attempt while the prompt stays open. Every other result
+ // closes it, so waiting for more would leave the operation hanging.
+ if (result !is Retry) cont.resume(result)
}
}
}
@@ -182,10 +174,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
is CanceledByUser -> {
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
- is Failure,
- is CanceledBySystem -> {
- throw IllegalStateException("Biometric authentication failures should be ignored")
- }
else -> {
// There is a chance we succeed if the user recently confirmed
// their screen lock. Doing so would have a potential to confuse
@@ -197,7 +185,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
Toast.LENGTH_LONG,
)
.show()
- callingActivity.finish()
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
}
} else {
diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
index 109e36c9..7c8f5ba5 100644
--- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
+++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt
@@ -16,6 +16,7 @@ import android.util.Base64
import androidx.core.content.edit
import app.passwordstore.Application
import app.passwordstore.R
+import app.passwordstore.ui.setup.readAtMost
import app.passwordstore.util.crypto.keystoreAesKey
import app.passwordstore.util.crypto.requireUserAuthentication
import app.passwordstore.util.crypto.seal
@@ -29,6 +30,8 @@ import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
import java.security.KeyFactory
import java.security.KeyPair
import java.security.KeyPairGenerator
@@ -46,7 +49,9 @@ import net.schmizz.sshj.SSHClient
import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Ed25519KeyFactory
import net.schmizz.sshj.common.KeyType
+import net.schmizz.sshj.userauth.keyprovider.KeyFormat
import net.schmizz.sshj.userauth.keyprovider.KeyProvider
+import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
@@ -82,6 +87,25 @@ internal fun ed25519KeyPair(seed: ByteArray) =
Ed25519KeyFactory.getPrivateKey(seed),
)
+/**
+ * True when [lines] look like an SSH private key sshj can load: private key markers on the first
+ * and last line, in a format sshj recognizes. An OpenPGP private key block has the same markers and
+ * is refused by the format check.
+ */
+internal fun isSshPrivateKey(lines: List<String>): Boolean {
+ if (
+ lines.size < 2 ||
+ !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
+ !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
+ )
+ return false
+ return try {
+ KeyProviderUtil.detectKeyFileFormat(lines.joinToString("\n"), false) != KeyFormat.Unknown
+ } catch (_: IOException) {
+ false
+ }
+}
+
object SshKey {
val sshPublicKey
@@ -182,15 +206,20 @@ object SshKey {
}
private fun delete() {
- androidKeystore.deleteEntry(KEYSTORE_ALIAS)
if (privateKeyFile.isFile) {
privateKeyFile.delete()
}
+ deleteKeyExtras()
+ type = null
+ }
+
+ /** Everything of the current key but its private key file. */
+ private fun deleteKeyExtras() {
+ androidKeystore.deleteEntry(KEYSTORE_ALIAS)
if (publicKeyFile.isFile) {
publicKeyFile.delete()
}
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
- type = null
}
fun import(uri: Uri) {
@@ -198,9 +227,8 @@ object SshKey {
val fileSize =
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use {
cursor ->
- // Cursor returns only a single row.
- cursor.moveToFirst()
- cursor.getInt(0)
+ // A single row; none when the provider cannot tell: the IOException below.
+ if (cursor.moveToFirst()) cursor.getInt(0) else null
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
@@ -212,26 +240,43 @@ object SshKey {
val sshKeyInputStream =
context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
- val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() }
-
- // The file must have more than 2 lines, and the first and last line must have private key
- // markers.
- if (
- lines.size < 2 ||
- !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
- !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
- )
+ // Bounded whatever the provider claimed above: a larger stream is refused, not read whole.
+ val bytes =
+ sshKeyInputStream.use { readAtMost(it, 100_000) }
+ ?: throw IllegalArgumentException(
+ context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
+ )
+ val lines =
+ try {
+ bytes.inputStream().bufferedReader().readLines()
+ } finally {
+ bytes.fill(0)
+ }
+
+ // Private key markers and a format sshj can read; the current key stays when this fails.
+ if (!isSshPrivateKey(lines))
throw IllegalArgumentException(
context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
)
// At this point, we are reasonably confident that we have actually been provided a private
- // key and delete the old key.
- delete()
- // Canonicalize line endings to '\n'.
- privateKeyFile.writeText(lines.joinToString("\n"))
-
+ // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's
+ // Keystore entry, public key and passphrase go.
+ val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp")
+ try {
+ // Canonicalize line endings to '\n'.
+ temp.writeText(lines.joinToString("\n"))
+ Files.move(
+ temp.toPath(),
+ privateKeyFile.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ } finally {
+ temp.delete()
+ }
type = Type.Imported
+ deleteKeyExtras()
}
/** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */
diff --git a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
index 1b521141..6382e1ed 100644
--- a/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
+++ b/app/src/main/java/app/passwordstore/util/proxy/ProxyUtils.kt
@@ -42,13 +42,10 @@ class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) {
)
val user = gitSettings.proxyUsername ?: ""
val password = gitSettings.proxyPassword ?: ""
- if (user.isEmpty() || password.isEmpty()) {
- System.clearProperty(HTTP_PROXY_USER_PROPERTY)
- System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
- } else {
- System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
- System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
- }
+ // The Authenticator below supplies the credentials; never copy the password into a
+ // process-wide system property.
+ System.clearProperty(HTTP_PROXY_USER_PROPERTY)
+ System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
Authenticator.setDefault(
object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
index d315daf3..004028c4 100644
--- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
@@ -108,7 +108,7 @@ constructor(
}
var proxyUsername
- get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
+ get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME)
set(value) {
proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
}
diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
index 06075655..82847f02 100644
--- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt
@@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti
migrateToDiceware(sharedPrefs)
removeExternalStorageProperties(sharedPrefs)
removeCurrentBranchValue(sharedPrefs)
+ migrateRemovedSortOrders(sharedPrefs)
+ removeOpenKeystoreKeyId(sharedPrefs)
}
/**
@@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) {
}
}
}
+
+/**
+ * "Files first" and "type independent" were removed with the Compose list, which shows folders and
+ * entries in separate groups: a stored one becomes folders first, the list's default.
+ */
+private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) {
+ val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return
+ if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return
+ logcat(TAG, INFO) { "Replacing the removed sort order $stored" }
+ sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) }
+}
+
+/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */
+private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) {
+ if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return
+ sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
+}
diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
index 224d9d66..921e9bc4 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt
@@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
(p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
}
),
- INDEPENDENT(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- p1.name.compareTo(p2.name, ignoreCase = true)
- }
- ),
RECENTLY_USED(
Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory =
@@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
else -> p1.name.compareTo(p2.name, ignoreCase = true)
}
}
- ),
- FILE_FIRST(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
- }
);
companion object {
+ /**
+ * The stored sort order; anything else (the removed "files first" and "type independent", a
+ * value from another version) reads as [FOLDER_FIRST].
+ */
@JvmStatic
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
- return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
+ val stored = settings.getString(PreferenceKeys.SORT_ORDER)
+ return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST
}
}
}
diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt
deleted file mode 100644
index 5c7429cd..00000000
--- a/app/src/main/java/app/passwordstore/util/viewmodel/PGPKeyListViewModel.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package app.passwordstore.util.viewmodel
-
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import app.passwordstore.crypto.KeyUtils
-import app.passwordstore.crypto.PGPIdentifier
-import app.passwordstore.crypto.PGPKeyManager
-import com.github.michaelbull.result.map
-import com.github.michaelbull.result.onSuccess
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.collections.immutable.toPersistentList
-import kotlinx.coroutines.launch
-
-@HiltViewModel
-class PGPKeyListViewModel @Inject constructor(private val keyManager: PGPKeyManager) : ViewModel() {
- var keys: ImmutableList<PGPIdentifier> by mutableStateOf(persistentListOf())
-
- init {
- updateKeySet()
- }
-
- fun updateKeySet() {
- viewModelScope.launch {
- keyManager
- .getAllKeys()
- .map { keys -> keys.mapNotNull { key -> KeyUtils.tryGetEmail(key) } }
- .onSuccess { keys = it.toPersistentList() }
- }
- }
-
- fun deleteKey(identifier: PGPIdentifier) {
- viewModelScope.launch {
- keyManager.removeKey(identifier)
- updateKeySet()
- }
- }
-}
diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
index 48804e7a..5f35644d 100644
--- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -6,24 +6,12 @@ package app.passwordstore.util.viewmodel
import android.app.Application
import android.content.SharedPreferences
-import android.os.Parcelable
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.AndroidViewModel
-import androidx.recyclerview.selection.ItemDetailsLookup
-import androidx.recyclerview.selection.ItemKeyProvider
-import androidx.recyclerview.selection.Selection
-import androidx.recyclerview.selection.SelectionPredicates
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.selection.StorageStrategy
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.ui.list.listRows
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.checkMainThread
import app.passwordstore.util.coroutines.DispatcherProvider
@@ -34,9 +22,7 @@ import com.github.androidpasswordstore.sublimefuzzy.Fuzzy
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.text.Collator
-import java.util.Locale
import javax.inject.Inject
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -50,9 +36,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
-import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem() =
if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
@@ -70,12 +54,7 @@ private fun PasswordItem.Companion.makeComparator(
directoryStructure: DirectoryStructure,
): Comparator<PasswordItem> {
return when (typeSortOrder) {
- PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
- // In order to let INDEPENDENT not distinguish between items based on their type, we
- // simply
- // declare them all equal at this stage.
- PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
- PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
+ PasswordSortOrder.FOLDER_FIRST -> compareBy<PasswordItem> { it.type }
PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
}
.then(
@@ -88,9 +67,6 @@ private fun PasswordItem.Companion.makeComparator(
)
}
-val PasswordItem.stableId: String
- get() = file.absolutePath
-
enum class FilterMode {
NoFilter,
StrictDomain,
@@ -105,7 +81,6 @@ enum class SearchMode {
enum class ListMode {
FilesOnly,
- DirectoriesOnly,
AllEntries,
}
@@ -191,7 +166,7 @@ constructor(
)
)
- data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+ data class SearchResult(val passwordItems: List<PasswordItem>)
val searchResult =
searchActionFlow
@@ -207,8 +182,6 @@ constructor(
when (searchAction.listMode) {
ListMode.FilesOnly ->
listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io())
- ListMode.DirectoriesOnly ->
- listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io())
ListMode.AllEntries -> listResultFlow
}
val passwordList =
@@ -264,7 +237,7 @@ constructor(
.map { it.second }
}
}
- SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
+ SearchResult(passwordList)
}
.flowOn(dispatcherProvider.io())
@@ -302,55 +275,28 @@ constructor(
private val _currentDir = MutableStateFlow(root)
val currentDir = _currentDir.asStateFlow()
- data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
-
- private val navigationStack = ArrayDeque<NavigationStackEntry>()
+ /** [searchResult] as folder and entry rows for the Compose list, built off the main thread. */
+ val rows =
+ searchResult
+ .map { result -> listRows(result.passwordItems.map { it.file }, currentDir.value) }
+ .flowOn(dispatcherProvider.io())
- fun navigateTo(
- newDirectory: File = root,
- listMode: ListMode = ListMode.AllEntries,
- recyclerViewState: Parcelable? = null,
- pushPreviousLocation: Boolean = true,
- ) {
+ /** Lists [newDirectory]; the Compose list keeps the back stack (one route per folder). */
+ fun navigateTo(newDirectory: File) {
if (!newDirectory.exists()) return
require(newDirectory.isDirectory) { "Can only navigate to a directory" }
- if (pushPreviousLocation) {
- navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState))
- }
searchActionFlow.update {
makeSearchAction(
filter = "",
baseDirectory = newDirectory,
filterMode = FilterMode.NoFilter,
searchMode = SearchMode.InCurrentDirectoryOnly,
- listMode = listMode,
+ listMode = ListMode.AllEntries,
)
}
_currentDir.update { newDirectory }
}
- val canNavigateBack
- get() = navigationStack.isNotEmpty()
-
- /**
- * Navigate back to the last location on the [navigationStack] and restore a cached scroll
- * position if possible.
- *
- * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
- */
- fun navigateBack(): Parcelable? {
- if (!canNavigateBack) return null
- val (oldDir, oldRecyclerViewState) = navigationStack.removeFirst()
- navigateTo(oldDir, pushPreviousLocation = false)
- return oldRecyclerViewState
- }
-
- fun reset() {
- navigationStack.clear()
- forceUpdateOnNextSearchAction()
- navigateTo(pushPreviousLocation = false)
- }
-
fun search(
filter: String,
baseDirectory: File? = null,
@@ -397,114 +343,3 @@ constructor(
}
}
}
-
-private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
-
- override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
- oldItem.file.absolutePath == newItem.file.absolutePath
-
- override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
-}
-
-open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
- private val layoutRes: Int,
- private val viewHolderCreator: (view: View) -> T,
- private val coroutineScope: CoroutineScope,
- private val dispatcherProvider: DispatcherProvider,
- private val viewHolderBinder:
- suspend T.(item: PasswordItem, dispatcherProvider: DispatcherProvider) -> Unit,
-) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
-
- fun <T : ItemDetailsLookup<String>> makeSelectable(
- recyclerView: RecyclerView,
- itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T,
- ) {
- selectionTracker =
- SelectionTracker.Builder(
- "SearchableRepositoryAdapter",
- recyclerView,
- itemKeyProvider,
- itemDetailsLookupCreator(recyclerView),
- StorageStrategy.createStringStorage(),
- )
- .withSelectionPredicate(SelectionPredicates.createSelectAnything())
- .build()
- .apply {
- addObserver(
- object : SelectionTracker.SelectionObserver<String>() {
- override fun onSelectionChanged() {
- this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
- requireSelectionTracker().selection
- )
- }
- }
- )
- }
- }
-
- private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
-
- open fun onItemClicked(
- listener: (holder: T, item: PasswordItem) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onItemClickedListener == null) {
- "Only a single listener can be registered for onItemClicked"
- }
- onItemClickedListener = listener
- return this
- }
-
- private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
-
- open fun onSelectionChanged(
- listener: (selection: Selection<String>) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onSelectionChangedListener == null) {
- "Only a single listener can be registered for onSelectionChanged"
- }
- onSelectionChangedListener = listener
- return this
- }
-
- private val itemKeyProvider =
- object : ItemKeyProvider<String>(SCOPE_MAPPED) {
- override fun getKey(position: Int) = getItem(position).stableId
-
- override fun getPosition(key: String) =
- (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION
- }
-
- private var selectionTracker: SelectionTracker<String>? = null
-
- fun requireSelectionTracker() = selectionTracker!!
-
- private val selectedFiles
- get() = requireSelectionTracker().selection.map { File(it) }
-
- fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
-
- fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
-
- final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
- val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
- return viewHolderCreator(view)
- }
-
- final override fun onBindViewHolder(holder: T, position: Int) {
- val item = getItem(position)
- holder.apply {
- coroutineScope.launch(dispatcherProvider.mainImmediate()) {
- viewHolderBinder.invoke(holder, item, dispatcherProvider)
- }
- selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
- itemView.setOnClickListener {
- // Do not emit custom click events while the user is selecting items.
- if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item)
- }
- }
- }
-
- final override fun getPopupText(view: View, position: Int): String {
- return getItem(position).name[0].toString().uppercase(Locale.getDefault())
- }
-}
diff --git a/app/src/main/res/anim/scale_down.xml b/app/src/main/res/anim/scale_down.xml
deleted file mode 100644
index cbb4cb97..00000000
--- a/app/src/main/res/anim/scale_down.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
- <scale
- android:duration="300"
- android:fromXScale="1.0"
- android:fromYScale="1.0"
- android:interpolator="@android:anim/linear_interpolator"
- android:pivotX="50%"
- android:pivotY="50%"
- android:toXScale="0"
- android:toYScale="0" />
-</set>
diff --git a/app/src/main/res/anim/scale_up.xml b/app/src/main/res/anim/scale_up.xml
deleted file mode 100644
index 1e149e38..00000000
--- a/app/src/main/res/anim/scale_up.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
- <scale
- android:duration="300"
- android:fromXScale="0"
- android:fromYScale="0"
- android:interpolator="@android:anim/linear_interpolator"
- android:pivotX="50%"
- android:pivotY="50%"
- android:toXScale="1.0"
- android:toYScale="1.0" />
-</set>
diff --git a/app/src/main/res/animator/slide_in_left.xml b/app/src/main/res/animator/slide_in_left.xml
deleted file mode 100644
index 355aeefb..00000000
--- a/app/src/main/res/animator/slide_in_left.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
-
- <objectAnimator
- android:duration="250"
- android:propertyName="x"
- android:valueFrom="1000"
- android:valueTo="0"
- android:valueType="floatType" />
-
-</set>
diff --git a/app/src/main/res/animator/slide_in_right.xml b/app/src/main/res/animator/slide_in_right.xml
deleted file mode 100644
index 685bda4d..00000000
--- a/app/src/main/res/animator/slide_in_right.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
-
- <objectAnimator
- android:duration="250"
- android:propertyName="x"
- android:valueFrom="-1000"
- android:valueTo="0"
- android:valueType="floatType" />
-
-</set>
diff --git a/app/src/main/res/animator/slide_out_left.xml b/app/src/main/res/animator/slide_out_left.xml
deleted file mode 100644
index 06c3f076..00000000
--- a/app/src/main/res/animator/slide_out_left.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
-
- <objectAnimator
- android:duration="250"
- android:propertyName="x"
- android:valueFrom="0"
- android:valueTo="-1000"
- android:valueType="floatType" />
-
-</set>
diff --git a/app/src/main/res/animator/slide_out_right.xml b/app/src/main/res/animator/slide_out_right.xml
deleted file mode 100644
index e5c61bdc..00000000
--- a/app/src/main/res/animator/slide_out_right.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<set xmlns:android="http://schemas.android.com/apk/res/android">
-
- <objectAnimator
- android:duration="250"
- android:propertyName="x"
- android:valueFrom="0"
- android:valueTo="1000"
- android:valueType="floatType" />
-
-</set>
diff --git a/app/src/main/res/drawable/divider.xml b/app/src/main/res/drawable/divider.xml
deleted file mode 100644
index 39ec0614..00000000
--- a/app/src/main/res/drawable/divider.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<shape xmlns:android="http://schemas.android.com/apk/res/android"
- android:shape="rectangle">
- <size android:height="1dp" />
- <solid android:color="?attr/colorPrimaryDark" />
-</shape>
diff --git a/app/src/main/res/drawable/ic_add_48dp.xml b/app/src/main/res/drawable/ic_add_48dp.xml
deleted file mode 100644
index 7192e79d..00000000
--- a/app/src/main/res/drawable/ic_add_48dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFF"
- android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml
deleted file mode 100644
index 234364a9..00000000
--- a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFFFF"
- android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_content_copy.xml b/app/src/main/res/drawable/ic_content_copy.xml
deleted file mode 100644
index cdee1758..00000000
--- a/app/src/main/res/drawable/ic_content_copy.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFF"
- android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM15,5L8,5c-1.1,0 -1.99,0.9 -1.99,2L6,21c0,1.1 0.89,2 1.99,2L19,23c1.1,0 2,-0.9 2,-2L21,11l-6,-6zM8,21L8,7h6v5h5v9L8,21z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_done_24dp.xml b/app/src/main/res/drawable/ic_done_24dp.xml
deleted file mode 100644
index f8b6cef6..00000000
--- a/app/src/main/res/drawable/ic_done_24dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFF"
- android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml
deleted file mode 100644
index 6861aa87..00000000
--- a/app/src/main/res/drawable/ic_keyboard_arrow_right_24dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFFFF"
- android:pathData="M8.59,16.59L13.17,12 8.59,7.41 10,6l6,6 -6,6 -1.41,-1.41z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_save_24dp.xml b/app/src/main/res/drawable/ic_save_24dp.xml
deleted file mode 100644
index 8a4276c4..00000000
--- a/app/src/main/res/drawable/ic_save_24dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFF"
- android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" />
-</vector>
diff --git a/app/src/main/res/drawable/ic_save_copy_24dp.xml b/app/src/main/res/drawable/ic_save_copy_24dp.xml
deleted file mode 100644
index 06419aac..00000000
--- a/app/src/main/res/drawable/ic_save_copy_24dp.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:tint="?attr/colorControlNormal"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:fillColor="#FFFFFF"
- android:pathData="M19,5L7,5A2,2 0,0 0,5 7L5,21a2,2 0,0 0,2 2L21,23a2,2 0,0 0,2 -2L23,9ZM21,21L7,21L7,7L18.17,7L21,9.83ZM14,14a3,3 0,1 0,3 3A3,3 0,0 0,14 14ZM8,8h9v4L8,12ZM15,1L3,1A2,2 0,0 0,1 3L1,17L3,17L3,3L15,3Z" />
-</vector>
diff --git a/app/src/main/res/drawable/password_row_background.xml b/app/src/main/res/drawable/password_row_background.xml
deleted file mode 100644
index 0f681cd8..00000000
--- a/app/src/main/res/drawable/password_row_background.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<!--
-Requires a layer-list since attributes cannot be resolved in selectors, see:
-https://stackoverflow.com/a/36424426/297261
- -->
-<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
- <item>
- <selector>
- <item
- android:drawable="@color/list_multiselect_background"
- android:state_selected="true" />
- <item android:drawable="@android:color/transparent" />
- </selector>
- </item>
- <item android:drawable="?android:attr/selectableItemBackground" />
-</layer-list>
diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml
deleted file mode 100644
index b2f699b1..00000000
--- a/app/src/main/res/layout/activity_git_clone.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/windowBackground"
- android:padding="@dimen/activity_horizontal_margin"
- tools:context="app.passwordstore.ui.git.config.GitServerConfigActivity">
-
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/label_server_url"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:hint="@string/server_url"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/server_url"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:imeOptions="actionNext"
- android:inputType="textWebEmailAddress"
- android:nextFocusForward="@id/auth_mode_group" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/label_auth_mode"
- style="@style/TextAppearance.MaterialComponents.Headline6"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
- android:layout_marginBottom="16dp"
- android:text="@string/connection_mode"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/label_server_url" />
-
- <com.google.android.material.button.MaterialButtonToggleGroup
- android:id="@+id/auth_mode_group"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/label_auth_mode"
- app:singleSelection="true">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/auth_mode_ssh_key"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/connection_mode_ssh_key" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/auth_mode_password"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/connection_mode_basic_authentication" />
-
- </com.google.android.material.button.MaterialButtonToggleGroup>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/save_button"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/crypto_save"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/auth_mode_group" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/clear_host_key_button"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/clear_saved_host_key"
- android:visibility="gone"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/auth_mode_group"
- tools:visibility="visible" />
- </androidx.constraintlayout.widget.ConstraintLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/activity_git_config.xml b/app/src/main/res/layout/activity_git_config.xml
deleted file mode 100644
index b43ec6d6..00000000
--- a/app/src/main/res/layout/activity_git_config.xml
+++ /dev/null
@@ -1,113 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:padding="@dimen/activity_horizontal_margin"
- tools:context="app.passwordstore.ui.git.config.GitConfigActivity"
- tools:layout_editor_absoluteX="0dp"
- tools:layout_editor_absoluteY="81dp">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/username_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:hint="@string/git_user_name_hint"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:layout_editor_absoluteY="64dp">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/git_user_name"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textPersonName" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/email_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:hint="@string/git_user_email"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/username_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/git_user_email"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textEmailAddress" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/save_button"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/crypto_save"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/email_input_layout" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/git_tools_title"
- style="@style/TextAppearance.MaterialComponents.Headline5"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/git_tools"
- android:textSize="24sp"
- android:textStyle="bold"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/save_button" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/git_head_status"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/git_tools_title"
- tools:text="HEAD status" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/git_log"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/git_log"
- app:layout_constraintTop_toBottomOf="@+id/git_head_status" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/git_abort_rebase"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/abort_rebase"
- app:layout_constraintTop_toBottomOf="@+id/git_log" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/git_reset_to_remote"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:text="@string/reset_to_remote"
- app:layout_constraintTop_toBottomOf="@id/git_abort_rebase" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/git_gc"
- android:layout_marginTop="8dp"
- android:text="@string/git_run_gc_job"
- app:layout_constraintTop_toBottomOf="@id/git_reset_to_remote"
- android:layout_width="match_parent"
- android:layout_height="wrap_content" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/activity_git_log.xml b/app/src/main/res/layout/activity_git_log.xml
deleted file mode 100644
index e9884db0..00000000
--- a/app/src/main/res/layout/activity_git_log.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/git_log_recycler_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:scrollbars="vertical"
- app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
- tools:context="app.passwordstore.ui.git.log.GitLogActivity"
- tools:listitem="@layout/git_log_row_layout" />
-
-</LinearLayout>
diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml
deleted file mode 100644
index d1153f5d..00000000
--- a/app/src/main/res/layout/activity_onboarding.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.fragment.app.FragmentContainerView xmlns:android="http://schemas.android.com/apk/res/android"
- android:id="@+id/fragment_first_run"
- android:name="app.passwordstore.ui.onboarding.fragments.WelcomeFragment"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:tag="welcome_fragment" />
diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml
deleted file mode 100644
index 0641f5c6..00000000
--- a/app/src/main/res/layout/activity_oreo_autofill_filter.xml
+++ /dev/null
@@ -1,132 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:elevation="2dp"
- tools:context="app.passwordstore.ui.autofill.AutofillFilterView">
-
- <ImageView
- android:id="@+id/cover"
- android:layout_width="0dp"
- android:layout_height="60dp"
- android:background="@android:color/transparent"
- android:contentDescription="@string/app_name"
- android:src="@drawable/ic_launcher_foreground"
- app:layout_constraintBottom_toTopOf="@id/origin"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintVertical_bias="0.0" />
-
- <TextView
- android:id="@+id/origin"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:singleLine="false"
- android:textAlignment="center"
- app:layout_constraintBottom_toTopOf="@id/searchLayout"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/cover"
- app:layout_constraintVertical_bias="0.0" />
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/searchLayout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- app:endIconMode="clear_text"
- app:layout_constraintBottom_toTopOf="@id/rvPasswordSwitcher"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/origin">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/search"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/action_search"
- android:imeOptions="actionDone"
- android:inputType="text"
- tools:text="example.com" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <ViewSwitcher
- android:id="@+id/rvPasswordSwitcher"
- android:layout_width="0dp"
- android:layout_height="0dp"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- app:layout_constraintBottom_toTopOf="@id/strictDomainSearch"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/searchLayout">
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/rvPassword"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:scrollbars="vertical"
- tools:itemCount="5"
- tools:listitem="@layout/oreo_autofill_filter_row" />
-
- <TextView
- android:id="@+id/rvPasswordEmpty"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:text="@string/oreo_autofill_filter_no_results"
- android:textSize="18sp" />
-
- </ViewSwitcher>
-
- <com.google.android.material.switchmaterial.SwitchMaterial
- android:id="@+id/strictDomainSearch"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/oreo_autofill_strict_domain_search"
- app:layout_constraintBottom_toTopOf="@id/shouldMatch"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/rvPasswordSwitcher"
- app:layout_constraintVertical_bias="1.0"
- tools:text="Phishing-resistant search" />
-
- <com.google.android.material.switchmaterial.SwitchMaterial
- android:id="@+id/shouldMatch"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:checked="true"
- app:layout_constraintBottom_toTopOf="@id/shouldClear"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/strictDomainSearch"
- app:layout_constraintVertical_bias="1.0"
- tools:text="Match with example.org" />
-
- <com.google.android.material.switchmaterial.SwitchMaterial
- android:id="@+id/shouldClear"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/oreo_autofill_matches_clear_existing"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/shouldMatch"
- app:layout_constraintVertical_bias="1.0" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml b/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml
deleted file mode 100644
index 89d64fe4..00000000
--- a/app/src/main/res/layout/activity_oreo_autofill_publisher_changed.xml
+++ /dev/null
@@ -1,174 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:animateLayoutChanges="true"
- android:elevation="2dp"
- android:paddingBottom="16dp"
- android:scrollbars="vertical"
- tools:context="app.passwordstore.ui.autofill.AutofillPublisherChangedActivity">
-
- <ImageView
- android:id="@+id/cover"
- android:layout_width="60dp"
- android:layout_height="60dp"
- android:background="@android:color/transparent"
- android:contentDescription="@string/app_name"
- android:src="@drawable/ic_launcher_foreground"
- app:layout_constraintBottom_toTopOf="@id/warningSign"
- app:layout_constraintEnd_toStartOf="@id/app_name"
- app:layout_constraintHorizontal_chainStyle="packed"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <TextView
- android:id="@+id/app_name"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/app_name"
- android:textSize="18sp"
- android:textStyle="bold"
- app:layout_constraintBottom_toBottomOf="@id/cover"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toEndOf="@id/cover"
- app:layout_constraintTop_toTopOf="@id/cover" />
-
- <ImageView
- android:id="@+id/warningSign"
- android:layout_width="40dp"
- android:layout_height="40dp"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:contentDescription="@string/oreo_autofill_warning_publisher_warning_sign_description"
- android:src="@drawable/ic_warning_red_24dp"
- app:layout_constraintBottom_toBottomOf="@id/warningHeader"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="@id/warningHeader" />
-
- <TextView
- android:id="@+id/warningHeader"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/oreo_autofill_warning_publisher_header"
- android:textSize="12sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toEndOf="@id/warningSign"
- app:layout_constraintTop_toBottomOf="@id/cover" />
-
- <TextView
- android:id="@+id/warningAppName"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:gravity="center_horizontal"
- android:textSize="12sp"
- app:layout_constraintBottom_toTopOf="@id/warningAppInstallDate"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/warningHeader"
- tools:text="Example Banking" />
-
- <TextView
- android:id="@+id/warningAppInstallDate"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:gravity="center_horizontal"
- android:textSize="12sp"
- app:layout_constraintBottom_toTopOf="@id/warningAppFooter"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/warningAppName"
- tools:text="Installed: 5 days ago" />
-
- <TextView
- android:id="@+id/warningAppFooter"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/oreo_autofill_warning_publisher_footer"
- android:textSize="12sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/warningAppInstallDate" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/okButton"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:minWidth="240dp"
- android:text="@string/oreo_autofill_warning_publisher_changed_disable_autofill_button"
- android:textSize="12sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/warningAppFooter" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/advancedButton"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:minWidth="240dp"
- android:text="@string/oreo_autofill_warning_publisher_advanced_info_button"
- android:textSize="12sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/okButton"
- tools:visibility="visible" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/resetButton"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:minWidth="240dp"
- android:text="@string/oreo_autofill_warning_publisher_reenable_button"
- android:textSize="12sp"
- android:visibility="gone"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/advancedButton"
- tools:visibility="visible" />
-
- <TextView
- android:id="@+id/warningAppAdvancedInfo"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="16dp"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:fontFamily="@font/jetbrainsmono_nl_regular"
- android:gravity="center_horizontal"
- android:textIsSelectable="true"
- android:textSize="10sp"
- android:visibility="gone"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/resetButton"
- tools:ignore="SmallSp"
- tools:text="Package: com.example.banking\n\nHash:\n8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M="
- tools:visibility="visible" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/activity_preference_recyclerview.xml b/app/src/main/res/layout/activity_preference_recyclerview.xml
deleted file mode 100644
index 805bc79a..00000000
--- a/app/src/main/res/layout/activity_preference_recyclerview.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/preference_recycler_view"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
-
-</LinearLayout>
diff --git a/app/src/main/res/layout/activity_proxy_selector.xml b/app/src/main/res/layout/activity_proxy_selector.xml
deleted file mode 100644
index b83ca9f7..00000000
--- a/app/src/main/res/layout/activity_proxy_selector.xml
+++ /dev/null
@@ -1,106 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/proxy_host_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:hint="@string/proxy_hostname"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:layout_editor_absoluteY="64dp">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/proxy_host"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textUri"
- android:nextFocusForward="@id/proxy_user" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/proxy_user_input_layout"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/normal_margin"
- android:layout_marginEnd="@dimen/normal_margin"
- android:hint="@string/username"
- app:layout_constraintEnd_toStartOf="@id/proxy_port_input_layout"
- app:layout_constraintHorizontal_weight="0.65"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/proxy_user"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textWebEmailAddress"
- android:nextFocusForward="@id/proxy_port" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/proxy_port_input_layout"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/normal_margin"
- android:layout_marginTop="@dimen/normal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:hint="@string/port"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_weight="0.35"
- app:layout_constraintStart_toEndOf="@id/proxy_user_input_layout"
- app:layout_constraintTop_toBottomOf="@id/proxy_host_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/proxy_port"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="number"
- android:nextFocusForward="@id/proxy_password" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/proxy_password_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:layout_marginTop="@dimen/normal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:hint="@string/password"
- app:endIconMode="password_toggle"
- app:layout_constraintStart_toStartOf="@id/proxy_user_input_layout"
- app:layout_constraintTop_toBottomOf="@id/proxy_user_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/proxy_password"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textPassword" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/save"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/normal_margin"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/crypto_save"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/proxy_password_input_layout" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/activity_pwdstore.xml b/app/src/main/res/layout/activity_pwdstore.xml
deleted file mode 100644
index 295efd9c..00000000
--- a/app/src/main/res/layout/activity_pwdstore.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- tools:context=".PasswordStore">
-
- <LinearLayout
- android:id="@+id/main_layout"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical" />
-
-</LinearLayout>
diff --git a/app/src/main/res/layout/activity_ssh_keygen.xml b/app/src/main/res/layout/activity_ssh_keygen.xml
deleted file mode 100644
index 49548b36..00000000
--- a/app/src/main/res/layout/activity_ssh_keygen.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:paddingLeft="@dimen/activity_horizontal_margin"
- android:paddingTop="@dimen/activity_vertical_margin"
- android:paddingRight="@dimen/activity_horizontal_margin">
-
- <com.google.android.material.button.MaterialButtonToggleGroup
- android:id="@+id/key_type_group"
- style="@style/TextAppearance.MaterialComponents.Headline1"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- app:selectionRequired="true"
- app:singleSelection="true">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/key_type_rsa"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/ssh_keygen_label_rsa" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/key_type_ecdsa"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/ssh_keygen_label_ecdsa" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/key_type_ed25519"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/ssh_keygen_label_ed25519" />
- </com.google.android.material.button.MaterialButtonToggleGroup>
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/key_type_explanation"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingTop="8dp" />
-
- <com.google.android.material.checkbox.MaterialCheckBox
- android:id="@+id/key_require_authentication"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:text="@string/ssh_keygen_require_authentication" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/generate"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="end"
- android:layout_marginTop="8dp"
- android:text="@string/ssh_keygen_generate" />
- </LinearLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/basic_bottom_sheet.xml b/app/src/main/res/layout/basic_bottom_sheet.xml
deleted file mode 100644
index b45fc9c9..00000000
--- a/app/src/main/res/layout/basic_bottom_sheet.xml
+++ /dev/null
@@ -1,62 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:paddingBottom="24dp">
-
- <com.google.android.material.textview.MaterialTextView
- android:id="@+id/bottom_sheet_title"
- style="?attr/textAppearanceTitleLarge"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:layout_marginTop="8dp"
- android:visibility="gone"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="Bottom sheet title" />
-
- <com.google.android.material.textview.MaterialTextView
- android:id="@+id/bottom_sheet_message"
- style="?attr/textAppearanceBodyLarge"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:layout_marginTop="16dp"
- android:layout_marginEnd="8dp"
- app:layout_constraintTop_toBottomOf="@id/bottom_sheet_title"
- tools:text="A long body of text that serves as the bottom sheet message" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/bottom_sheet_cancel_button"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="24dp"
- android:layout_marginEnd="8dp"
- android:text="@string/dialog_cancel"
- android:visibility="gone"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message"
- tools:visibility="visible" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/bottom_sheet_ok_button"
- style="?attr/materialButtonOutlinedStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="24dp"
- android:layout_marginEnd="8dp"
- android:text="@string/dialog_ok"
- android:visibility="gone"
- app:layout_constraintEnd_toStartOf="@id/bottom_sheet_cancel_button"
- app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message"
- tools:visibility="visible" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/decrypt_layout.xml b/app/src/main/res/layout/decrypt_layout.xml
deleted file mode 100644
index 88c0fb1a..00000000
--- a/app/src/main/res/layout/decrypt_layout.xml
+++ /dev/null
@@ -1,65 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:paddingTop="16dp"
- tools:context="app.passwordstore.ui.crypto.DecryptActivity">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/password_category"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_gravity="center_vertical"
- android:layout_marginStart="32dp"
- android:layout_marginEnd="16dp"
- android:textIsSelectable="false"
- android:textSize="18sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="CATEGORY HERE" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/password_file"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="32dp"
- android:layout_marginEnd="16dp"
- android:textAppearance="@android:style/TextAppearance.DeviceDefault.Medium"
- android:textColor="?attr/colorSecondary"
- android:textSize="24sp"
- android:textStyle="bold"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/password_category"
- tools:text="PASSWORD FILE NAME HERE" />
-
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/divider"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_margin="16dp"
- android:src="@drawable/divider"
- app:layout_constraintTop_toBottomOf="@id/password_file"
- tools:ignore="ContentDescription" />
-
- <androidx.recyclerview.widget.RecyclerView
- android:id="@+id/recycler_view"
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:paddingHorizontal="16dp"
- app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/divider"
- tools:listitem="@layout/item_field" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/dialog_password_entry.xml b/app/src/main/res/layout/dialog_password_entry.xml
deleted file mode 100644
index bbfbc506..00000000
--- a/app/src/main/res/layout/dialog_password_entry.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="@dimen/activity_horizontal_margin">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/password_field"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/password"
- app:endIconMode="password_toggle"
- app:hintAnimationEnabled="true"
- app:hintEnabled="true">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/password_edit_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textPassword" />
- <requestFocus />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.checkbox.MaterialCheckBox
- android:id="@+id/auto_clear_cache"
- android:layout_width="match_parent"
- android:text="@string/clear_cached_password_on_screen_off"
- android:layout_height="wrap_content" />
-</LinearLayout>
diff --git a/app/src/main/res/layout/dialog_text_input.xml b/app/src/main/res/layout/dialog_text_input.xml
deleted file mode 100644
index c8539273..00000000
--- a/app/src/main/res/layout/dialog_text_input.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="@dimen/activity_horizontal_margin">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/text_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/edit_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content" />
-
- <requestFocus />
- </com.google.android.material.textfield.TextInputLayout>
-
-</LinearLayout>
diff --git a/app/src/main/res/layout/folder_dialog_fragment.xml b/app/src/main/res/layout/folder_dialog_fragment.xml
deleted file mode 100644
index 7860c9b4..00000000
--- a/app/src/main/res/layout/folder_dialog_fragment.xml
+++ /dev/null
@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical"
- android:padding="16dp">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/folder_name_container"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/crypto_name_hint"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/folder_name_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textNoSuggestions|textVisiblePassword" />
- <requestFocus />
- </com.google.android.material.textfield.TextInputLayout>
-
- <!-- TODO(msfjarvis): Restore this functionality -->
- <com.google.android.material.checkbox.MaterialCheckBox
- android:id="@+id/set_gpg_key"
- android:layout_width="0dp"
- android:visibility="gone"
- android:layout_height="wrap_content"
- android:text="@string/new_folder_set_gpg_key"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/folder_name_container" />
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_clone.xml b/app/src/main/res/layout/fragment_clone.xml
deleted file mode 100644
index 9cb34761..00000000
--- a/app/src/main/res/layout/fragment_clone.xml
+++ /dev/null
@@ -1,96 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/colorBackground">
-
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content">
-
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/app_icon"
- android:layout_width="64dp"
- android:layout_height="64dp"
- android:layout_marginStart="32dp"
- android:layout_marginTop="@dimen/onboarding_icon_margin_top"
- android:contentDescription="@string/app_icon_hint"
- android:src="@mipmap/ic_launcher"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/app_name"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_centerHorizontal="true"
- android:layout_marginStart="@dimen/fab_compat_margin"
- android:layout_marginEnd="@dimen/fab_compat_margin"
- android:text="@string/app_name"
- android:textAppearance="?attr/textAppearanceTitleLarge"
- android:textStyle="bold"
- app:layout_constraintBottom_toBottomOf="@id/app_icon"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toEndOf="@id/app_icon"
- app:layout_constraintTop_toTopOf="@+id/app_icon" />
-
- <TextView
- android:id="@+id/repo_type"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="48dp"
- android:layout_marginEnd="@dimen/fab_compat_margin"
- android:text="@string/select_n_repository_type"
- android:textAppearance="?attr/textAppearanceHeadlineSmall"
- android:textStyle="bold"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0"
- app:layout_constraintStart_toStartOf="@id/app_icon"
- app:layout_constraintTop_toBottomOf="@id/app_icon" />
-
- <TextView
- android:id="@+id/tv_repo_type_text"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/onboarding_desc_margin_top"
- android:layout_marginEnd="@dimen/fab_compat_margin"
- android:text="@string/select_repo_type_text"
- android:textAppearance="?attr/textAppearanceTitleSmall"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="@id/repo_type"
- app:layout_constraintTop_toBottomOf="@id/repo_type" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/clone_remote"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="16dp"
- android:layout_marginTop="@dimen/onboarding_button_margin_top"
- android:layout_marginEnd="16dp"
- android:maxWidth="300dp"
- android:minWidth="100dp"
- android:text="@string/clone_remote_repo"
- app:layout_constraintBottom_toTopOf="@id/create_local"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/tv_repo_type_text" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/create_local"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginBottom="128dp"
- android:maxWidth="300dp"
- android:minWidth="100dp"
- android:text="@string/create_local_repo"
- app:layout_constraintEnd_toEndOf="@id/clone_remote"
- app:layout_constraintStart_toStartOf="@id/clone_remote"
- app:layout_constraintTop_toBottomOf="@id/clone_remote" />
-
- </androidx.constraintlayout.widget.ConstraintLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/fragment_key_selection.xml b/app/src/main/res/layout/fragment_key_selection.xml
deleted file mode 100644
index b9141960..00000000
--- a/app/src/main/res/layout/fragment_key_selection.xml
+++ /dev/null
@@ -1,80 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/colorBackground"
- android:orientation="vertical">
-
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/app_icon"
- android:layout_width="64dp"
- android:layout_height="64dp"
- android:layout_marginStart="32dp"
- android:layout_marginTop="@dimen/onboarding_icon_margin_top"
- android:contentDescription="@string/app_icon_hint"
- android:src="@mipmap/ic_launcher"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/app_name"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_centerHorizontal="true"
- android:layout_marginStart="@dimen/fab_compat_margin"
- android:layout_marginEnd="@dimen/fab_compat_margin"
- android:text="@string/app_name"
- android:textAppearance="?attr/textAppearanceTitleLarge"
- android:textStyle="bold"
- app:layout_constraintBottom_toBottomOf="@id/app_icon"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toEndOf="@id/app_icon"
- app:layout_constraintTop_toTopOf="@+id/app_icon" />
-
- <TextView
- android:id="@+id/gpg_key"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="48dp"
- android:layout_marginEnd="@dimen/fab_compat_margin"
- android:text="@string/select_gpg_key_title"
- android:textAppearance="?attr/textAppearanceHeadlineSmall"
- android:textStyle="bold"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="0"
- app:layout_constraintStart_toStartOf="@id/app_icon"
- app:layout_constraintTop_toBottomOf="@id/app_icon" />
-
- <TextView
- android:id="@+id/gpg_key_text"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/onboarding_desc_margin_top"
- android:layout_marginEnd="16dp"
- android:text="@string/select_gpg_key_message"
- android:textAppearance="?attr/textAppearanceTitleSmall"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="@id/gpg_key"
- app:layout_constraintTop_toBottomOf="@id/gpg_key" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/select_key"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="16dp"
- android:layout_marginTop="48dp"
- android:layout_marginEnd="16dp"
- android:maxWidth="300dp"
- android:minWidth="100dp"
- android:text="@string/gpg_key_select"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/gpg_key_text" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_manual_otp_entry.xml b/app/src/main/res/layout/fragment_manual_otp_entry.xml
deleted file mode 100644
index 7d7ea832..00000000
--- a/app/src/main/res/layout/fragment_manual_otp_entry.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/secret_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/otp_import_manual_hint_secret"
- android:paddingStart="16dp"
- android:paddingTop="16dp"
- android:paddingEnd="16dp"
- app:hintEnabled="true"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/secret"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:inputType="textPassword" />
-
- <requestFocus />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/account_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:hint="@string/otp_import_manual_hint_account"
- android:paddingStart="16dp"
- android:paddingTop="16dp"
- android:paddingEnd="16dp"
- android:paddingBottom="16dp"
- app:hintEnabled="true"
- app:layout_constraintTop_toBottomOf="@id/secret_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/account"
- android:layout_width="match_parent"
- android:layout_height="match_parent" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_pwgen.xml b/app/src/main/res/layout/fragment_pwgen.xml
deleted file mode 100644
index 02eadff5..00000000
--- a/app/src/main/res/layout/fragment_pwgen.xml
+++ /dev/null
@@ -1,115 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:paddingLeft="24dp"
- android:paddingTop="20dp"
- android:paddingRight="24dp"
- android:paddingBottom="20dp"
- tools:context=".MainActivityFragment">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/passwordText"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8dp"
- android:textAppearance="?android:attr/textAppearanceMedium"
- android:textIsSelectable="true" />
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:baselineAligned="false"
- android:orientation="horizontal"
- android:weightSum="2">
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_weight="1"
- android:orientation="vertical">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8dp"
- android:text="@string/pwgen_include"
- android:textAppearance="?android:attr/textAppearanceSmall" />
-
- <CheckBox
- android:id="@+id/numerals"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_numerals" />
-
-
- <CheckBox
- android:id="@+id/symbols"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_symbols" />
-
- <CheckBox
- android:id="@+id/uppercase"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_uppercase" />
-
- <CheckBox
- android:id="@+id/lowercase"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_lowercase" />
-
- </LinearLayout>
-
- <LinearLayout
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_weight="1"
- android:orientation="vertical">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/length"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8dp"
- android:labelFor="@id/lengthNumber"
- android:text="@string/pwgen_length"
- android:textAppearance="?android:attr/textAppearanceSmall" />
-
- <EditText
- android:id="@+id/lengthNumber"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="8dp"
- android:autofillHints="notApplicable"
- android:ems="10"
- android:inputType="number" />
-
- <CheckBox
- android:id="@+id/pronounceable"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_pronounceable" />
-
- <CheckBox
- android:id="@+id/ambiguous"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/pwgen_ambiguous" />
-
- </LinearLayout>
- </LinearLayout>
- </LinearLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/fragment_pwgen_diceware.xml b/app/src/main/res/layout/fragment_pwgen_diceware.xml
deleted file mode 100644
index b8627670..00000000
--- a/app/src/main/res/layout/fragment_pwgen_diceware.xml
+++ /dev/null
@@ -1,73 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:paddingLeft="24dp"
- android:paddingTop="20dp"
- android:paddingRight="24dp"
- android:paddingBottom="20dp">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/password_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:textAppearance="?android:attr/textAppearanceMedium"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/password_length"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:layout_marginEnd="4dp"
- android:hint="@string/pwgen_length"
- android:labelFor="@id/password_length_text"
- app:hintAnimationEnabled="true"
- app:hintEnabled="true"
- app:layout_constraintEnd_toStartOf="@id/password_separator"
- app:layout_constraintHorizontal_weight="0.5"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/password_text">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/password_length_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="number"
- android:maxLength="2" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/password_separator"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="4dp"
- android:layout_marginTop="8dp"
- android:hint="@string/pwgen_separator"
- android:labelFor="@id/password_separator_text"
- app:hintAnimationEnabled="true"
- app:hintEnabled="true"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_weight="0.5"
- app:layout_constraintStart_toEndOf="@id/password_length"
- app:layout_constraintTop_toBottomOf="@id/password_text">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/password_separator_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:autofillHints=""
- android:importantForAutofill="no"
- android:inputType="text"
- android:maxLength="1" />
- </com.google.android.material.textfield.TextInputLayout>
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/fragment_welcome.xml b/app/src/main/res/layout/fragment_welcome.xml
deleted file mode 100644
index c68cd56a..00000000
--- a/app/src/main/res/layout/fragment_welcome.xml
+++ /dev/null
@@ -1,67 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="?android:attr/colorBackground"
- android:orientation="vertical">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/settings_button"
- style="?attr/borderlessButtonStyle"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentTop="true"
- android:layout_alignParentEnd="true"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:text="@string/action_settings"
- android:textAllCaps="true"
- android:textColor="?attr/colorOnBackground"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/app_icon"
- android:layout_width="100dp"
- android:layout_height="100dp"
- android:contentDescription="@string/app_icon_hint"
- android:src="@mipmap/ic_launcher"
- android:transitionName="transition_first_app_icon"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/app_name"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_centerHorizontal="true"
- android:layout_marginTop="16dp"
- android:text="@string/app_name"
- android:textAppearance="?attr/textAppearanceTitleLarge"
- android:textStyle="bold"
- android:transitionName="transition_first_run_app_name"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/app_icon" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/lets_go"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginStart="16dp"
- android:layout_marginTop="48dp"
- android:layout_marginEnd="16dp"
- android:maxWidth="300dp"
- android:minWidth="100dp"
- android:text="@string/let_s_go"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/app_name" />
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml
index 240c2d7a..8fc527e9 100644
--- a/app/src/main/res/layout/git_credential_layout.xml
+++ b/app/src/main/res/layout/git_credential_layout.xml
@@ -3,10 +3,11 @@
~ SPDX-License-Identifier: GPL-3.0-only
-->
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
@@ -15,10 +16,7 @@
android:layout_height="wrap_content"
app:endIconMode="password_toggle"
app:errorEnabled="true"
- app:hintEnabled="true"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
+ app:hintEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/git_auth_credential"
@@ -35,8 +33,5 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/git_operation_remember_passphrase"
- app:checkedState="checked"
- app:layout_constraintRight_toRightOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/git_auth_passphrase_layout" />
-</androidx.constraintlayout.widget.ConstraintLayout>
+ app:checkedState="checked" />
+</LinearLayout>
diff --git a/app/src/main/res/layout/git_log_row_layout.xml b/app/src/main/res/layout/git_log_row_layout.xml
deleted file mode 100644
index e996f6af..00000000
--- a/app/src/main/res/layout/git_log_row_layout.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="@dimen/activity_vertical_margin"
- android:paddingVertical="@dimen/activity_horizontal_margin">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/git_log_row_message"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/normal_margin"
- android:ellipsize="end"
- android:maxLines="2"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@+id/git_log_row_hash"
- tools:text="Commit message" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/git_log_row_hash"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textColor="?attr/colorSecondary"
- android:textStyle="bold"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="Hash" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/git_log_row_time"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:alpha="0.5"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="Time" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/item_create_sheet.xml b/app/src/main/res/layout/item_create_sheet.xml
deleted file mode 100644
index 10882a00..00000000
--- a/app/src/main/res/layout/item_create_sheet.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/create_folder"
- style="@style/Widget.MaterialComponents.Button.TextButton"
- android:layout_width="match_parent"
- android:layout_height="@dimen/bottom_sheet_item_height"
- android:layout_margin="@dimen/normal_margin"
- android:text="@string/bottom_sheet_create_new_folder"
- android:textAlignment="viewStart"
- android:textColor="?attr/colorOnSurface"
- app:icon="@drawable/ic_action_new_folder"
- app:iconPadding="@dimen/normal_margin"
- app:iconTint="?attr/colorSecondary"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:rippleColor="?attr/colorSecondary" />
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/create_password"
- style="@style/Widget.MaterialComponents.Button.TextButton"
- android:layout_width="match_parent"
- android:layout_height="@dimen/bottom_sheet_item_height"
- android:layout_margin="@dimen/normal_margin"
- android:text="@string/bottom_sheet_create_new_password"
- android:textAlignment="viewStart"
- android:textColor="?attr/colorOnSurface"
- app:icon="@drawable/ic_action_new_password"
- app:iconPadding="@dimen/normal_margin"
- app:iconTint="?attr/colorSecondary"
- app:layout_constraintTop_toBottomOf="@id/create_folder"
- app:rippleColor="?attr/colorSecondary" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/item_field.xml b/app/src/main/res/layout/item_field.xml
deleted file mode 100644
index 79697a56..00000000
--- a/app/src/main/res/layout/item_field.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/item_text_container"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
- tools:hint="@string/password"
- tools:visibility="visible">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/item_text"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:editable="false"
- android:textIsSelectable="true"
- tools:text="p@55w0rd!" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
-</LinearLayout>
diff --git a/app/src/main/res/layout/map_preference_widget_switch.xml b/app/src/main/res/layout/map_preference_widget_switch.xml
deleted file mode 100644
index 566e72d4..00000000
--- a/app/src/main/res/layout/map_preference_widget_switch.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<com.google.android.material.materialswitch.MaterialSwitch xmlns:android="http://schemas.android.com/apk/res/android"
- android:id="@android:id/checkbox"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:background="@null"
- android:clickable="false"
- android:focusable="false" />
diff --git a/app/src/main/res/layout/oreo_autofill_filter_row.xml b/app/src/main/res/layout/oreo_autofill_filter_row.xml
deleted file mode 100644
index b4284966..00000000
--- a/app/src/main/res/layout/oreo_autofill_filter_row.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:background="@drawable/password_row_background"
- android:paddingTop="8dp"
- android:paddingBottom="8dp">
-
- <ImageView
- android:id="@+id/credentialIcon"
- android:layout_width="wrap_content"
- android:layout_height="0dp"
- android:layout_marginStart="8dp"
- android:alpha="0.5"
- android:src="@drawable/ic_person_black_24dp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toStartOf="@id/title"
- app:layout_constraintHorizontal_bias="0.0"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:tint="?attr/colorControlNormal" />
-
- <TextView
- android:id="@+id/title"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:ellipsize="middle"
- android:singleLine="true"
- android:textSize="12sp"
- app:layout_constraintBottom_toTopOf="@id/subtitle"
- app:layout_constraintEnd_toStartOf="@id/continueIcon"
- app:layout_constraintStart_toEndOf="@id/credentialIcon"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="/example.com/" />
-
- <TextView
- android:id="@+id/subtitle"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:ellipsize="middle"
- android:singleLine="true"
- android:textSize="14sp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toStartOf="@+id/continueIcon"
- app:layout_constraintStart_toEndOf="@id/credentialIcon"
- app:layout_constraintTop_toBottomOf="@id/title"
- tools:text="johndoe@example.com" />
-
- <ImageView
- android:id="@+id/continueIcon"
- android:layout_width="wrap_content"
- android:layout_height="0dp"
- android:layout_marginEnd="8dp"
- android:src="@drawable/ic_keyboard_arrow_right_24dp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintHorizontal_bias="1.0"
- app:layout_constraintStart_toEndOf="@id/title"
- app:layout_constraintTop_toTopOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/oreo_autofill_instructions.xml b/app/src/main/res/layout/oreo_autofill_instructions.xml
deleted file mode 100644
index 66881390..00000000
--- a/app/src/main/res/layout/oreo_autofill_instructions.xml
+++ /dev/null
@@ -1,51 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <LinearLayout
- android:layout_width="fill_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:paddingLeft="24dp"
- android:paddingTop="20dp"
- android:paddingRight="24dp"
- android:paddingBottom="20dp">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/intro_text"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/oreo_autofill_enable_dialog_description"
- android:textSize="16sp" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:text="@string/oreo_autofill_enable_dialog_instructions"
- android:textSize="16sp" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/activity_vertical_margin"
- android:text="@string/oreo_autofill_enable_dialog_installed_browsers"
- android:textSize="16sp"
- android:textStyle="bold" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/supportedBrowsers"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:textSize="16sp"
- tools:text="Firefox: Fill and save credentials" />
-
-
- </LinearLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml
deleted file mode 100644
index c0692753..00000000
--- a/app/src/main/res/layout/password_creation_activity.xml
+++ /dev/null
@@ -1,147 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:fillViewport="false"
- tools:context="app.passwordstore.ui.crypto.PasswordCreationActivity">
-
- <androidx.constraintlayout.widget.ConstraintLayout
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:orientation="vertical"
- android:padding="@dimen/activity_horizontal_margin">
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/directory_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_gravity="center_vertical"
- android:layout_margin="8dp"
- android:enabled="false"
- android:hint="@string/directory_hint"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/directory"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:imeOptions="actionNext"
- android:inputType="textNoSuggestions"
- android:nextFocusForward="@id/password"
- tools:text="CATEGORY HERE" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/name_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_gravity="center_vertical"
- android:layout_margin="8dp"
- android:hint="@string/crypto_name_hint"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/directory_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/filename"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:imeOptions="actionNext"
- android:inputType="textNoSuggestions"
- android:nextFocusForward="@id/username" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:visibility="gone"
- tools:visibility="visible"
- android:id="@+id/username_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:hint="@string/crypto_username_hint"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/name_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/username"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:imeOptions="actionNext"
- android:inputType="textNoSuggestions"
- android:nextFocusForward="@id/password" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/password_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:hint="@string/crypto_pass_label"
- app:endIconMode="password_toggle"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/username_input_layout">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/password"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:imeOptions="actionDone"
- android:inputType="textVisiblePassword" />
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/generate_password"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:text="@string/pwd_generate_button"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/password_input_layout" />
-
- <com.google.android.material.textfield.TextInputLayout
- android:id="@+id/extra_input_layout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:hint="@string/crypto_extra_label"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/generate_password">
-
- <com.google.android.material.textfield.TextInputEditText
- android:id="@+id/extra_content"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:inputType="textMultiLine|textVisiblePassword" />
-
- </com.google.android.material.textfield.TextInputLayout>
-
- <com.google.android.material.button.MaterialButton
- android:id="@+id/otp_import_button"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:text="@string/add_otp"
- app:icon="@drawable/ic_qr_code_scanner"
- app:iconTint="?attr/colorOnSecondary"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toBottomOf="@id/extra_input_layout" />
-
- <com.google.android.material.switchmaterial.SwitchMaterial
- android:id="@+id/encrypt_username"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_margin="8dp"
- android:text="@string/crypto_encrypt_username_label"
- android:visibility="gone"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/otp_import_button"
- tools:visibility="visible" />
- </androidx.constraintlayout.widget.ConstraintLayout>
-</ScrollView>
diff --git a/app/src/main/res/layout/password_recycler_view.xml b/app/src/main/res/layout/password_recycler_view.xml
deleted file mode 100644
index 49b7ff82..00000000
--- a/app/src/main/res/layout/password_recycler_view.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:clipChildren="false"
- android:clipToPadding="false"
- android:orientation="vertical"
- tools:context="app.passwordstore.ui.passwords.PasswordFragment">
-
- <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
- android:id="@+id/swipe_refresher"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <me.zhanghai.android.fastscroll.FixOnItemTouchListenerRecyclerView
- android:id="@+id/pass_recycler"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:scrollbars="none"
- tools:itemCount="20"
- tools:listitem="@layout/password_row_layout" />
- </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
-
- <com.google.android.material.floatingactionbutton.FloatingActionButton
- android:id="@+id/fab"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_alignParentEnd="true"
- android:layout_alignParentBottom="true"
- android:layout_gravity="bottom|end"
- android:layout_margin="@dimen/fab_compat_margin"
- android:contentDescription="@string/password_list_fab_content_description"
- android:src="@drawable/ic_add_48dp" />
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/password_row_layout.xml b/app/src/main/res/layout/password_row_layout.xml
deleted file mode 100644
index 5c042839..00000000
--- a/app/src/main/res/layout/password_row_layout.xml
+++ /dev/null
@@ -1,50 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:background="@drawable/password_row_background"
- android:paddingTop="12dp"
- android:paddingBottom="12dp">
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/label"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_gravity="bottom"
- android:layout_marginStart="@dimen/activity_horizontal_margin"
- android:textSize="18sp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toStartOf="@id/child_count"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="FILE_NAME" />
-
- <androidx.appcompat.widget.AppCompatTextView
- android:id="@+id/child_count"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginEnd="8dp"
- android:textSize="18sp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toStartOf="@id/folder_indicator"
- app:layout_constraintTop_toTopOf="parent"
- tools:text="12" />
-
- <androidx.appcompat.widget.AppCompatImageView
- android:id="@+id/folder_indicator"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginEnd="@dimen/activity_horizontal_margin"
- android:contentDescription="@null"
- android:src="@drawable/ic_keyboard_arrow_right_24dp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/select_folder_layout.xml b/app/src/main/res/layout/select_folder_layout.xml
deleted file mode 100644
index 78b03579..00000000
--- a/app/src/main/res/layout/select_folder_layout.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
-
- <LinearLayout
- android:id="@+id/pgp_handler_linearlayout"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical" />
-</LinearLayout>
diff --git a/app/src/main/res/menu/context_pass.xml b/app/src/main/res/menu/context_pass.xml
deleted file mode 100644
index 608d7eef..00000000
--- a/app/src/main/res/menu/context_pass.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- tools:context=".pwdstore">
-
- <item
- android:id="@+id/menu_move_password"
- android:icon="@drawable/ic_move_folder_24dp"
- android:title="@string/move"
- app:showAsAction="ifRoom" />
-
- <item
- android:id="@+id/menu_delete_password"
- android:icon="@drawable/ic_delete_24dp"
- android:title="@string/delete"
- app:showAsAction="ifRoom" />
-
- <item
- android:id="@+id/menu_edit_password"
- android:icon="@drawable/ic_edit_24dp"
- android:title="@string/edit"
- app:showAsAction="ifRoom" />
-
- <item
- android:id="@+id/menu_pin_password"
- android:icon="@drawable/ic_push_pin_24dp"
- android:title="@string/place_shortcut_on_home_screen"
- app:showAsAction="ifRoom" />
-</menu>
diff --git a/app/src/main/res/menu/main_menu_git.xml b/app/src/main/res/menu/main_menu_git.xml
deleted file mode 100644
index 1b64c083..00000000
--- a/app/src/main/res/menu/main_menu_git.xml
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto">
-
- <item
- android:id="@+id/action_search"
- android:icon="@drawable/ic_search_24dp"
- android:title="@string/action_search"
- app:actionViewClass="androidx.appcompat.widget.SearchView"
- app:showAsAction="always|collapseActionView" />
-
- <item
- android:id="@+id/git_sync"
- android:title="@string/git_sync" />
-
- <item
- android:id="@+id/git_pull"
- android:title="@string/git_pull" />
-
- <item
- android:id="@+id/git_push"
- android:title="@string/git_push" />
-
- <item
- android:id="@+id/refresh"
- android:title="@string/refresh_list" />
-
- <item
- android:id="@+id/user_pref"
- android:orderInCategory="100"
- android:title="@string/action_settings" />
-</menu>
diff --git a/app/src/main/res/menu/main_menu_no_auth.xml b/app/src/main/res/menu/main_menu_no_auth.xml
deleted file mode 100644
index 46f19e49..00000000
--- a/app/src/main/res/menu/main_menu_no_auth.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto">
-
- <item
- android:id="@+id/action_search"
- android:icon="@drawable/ic_search_24dp"
- android:title="@string/action_search"
- app:actionViewClass="androidx.appcompat.widget.SearchView"
- app:showAsAction="always|collapseActionView" />
-
- <item
- android:id="@+id/git_pull"
- android:title="@string/git_pull" />
-
- <item
- android:id="@+id/refresh"
- android:title="@string/refresh_list" />
-
- <item
- android:id="@+id/user_pref"
- android:orderInCategory="100"
- android:title="@string/action_settings" />
-</menu>
diff --git a/app/src/main/res/menu/main_menu_non_git.xml b/app/src/main/res/menu/main_menu_non_git.xml
deleted file mode 100644
index 5a7cf00b..00000000
--- a/app/src/main/res/menu/main_menu_non_git.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto">
-
- <item
- android:id="@+id/action_search"
- android:icon="@drawable/ic_search_24dp"
- android:title="@string/action_search"
- app:actionViewClass="androidx.appcompat.widget.SearchView"
- app:showAsAction="always|collapseActionView" />
-
- <item
- android:id="@+id/refresh"
- android:title="@string/refresh_list" />
-
- <item
- android:id="@+id/user_pref"
- android:orderInCategory="100"
- android:title="@string/action_settings" />
-</menu>
diff --git a/app/src/main/res/menu/pgp_handler.xml b/app/src/main/res/menu/pgp_handler.xml
deleted file mode 100644
index a8b6a524..00000000
--- a/app/src/main/res/menu/pgp_handler.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:pwstore="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- tools:context=".ui.crypto.PgpActivity">
- <item
- android:id="@+id/share_password_as_plaintext"
- android:icon="@drawable/ic_share_24dp"
- android:title="@string/share_as_plaintext"
- android:visible="false"
- pwstore:showAsAction="ifRoom" />
- <item
- android:id="@+id/copy_password"
- android:icon="@drawable/ic_content_copy"
- android:title="@string/copy_password"
- android:visible="false"
- pwstore:showAsAction="ifRoom" />
- <item
- android:id="@+id/edit_password"
- android:icon="@drawable/ic_edit_24dp"
- android:title="@string/edit_password"
- android:visible="false"
- pwstore:showAsAction="ifRoom" />
-</menu>
diff --git a/app/src/main/res/menu/pgp_handler_new_password.xml b/app/src/main/res/menu/pgp_handler_new_password.xml
deleted file mode 100644
index 6c61fb55..00000000
--- a/app/src/main/res/menu/pgp_handler_new_password.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:pwstore="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- tools:context=".ui.crypto.PgpActivity">
- <item
- android:id="@+id/save_password"
- android:icon="@drawable/ic_save_24dp"
- android:title="@string/crypto_save"
- pwstore:showAsAction="ifRoom" />
- <item
- android:id="@+id/save_and_copy_password"
- android:icon="@drawable/ic_save_copy_24dp"
- android:title="@string/crypto_save_and_copy"
- pwstore:showAsAction="ifRoom" />
-</menu>
diff --git a/app/src/main/res/menu/pgp_handler_select_folder.xml b/app/src/main/res/menu/pgp_handler_select_folder.xml
deleted file mode 100644
index d9505814..00000000
--- a/app/src/main/res/menu/pgp_handler_select_folder.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:pwstore="http://schemas.android.com/apk/res-auto"
- xmlns:tools="http://schemas.android.com/tools"
- tools:context="app.passwordstore.SelectFolderActivity">
- <item
- android:id="@+id/crypto_select"
- android:icon="@drawable/ic_done_24dp"
- android:title="@string/crypto_select"
- pwstore:showAsAction="ifRoom" />
-</menu>
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 7499fe5e..a0a20dc7 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -24,7 +24,6 @@
</plurals>
<string name="delete_directory_progress_text">Wird gelöscht</string>
<string name="move">Verschieben</string>
- <string name="edit">Bearbeiten</string>
<string name="delete">Löschen</string>
<string name="password_exists_title">Passwort existiert bereits!</string>
<string name="password_exists_message">Dies wird %1$s mit %2$s überschreiben.</string>
@@ -40,7 +39,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">In die Zwischenablage kopiert</string>
<string name="file_toast_text">Bitte setzen Sie einen Dateinamen.</string>
- <string name="path_toast_text">Bitte setzen Sie einen Pfad.</string>
<string name="empty_toast_text">Sie können kein leeres Passwort oder leere Extra-Angaben setzen.</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Es ist ein Fehler während der Git-Operation aufgetreten.</string>
@@ -64,7 +62,6 @@
<string name="crypto_name_hint">Name</string>
<string name="crypto_pass_label">Passwort</string>
<string name="crypto_extra_label">Zusätzliche Angaben</string>
- <string name="crypto_encrypt_username_label">Benutzername verschlüsseln</string>
<string name="crypto_select">Auswählen</string>
<string name="crypto_save">Speichern</string>
<string name="crypto_save_and_copy">Speichern und kopieren</string>
@@ -99,8 +96,6 @@
<string name="pref_recursive_filter_summary">Findet Passwörter auch in Unterordnern.</string>
<string name="pref_sort_order_title">Passwortsortierung</string>
<string name="pref_folder_first_sort_order">Ordner zuerst</string>
- <string name="pref_file_first_sort_order">Dateien zuerst</string>
- <string name="pref_type_independent_sort_order">typunabhängig</string>
<string name="pref_recently_used_sort_order">Zuletzt verwendet</string>
<string name="pref_category_autofill_title">Automatisch ausfüllen</string>
<string name="pref_autofill_enable_title">Autofill aktivieren</string>
@@ -126,7 +121,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Passwort erstellen</string>
<string name="pwgen_generate">Erstellen</string>
- <string name="pwgen_include">Einschließen</string>
<string name="pwgen_length">Länge</string>
<string name="pwgen_numerals">Nummern</string>
<string name="pwgen_symbols">Symbole</string>
@@ -167,7 +161,6 @@
<string name="dialog_yes">Ja</string>
<string name="dialog_no">Nein</string>
<string name="dialog_cancel">Abbruch</string>
- <string name="dialog_skip">Überspringen</string>
<string name="git_sync">Synchronisiere Repository</string>
<string name="git_pull">Git Pull</string>
<string name="git_push">Git Push</string>
@@ -175,10 +168,7 @@
<string name="git_log">Commit-Log anzeigen</string>
<string name="show_password_pref_title">Zeige das Passwort.</string>
<string name="show_password_pref_summary">Soll das entschlüsselte Passwort sichtbar sein? Dies deaktiviert nicht das Kopieren.</string>
- <string name="pwd_generate_button">Erstellen</string>
- <string name="refresh_list">Liste aktualisieren</string>
<string name="send_plaintext_password_to">Passwort unverschlüsselt senden an…</string>
- <string name="app_icon_hint">App-Icon</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Eintrag zum Ausfüllen auswählen</string>
<string name="oreo_autofill_strict_domain_search">Phishing-resistente Suche</string>
@@ -205,12 +195,11 @@
<string name="oreo_autofill_password_fill_support">Passwörter ausfüllen</string>
<string name="oreo_autofill_flaky_fill_support">Passwörter ausfüllen (manchmal kann ein Neustart des Browsers erforderlich sein)</string>
<string name="oreo_autofill_no_support">Kein Support</string>
- <string name="oreo_autofill_enable_dialog_description">Password Store kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"Password Store\" aus der Liste und bestätigen mit \"OK\".</string>
+ <string name="oreo_autofill_enable_dialog_description">HardenedPass kann das Ausfüllen von Anmeldeformularen und sogar das Speichern von Anmeldedaten in Apps oder auf Webseiten übernehmen.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Tippen Sie auf OK, um diese Funktion zu aktivieren und um zu den Autofill-Einstellungen zu gelangen. Dort wählen Sie \"HardenedPass\" aus der Liste und bestätigen mit \"OK\".</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Autofill-Unterstützung mit installierten Browsern:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Der private SSH-Schlüssel konnte nicht geöffnet werden. Bitte überprüfen Sie, ob die Datei existiert.</string>
- <string name="new_password_title">Neues Passwort</string>
<string name="clear_saved_passphrase_ssh">Gespeicherte Passphrase für lokalen SSH-Schlüssel löschen</string>
<string name="clear_saved_passphrase_https">Gespeichertes HTTPS-Passwort löschen</string>
<string name="git_operation_remember_passphrase">Schlüsselpasswort merken</string>
@@ -226,7 +215,6 @@
<string name="pref_biometric_auth_title">Biometrische Authentifizierung</string>
<string name="pref_biometric_auth_summary">Falls aktiviert, werden Sie beim Starten der App nach Ihrem Fingerabdruck gefragt.</string>
<string name="pref_biometric_auth_summary_error">Fingerabdrucksensor fehlt oder ist nicht ansprechbar.</string>
- <string name="pref_title_openkeystore_clear_keyid">Lösche gespeicherte OpenKeystore SSH-Schlüssel-ID.</string>
<string name="your_public_key">Ihr öffentlicher Schlüssel</string>
<string name="error_generate_ssh_key">Fehler beim Erstellen des SSH-Schlüssels</string>
<string name="pref_show_hidden_title">Alle Dateien und Ordner anzeigen</string>
@@ -234,7 +222,6 @@
<string name="title_create_folder">Ordner erstellen</string>
<string name="title_rename_folder">Ordner umbenennen</string>
<string name="message_category_error_empty_field">Der Name der Kategorie darf nicht leer sein.</string>
- <string name="message_category_error_category_exists">Der Kategoriename existiert bereits.</string>
<string name="message_error_destination_outside_repo">Ziel muss innerhalb des Repositorys sein.</string>
<string name="message_rename_folder">Ziel für %1$s angeben</string>
<string name="button_create">Erstellen</string>
@@ -269,8 +256,8 @@
<string name="preference_custom_public_suffixes_hint">company.com\npersonal.com</string>
<!-- Password creation failure -->
<string name="password_creation_file_fail_title">Fehler</string>
- <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Password Store, bitte versuchen Sie es erneut.</string>
- <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Password Store, bitte löschen Sie die Datei von Hand.</string>
+ <string name="password_creation_file_write_fail_message">Fehler beim Speichern der Passwortdatei im Repository, bitte versuchen Sie es erneut.</string>
+ <string name="password_creation_file_delete_fail_message">Fehler beim Löschen der Passwortdatei %1$s aus dem Repository, bitte löschen Sie die Datei von Hand.</string>
<string name="password_creation_duplicate_error">Datei existiert bereits, bitte benutzen Sie einen anderen Dateinamen.</string>
<string name="add_otp">OTP hinzufügen</string>
<string name="otp_import_success">TOTP-Konfiguration erfolgreich importiert</string>
@@ -280,11 +267,9 @@
<string name="invalid_gpg_id">.gpg-id wurde gefunden, enthält jedoch ungültige Schlüssel-ID, Fingerabdruck oder Benutzer-ID.</string>
<string name="short_gpg_id">.gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird.</string>
<string name="invalid_filename_text">Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen</string>
- <string name="directory_hint">Ordner</string>
- <string name="new_folder_set_gpg_key">PGP-Schlüssel für Ordner festlegen</string>
<!-- GitException messages -->
<string name="git_unknown_error">Unbekannter Fehler</string>
- <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen &gt; Lokale Git-Konfiguration &amp; Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string>
+ <string name="git_pull_rebase_fail_error">Pull fehlgeschlagen. Sie befinden sich in einem abgelösten Head. Speichern Sie die Änderungen in \"Einstellungen &gt; Repository &gt; Lokale Git-Konfiguration &amp; Dienstprogramme\" auf das Remote-Repository in einem neuen Branch und lösen Sie den Konflikt auf Ihrem Computer auf.</string>
<string name="git_pull_merge_fail_error">Merge fehlgeschlagen, Sie befinden sich in einem Konflikt.</string>
<string name="git_push_nff_error">Push wurde vom Remote abgelehnt. Führen Sie den Pull vor dem Push aus. Sie können \"Synchronisieren\" statt Pull/Push nutzen, da es beides implementiert</string>
<string name="git_push_generic_error">Push wurde von Remote abgelehnt, Grund: %1$s</string>
@@ -294,16 +279,12 @@
<string name="git_break_out_of_detached_success">Es gab einen Konflikt während des Rebase-Prozesses. Ihr lokaler Branch %1$s wurde auf einen anderen Branch namens %2$s gepusht.\nNutzen Sie diesen Branch, um den Konflikt auf dem Server zu beheben</string>
<string name="git_break_out_of_detached_unneeded">Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen.</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">Um fortzufahren, muss ein PGP-Schlüssel ausgewählt werden.</string>
<string name="folder_creation_err_file_exists">Dateiname bereits vergeben</string>
<string name="folder_creation_err_folder_exists">Ein Ordner mit diesem Namen existiert bereits.</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Los geht\'s</string>
- <string name="select_n_repository_type">Wählen Sie einen \nRepository-Typ.</string>
<string name="select_repo_type_text">Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten.</string>
<string name="clone_remote_repo">Remote-Repository klonen</string>
<string name="create_local_repo">Lokales Repository anlegen</string>
- <string name="select_gpg_key_title">Wählen Sie einen\nPGP-Schlüssel.</string>
<string name="select_gpg_key_message">Wählen einen PGP-Schlüssel, mit dem der Store initialisiert werden soll.</string>
<string name="gpg_key_select">Schlüssel auswählen</string>
<!-- SSH port validation -->
@@ -324,11 +305,9 @@
<string name="clear_saved_host_key_success">Host-Schlüssel erfolgreich gelöscht!</string>
<string name="otp_import_qr_code">QR-Code scannen</string>
<string name="otp_import_from_file">Wählen Sie ein Bild.</string>
- <string name="otp_import_manual_entry">Manuell eingeben</string>
<string name="otp_import_manual_hint_secret">Schlüssel</string>
<string name="otp_import_manual_hint_account">Benutzer-Konto</string>
<string name="place_shortcut_on_home_screen">Verknüpfung auf dem Home-Screen erstellen</string>
- <string name="password_list_fab_content_description">Neues Passwort oder neuen Ordner erstellen</string>
<string name="pgp_key_import_failed">Fehler beim Import des PGP-Schlüssels</string>
<string name="pgp_key_import_failed_replace_message">Ein bestehender Schlüssel mit dieser ID wurde gefunden. Möchten Sie ihn ersetzen?</string>
<string name="pgp_key_import_succeeded">PGP-Schlüssel erfolgreich importiert</string>
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 6c8ac709..d4d4d424 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -24,7 +24,6 @@
</plurals>
<string name="delete_directory_progress_text">Suppression…</string>
<string name="move">Déplacer</string>
- <string name="edit">Éditer</string>
<string name="delete">Supprimer</string>
<string name="password_exists_title">Le mot de passe existe!</string>
<string name="password_exists_message">Cela écrasera %1$s avec %2$s.</string>
@@ -39,7 +38,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Copié dans le presse-papiers</string>
<string name="file_toast_text">Veuillez fournir un nom de fichier</string>
- <string name="path_toast_text">Veuillez fournir un chemin d\'accès au fichier</string>
<string name="empty_toast_text">Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Une erreur c\'est produite lors d\'une opération Git</string>
@@ -63,7 +61,6 @@
<string name="crypto_name_hint">Nom</string>
<string name="crypto_pass_label">Mot de passe</string>
<string name="crypto_extra_label">Extra</string>
- <string name="crypto_encrypt_username_label">Chiffrer le nom d\'utilisateur</string>
<string name="crypto_select">Sélectionner</string>
<string name="crypto_save">Sauvegarder</string>
<string name="crypto_save_and_copy">Enregistrer et copier</string>
@@ -97,8 +94,6 @@
<string name="pref_recursive_filter_summary">Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel.</string>
<string name="pref_sort_order_title">Ordre de tri des mots de passe</string>
<string name="pref_folder_first_sort_order">Dossiers en premier</string>
- <string name="pref_file_first_sort_order">Fichiers en premier</string>
- <string name="pref_type_independent_sort_order">Indifférent au type d\'entrée</string>
<string name="pref_recently_used_sort_order">Récemment utilisé</string>
<string name="pref_category_autofill_title">Saisie automatique</string>
<string name="pref_autofill_enable_title">Saisie automatique</string>
@@ -114,7 +109,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Générer un mot de passe</string>
<string name="pwgen_generate">Générer</string>
- <string name="pwgen_include">Inclure</string>
<string name="pwgen_length">Longueur</string>
<string name="pwgen_numerals">Chiffres</string>
<string name="pwgen_symbols">Symboles</string>
@@ -155,7 +149,6 @@
<string name="dialog_yes">Oui</string>
<string name="dialog_no">Non</string>
<string name="dialog_cancel">Annuler</string>
- <string name="dialog_skip">Ignorer</string>
<string name="git_sync">Synchronisation du dépôt</string>
<string name="git_pull">Importer du serveur</string>
<string name="git_push">Pousser sur le serveur</string>
@@ -163,10 +156,7 @@
<string name="git_log">Afficher l\'historique des commits</string>
<string name="show_password_pref_title">Montrer le mot de passe</string>
<string name="show_password_pref_summary">Contrôle la visibilité des mots de passe une fois déchifrées, ceci n\'empêche pas de copier le mot de passe</string>
- <string name="pwd_generate_button">Générer</string>
- <string name="refresh_list">Rafraichir la liste</string>
<string name="send_plaintext_password_to">Envoyer le mot de passe en clair via…</string>
- <string name="app_icon_hint">Icône de l\'application</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Sélectionnez l\'entrée à remplir</string>
<string name="oreo_autofill_strict_domain_search">Recherche protégée contre le hameçonnage</string>
@@ -193,12 +183,11 @@
<string name="oreo_autofill_password_fill_support">Remplir le mot de passe</string>
<string name="oreo_autofill_flaky_fill_support">Remplir les mots de passe (peut nécessiter un redémarrage du navigateur de temps à autre)</string>
<string name="oreo_autofill_no_support">Pas de support</string>
- <string name="oreo_autofill_enable_dialog_description">Password Store vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez Password Store dans la liste puis confirmez avec OK.</string>
+ <string name="oreo_autofill_enable_dialog_description">HardenedPass vous permet de remplir des formulaires de connexion ainsi que d\'enregistrer vos identifiants d\'applications ou de sites Web.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Pour activer cette fonctionnalité, appuyez sur OK pour aller dans les paramètres de saisie automatique, sélectionnez HardenedPass dans la liste puis confirmez avec OK.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Prise en charge du remplissage automatique avec les navigateurs installés:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Impossible d\'ouvrir la clef ssh, merci de vérifier que le ficher existe</string>
- <string name="new_password_title">Nouveau mot de passe</string>
<string name="clear_saved_passphrase_ssh">Effacer le mot de passe enregistrée pour la clé SSH locale</string>
<string name="clear_saved_passphrase_https">Effacer le mot de passe HTTPS enregistré</string>
<string name="git_operation_remember_passphrase">Se rappeler de la phrase secrète dans la configuration de l\'application (peu sûr)</string>
@@ -212,9 +201,8 @@
<string name="biometric_prompt_title">Identification biométrique</string>
<string name="biometric_auth_error_reason">Échec de l\'authentification : %s</string>
<string name="pref_biometric_auth_title">Activer l\'authentification biométrique</string>
- <string name="pref_biometric_auth_summary">Lorsque cette option est activée, Password Store vous demandera votre empreinte digitale au lancement</string>
+ <string name="pref_biometric_auth_summary">Lorsque cette option est activée, HardenedPass vous demandera votre empreinte digitale au lancement</string>
<string name="pref_biometric_auth_summary_error">Lecteur d\'empreinte digitale non accessible ou manquant</string>
- <string name="pref_title_openkeystore_clear_keyid">Effacer la clé d\'identification SSH d\'OpenKeystore mémorisée</string>
<string name="your_public_key">Votre clé publique</string>
<string name="error_generate_ssh_key">Une erreur est survenue pendant la génération de la clé ssh</string>
<string name="pref_show_hidden_title">Afficher tous les fichiers et dossiers</string>
@@ -222,7 +210,6 @@
<string name="title_create_folder">Créer un dossier</string>
<string name="title_rename_folder">Renommer le dossier</string>
<string name="message_category_error_empty_field">Le nom de la catégorie ne peut pas être vide</string>
- <string name="message_category_error_category_exists">Une catégorie du même nom existe déjà</string>
<string name="message_error_destination_outside_repo">La destination doit être à l\'intérieur du dépôt</string>
<string name="message_rename_folder">Entrez le chemin pour %1$s</string>
<string name="button_create">Créer</string>
@@ -265,11 +252,9 @@
<string name="otp_import_failure_generic">Impossible d\'importer la configuration TOTP</string>
<string name="exporting_passwords">Exportation des mots de passe…</string>
<string name="invalid_filename_text">Le nom du fichier ne doit pas contenir \'/\', définir le répertoire parent</string>
- <string name="directory_hint">Dossier</string>
- <string name="new_folder_set_gpg_key">Définir la clé GPG pour le dossier</string>
<!-- GitException messages -->
<string name="git_unknown_error">Erreur inconnue</string>
- <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"settings &gt; git utils\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string>
+ <string name="git_pull_rebase_fail_error">Tirer a échoué, vous êtes dans un \'head\' détaché. En utilisant \"Paramètres &gt; Dépôt &gt; Configuration locale de Git &amp; utilitaires\", enregistrez vos modifications sur le dépôt distant dans une nouvelle branche et résolvez le conflit sur votre ordinateur.</string>
<string name="git_pull_merge_fail_error">La fusion a échoué, vous êtes dans un état de conflit.</string>
<string name="git_push_nff_error">La poussée a été rejetée par le dépôt distant, exécutez une tirée avant de pousser à nouveau. Vous pouvez utiliser Synchroniser plutôt que de tirer/pousser car il implémente les deux.</string>
<string name="git_push_generic_error">L\'envoi a été rejeté par le dépôt distant, raison : %1$s</string>
@@ -282,12 +267,9 @@
<string name="folder_creation_err_file_exists">Un fichier portant ce nom existe déjà</string>
<string name="folder_creation_err_folder_exists">Un dossier portant ce nom existe déjà</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Allons-y</string>
- <string name="select_n_repository_type">Sélectionnez le type de dépôt \n</string>
<string name="select_repo_type_text">Sélectionnez si vous voulez créer un dépôt local ou cloner un dépôt distant.</string>
<string name="clone_remote_repo">Cloner le dépôt distant</string>
<string name="create_local_repo">Créer un dépôt local</string>
- <string name="select_gpg_key_title">Sélectionnez\nla clé GPG</string>
<string name="select_gpg_key_message">Sélectionnez une clé GPG pour initialiser votre magasin</string>
<string name="gpg_key_select">Sélectionner la clé</string>
<!-- SSH port validation -->
@@ -305,10 +287,8 @@
<string name="clear_saved_host_key_success">La clé d\'hôte a été effacée avec succès!</string>
<string name="otp_import_qr_code">Scanner le code QR</string>
<string name="otp_import_from_file">Choisissez une image</string>
- <string name="otp_import_manual_entry">Saisir manuellement</string>
<string name="otp_import_manual_hint_secret">Secret</string>
<string name="otp_import_manual_hint_account">Compte</string>
- <string name="password_list_fab_content_description">Créer un nouveau mot de passe ou dossier</string>
<string name="pgp_key_import_failed">Impossible d\'importer la clé PGP</string>
<string name="pgp_key_import_succeeded">Clé PGP importée avec succès</string>
<string name="pgp_key_import_succeeded_message">La clé d\'identification de la clé importée est donné ci-dessous, veuillez la vérifier pour qu\'elle soit correcte :\n%1$s</string>
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index 4ca39712..f966230c 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -24,7 +24,6 @@
</plurals>
<string name="delete_directory_progress_text">Eliminando…</string>
<string name="move">Mover</string>
- <string name="edit">Editar</string>
<string name="delete">Eliminar</string>
<string name="password_exists_title">O contrasinal xa existe!</string>
<string name="password_exists_message">Vas sobrescribir %1$s con %2$s.</string>
@@ -40,7 +39,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Copiado ao portapapeis</string>
<string name="file_toast_text">Debes proporcionar un nome de ficheiro</string>
- <string name="path_toast_text">Por favor indica a ruta ao ficheiro</string>
<string name="empty_toast_text">Non podes deixar baleiro o contrasinal ou o contido extra</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Algo fallou na operación de Git</string>
@@ -64,7 +62,6 @@
<string name="crypto_name_hint">Nome</string>
<string name="crypto_pass_label">Contrasinal</string>
<string name="crypto_extra_label">Contido extra</string>
- <string name="crypto_encrypt_username_label">Cifrar identificador</string>
<string name="crypto_select">Seleccionar</string>
<string name="crypto_save">Gardar</string>
<string name="crypto_save_and_copy">Gardar e copiar</string>
@@ -100,8 +97,6 @@
<string name="pref_directory_structure">Organización dos contrasinais</string>
<string name="pref_sort_order_title">Orde para mostrar contrasinais</string>
<string name="pref_folder_first_sort_order">Primeiro cartafoles</string>
- <string name="pref_file_first_sort_order">Primeiro ficheiros</string>
- <string name="pref_type_independent_sort_order">Independentemente do tipo</string>
<string name="pref_recently_used_sort_order">Usadas recentemente</string>
<string name="pref_category_autofill_title">Completado automático</string>
<string name="pref_autofill_enable_title">Activar completado automático</string>
@@ -127,7 +122,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Crear contrasinal</string>
<string name="pwgen_generate">Crear</string>
- <string name="pwgen_include">Incluír</string>
<string name="pwgen_length">Lonxitude</string>
<string name="pwgen_numerals">Números</string>
<string name="pwgen_symbols">Símbolos</string>
@@ -168,7 +162,6 @@
<string name="dialog_yes">Si</string>
<string name="dialog_no">Non</string>
<string name="dialog_cancel">Cancelar</string>
- <string name="dialog_skip">Omitir</string>
<string name="git_sync">Sincronizar repositorio</string>
<string name="git_pull">Traer desde remoto</string>
<string name="git_push">Enviar a remoto</string>
@@ -176,10 +169,7 @@
<string name="git_log">Mostrar rexistro dos cambios</string>
<string name="show_password_pref_title">Mostrar contrasinal</string>
<string name="show_password_pref_summary">Controlar a visibilidade do contrasinal unha vez descifrado, esto non desactiva o copiado do contrasinal</string>
- <string name="pwd_generate_button">Crear</string>
- <string name="refresh_list">Actualizar lista</string>
<string name="send_plaintext_password_to">Enviar contrasinal como texto plano usando…</string>
- <string name="app_icon_hint">Icona da app</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Elixe o elemento a completar</string>
<string name="oreo_autofill_strict_domain_search">Busca resistente ao phishing</string>
@@ -192,7 +182,7 @@
<string name="oreo_autofill_save_passwords_dont_match">Non concordan os contrasinais</string>
<string name="oreo_autofill_generate_password">Crear elemento</string>
<string name="oreo_autofill_max_matches_reached">Acadouse o máximo número de coincidencias (%1$d); eliminar as coincidencias antes de engadir máis.</string>
- <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada Password Store con ela:</string>
+ <string name="oreo_autofill_warning_publisher_header">A autoría desta app cambiou desde que rexistraches a entrada HardenedPass con ela:</string>
<string name="oreo_autofill_warning_publisher_footer"><b>A aplicación actualmente instalada podería intentar roubar as túas credenciais pretendendo ser unha app de confianza.</b>\n\nIntenta desinstalar e reinstalar
a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid ou a tenda do fabricante do teu móbil.</string>
<string name="oreo_autofill_warning_publisher_install_time">Instalada: %1$s</string>
@@ -207,12 +197,11 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="oreo_autofill_password_fill_support">Completar contrasinais</string>
<string name="oreo_autofill_flaky_fill_support">Completa as credenciais (poderías ter que reiniciar o navegador de cando en vez)</string>
<string name="oreo_autofill_no_support">Sen soporte</string>
- <string name="oreo_autofill_enable_dialog_description">Password Store pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle Password Store da lista e confirma a solicitude premendo no OK.</string>
+ <string name="oreo_autofill_enable_dialog_description">HardenedPass pode ofrecerche completar formularios e incluso gardar contrasinais que escribes en apps e sitios web.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Para habilitar esta característica toca en OK e vaite ós axustes de autocompletado. Alí, escolle HardenedPass da lista e confirma a solicitude premendo no OK.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Soporte de autocompletado cos navegadores:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Non se pode abrir a chave privada ssh, comproba que existe tal ficheiro</string>
- <string name="new_password_title">Novo contrasinal</string>
<string name="clear_saved_passphrase_ssh">Baleirar a frase de paso gardada para a chave SSH local</string>
<string name="clear_saved_passphrase_https">Baleirar contrasinal HTTPS gardado</string>
<string name="git_operation_remember_passphrase">Lembrar frase de paso da chave</string>
@@ -226,9 +215,8 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="biometric_prompt_title">Petición biométrica</string>
<string name="biometric_auth_error_reason">Fallou a autenticación: %s</string>
<string name="pref_biometric_auth_title">Activar autenticación biométrica</string>
- <string name="pref_biometric_auth_summary">Ao activala, Password Store vaiche pedir a túa pegada dactilar ao iniciar a app</string>
+ <string name="pref_biometric_auth_summary">Ao activala, HardenedPass vaiche pedir a túa pegada dactilar ao iniciar a app</string>
<string name="pref_biometric_auth_summary_error">O hardware de pegada dixital non é accesible ou existente</string>
- <string name="pref_title_openkeystore_clear_keyid">Eliminar o ID lembrado da chave SSH en OpenKeystore</string>
<string name="your_public_key">A túa chave pública</string>
<string name="error_generate_ssh_key">Algo fallou ao intentar crear a chave-ssh</string>
<string name="pref_show_hidden_title">Mostar cartafoles ocultos</string>
@@ -236,7 +224,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="title_create_folder">Crear cartafol</string>
<string name="title_rename_folder">Renomear cartafol</string>
<string name="message_category_error_empty_field">O nome da categoría non pode estar baleiro</string>
- <string name="message_category_error_category_exists">Xa existe o nome da categoría</string>
<string name="message_error_destination_outside_repo">O destino debe estar dentro do repositorio</string>
<string name="message_rename_folder">Escribe o destino para %1$s</string>
<string name="button_create">Crear</string>
@@ -282,11 +269,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="invalid_gpg_id">Atopouse .gpg-id, pero contén un ID de chave, impresión dixital ou ID de usuaria non válidos</string>
<string name="short_gpg_id">Atopouse .gpg-id, pero contén un ID hex curto, ao que non damos soporte</string>
<string name="invalid_filename_text">O nome do ficheiro non pode conter \'/\', establece un directorio superior</string>
- <string name="directory_hint">Directorio</string>
- <string name="new_folder_set_gpg_key">Establece chave GPG para o directorio</string>
<!-- GitException messages -->
<string name="git_unknown_error">Fallo descoñecido</string>
- <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"axustes &gt; utilidades git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string>
+ <string name="git_pull_rebase_fail_error">Fallou a acción pull, estás nun head diferente. Utiliza \"Axustes &gt; Repositorio &gt; Configuración e Utilidades locais Git\", garda os cambios no remoto nunha nova rama e resolve o conflicto nun ordenador.</string>
<string name="git_pull_merge_fail_error">Fallou o merge, hai un conflito nos datos. TAREFA: engadir método de recuperación.</string>
<string name="git_push_nff_error">O push foi rexeitado polo remoto, executa pull antes de voltar a subilos de novo. Podes usar Sincronizar mellor que push/pull xa que inclú ambos.</string>
<string name="git_push_generic_error">O push foi rexeitado polo remoto, razón: %1$s</string>
@@ -296,16 +281,12 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="git_break_out_of_detached_success">Hai un conflito ó realizar rebase. A túa póla local %1$s fixo push a outra póla chamada %2$s\nUsa esta póla para resolver o conflito na túa computadora</string>
<string name="git_break_out_of_detached_unneeded">O repositorio non cambiou de base, non é preciso cambiar a outra póla</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">Hai que elexir unha chave GPG para continuar</string>
<string name="folder_creation_err_file_exists">Xa existe un ficheiro con ese nome</string>
<string name="folder_creation_err_folder_exists">Xa existe un cartafol con ese nome</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Imos alá</string>
- <string name="select_n_repository_type">Escolle \nTipo de repositorio</string>
<string name="select_repo_type_text">Escolle se queres crear un repositorio local ou clonar un remoto.</string>
<string name="clone_remote_repo">Clonar repositorio remoto</string>
<string name="create_local_repo">Crear repositorio local</string>
- <string name="select_gpg_key_title">Elixe\nChave\nGPG</string>
<string name="select_gpg_key_message">Elixe a chave GPG coa que queres iniciar a almacenaxe</string>
<string name="gpg_key_select">Elixe chave</string>
<!-- SSH port validation -->
@@ -326,11 +307,9 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
<string name="clear_saved_host_key_success">Eliminouse correctamente a chave gardada do host!</string>
<string name="otp_import_qr_code">Escanear código QR</string>
<string name="otp_import_from_file">Elixe unha imaxe</string>
- <string name="otp_import_manual_entry">Escribir manualmente</string>
<string name="otp_import_manual_hint_secret">Segredo</string>
<string name="otp_import_manual_hint_account">Conta</string>
<string name="place_shortcut_on_home_screen">Crear atallo na pantalla de inicio</string>
- <string name="password_list_fab_content_description">Crear novo contrasinal ou cartafol</string>
<string name="pgp_key_import_failed">Fallou a importación da chave PGP</string>
<string name="pgp_key_import_failed_replace_message">Atopouse unha chave existente con este ID, queres substituíla?</string>
<string name="pgp_key_import_succeeded">Chave PGP importada correctamente</string>
diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml
deleted file mode 100644
index f9a452aa..00000000
--- a/app/src/main/res/values-hdpi/dimens.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<resources>
- <dimen name="onboarding_icon_margin_top">50dp</dimen>
- <dimen name="onboarding_button_margin_top">70dp</dimen>
- <dimen name="onboarding_desc_margin_top">48dp</dimen>
-</resources>
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index dfb18d52..e7c05909 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -24,7 +24,6 @@
</plurals>
<string name="delete_directory_progress_text">Eliminazione…</string>
<string name="move">Sposta</string>
- <string name="edit">Modifica</string>
<string name="delete">Elimina</string>
<string name="password_exists_title">La password esiste già!</string>
<string name="password_exists_message">Questo sovrascriverà %1$s con %2$s.</string>
@@ -39,7 +38,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Copiato negli appunti</string>
<string name="file_toast_text">Inserisci un nome per il file</string>
- <string name="path_toast_text">Inserisci il percorso del file</string>
<string name="empty_toast_text">Non puoi usare una password vuota o dei contenuti extra vuoti</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Si è verificato un errore durante un\'operazione Git</string>
@@ -63,7 +61,6 @@
<string name="crypto_name_hint">Nome</string>
<string name="crypto_pass_label">Password</string>
<string name="crypto_extra_label">Contenuto extra</string>
- <string name="crypto_encrypt_username_label">Crittografa nome utente</string>
<string name="crypto_select">Seleziona</string>
<string name="crypto_save">Salva</string>
<string name="crypto_save_and_copy">Salva e Copia</string>
@@ -97,8 +94,6 @@
<string name="pref_recursive_filter_summary">Trova ricorsivamente le password della directory corrente.</string>
<string name="pref_sort_order_title">Ordine password</string>
<string name="pref_folder_first_sort_order">Prima le cartelle</string>
- <string name="pref_file_first_sort_order">Prima i file</string>
- <string name="pref_type_independent_sort_order">Indipendente dal tipo</string>
<string name="pref_recently_used_sort_order">Usato di recente</string>
<string name="pref_category_autofill_title">Compilazione automatica</string>
<string name="pref_autofill_enable_title">Attiva la compilazione automatica</string>
@@ -109,7 +104,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Genera Password</string>
<string name="pwgen_generate">Genera</string>
- <string name="pwgen_include">Includi</string>
<string name="pwgen_length">Lunghezza</string>
<string name="pwgen_numerals">Numeri</string>
<string name="pwgen_symbols">Simboli</string>
@@ -150,7 +144,6 @@
<string name="dialog_yes">Sì</string>
<string name="dialog_no">No</string>
<string name="dialog_cancel">Annulla</string>
- <string name="dialog_skip">Salta</string>
<string name="git_sync">Sincronizza repository</string>
<string name="git_pull">Pull dal remote</string>
<string name="git_push">Push sul remote</string>
@@ -158,10 +151,7 @@
<string name="git_log">Mostra registro dei commit</string>
<string name="show_password_pref_title">Mostra la password</string>
<string name="show_password_pref_summary">Controlla la visibilità delle password una volta decrittografate. Questo non disabilita la copia negli appunti.</string>
- <string name="pwd_generate_button">Genera</string>
- <string name="refresh_list">Aggiorna elenco</string>
<string name="send_plaintext_password_to">Invia password come testo semplice usando…</string>
- <string name="app_icon_hint">Icona app</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Seleziona la voce da compilare</string>
<string name="oreo_autofill_strict_domain_search">Ricerca resistente al phishing</string>
@@ -193,7 +183,6 @@
<string name="oreo_autofill_enable_dialog_installed_browsers">Supporto alla compilazione automatica nei browser installati:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Impossibile aprire la chiave privata SSH, controlla che il file esista</string>
- <string name="new_password_title">Nuova password</string>
<string name="clear_saved_passphrase_ssh">Cancella la frase segreta salvata per la chiave SSH locale</string>
<string name="clear_saved_passphrase_https">Cancella la password HTTPS salvata</string>
<string name="git_operation_remember_passphrase">Ricorda la frase segreta della chiave</string>
@@ -209,7 +198,6 @@
<string name="pref_biometric_auth_title">Abilita autenticazione biometrica</string>
<string name="pref_biometric_auth_summary">Se attiva, HardenedPass chiede l\'impronta digitale all\'avvio dell\'app</string>
<string name="pref_biometric_auth_summary_error">L\'hardware delle impronte digitali non è accessibile o mancante</string>
- <string name="pref_title_openkeystore_clear_keyid">Elimina l\'ID della Chiave SSH di OpenKeystore memorizzato</string>
<string name="your_public_key">La tua chiave pubblica</string>
<string name="error_generate_ssh_key">Errore durante la generazione della chiave SSH</string>
<string name="pref_show_hidden_title">Mostra tutti i file e cartelle</string>
@@ -217,7 +205,6 @@
<string name="title_create_folder">Crea cartella</string>
<string name="title_rename_folder">Rinomina cartella</string>
<string name="message_category_error_empty_field">Il nome della categoria non può essere vuoto</string>
- <string name="message_category_error_category_exists">Il nome della categoria esiste già</string>
<string name="message_error_destination_outside_repo">La destinazione deve essere nel repository</string>
<string name="message_rename_folder">Inserisci la destinazione per %1$s</string>
<string name="button_create">Crea</string>
@@ -260,11 +247,9 @@
<string name="otp_import_failure_generic">Impossibile importare la configurazione di TOTP</string>
<string name="exporting_passwords">Esportazione delle password…</string>
<string name="invalid_filename_text">Il nome del file non deve contenere \'/\', imposta la directory qui sopra</string>
- <string name="directory_hint">Directory</string>
- <string name="new_folder_set_gpg_key">Imposta la chiave GPG per la directory</string>
<!-- GitException messages -->
<string name="git_unknown_error">Errore sconosciuto</string>
- <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Utilità Git\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string>
+ <string name="git_pull_rebase_fail_error">Pull non riuscito, sei in detached HEAD. Da \"Impostazioni > Repository > Configurazione Git locale e utilità\" esegui il push delle modifiche sul remote in un nuovo branch e risolvi il conflitto dal computer.</string>
<string name="git_push_nff_error">Push rifiutato dal remote: esegui un pull prima di riprovare. Puoi usare Sincronizza, che esegue sia il pull sia il push</string>
<string name="git_push_generic_error">Push rifiutato dal remote, motivo: %1$s</string>
<string name="git_push_other_error">Il remote ha rifiutato un push non fast-forward. Controlla la variabile receive.denyNonFastForwards nella configurazione del repository di destinazione.</string>
@@ -276,12 +261,9 @@
<string name="folder_creation_err_file_exists">Un file con quel nome esiste già</string>
<string name="folder_creation_err_folder_exists">Una cartella con quel nome esiste già</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Andiamo</string>
- <string name="select_n_repository_type">Seleziona \nTipo di repository</string>
<string name="select_repo_type_text">Seleziona se vuoi creare un repo locale o clonarne uno remoto.</string>
<string name="clone_remote_repo">Clona repo remoto</string>
<string name="create_local_repo">Crea repo locale</string>
- <string name="select_gpg_key_title">Seleziona\nChiave\nGPG</string>
<string name="select_gpg_key_message">Seleziona una chiave GPG con cui inizializzare il tuo archivio</string>
<string name="gpg_key_select">Seleziona chiave</string>
<!-- SSH port validation -->
@@ -298,7 +280,6 @@
<string name="clear_saved_host_key">Cancella la chiave host salvata</string>
<string name="clear_saved_host_key_success">Chiave host cancellata con successo!</string>
<string name="otp_import_from_file">Scegli un\'immagine</string>
- <string name="password_list_fab_content_description">Crea nuova password o cartella</string>
<string name="git_commit_gpg_id">Inizializza gli ID GPG in %1$s.</string>
<string name="crypto_username_hint">Nome utente</string>
<string name="otp_label_format">OTP (scade tra %ds)</string>
@@ -321,18 +302,15 @@
<string name="pref_passphrase_cache_auto_clear_authenticate_disable">Autenticati per disattivare la cancellazione automatica</string>
<string name="pref_passphrase_cache_auto_clear_title">Cancella automaticamente le frasi segrete memorizzate</string>
<string name="pref_passphrase_cache_auto_clear_summary">Cancella le frasi segrete memorizzate allo spegnimento dello schermo</string>
- <string name="pref_biometric_auth_summary_disabled_platform">Funzione non disponibile a causa di un bug della piattaforma</string>
<string name="password_creation_encrypt_fail_message">Impossibile cifrare la password, non è stato salvato nulla: %1$s</string>
<string name="otp_import_failure_no_selection">Nessuna immagine selezionata</string>
<string name="invalid_gpg_id">Trovato .gpg-id, ma contiene un ID chiave, un\'impronta o un ID utente non valido</string>
<string name="short_gpg_id">Trovato .gpg-id, ma contiene un ID esadecimale corto, che non è supportato</string>
<string name="git_pull_merge_fail_error">Merge non riuscito, il repository è in conflitto.</string>
- <string name="gpg_key_select_mandatory">Per proseguire devi selezionare una chiave GPG</string>
<string name="git_scheme_disallowed_title">Il protocollo git:// è sconsigliato</string>
<string name="git_scheme_disallowed_message">Il protocollo git di git-daemon non cifra il trasporto e non è adatto a operazioni sicure.</string>
<string name="activity_proxy_label">Impostazioni proxy</string>
<string name="otp_import_qr_code">Scansiona codice QR</string>
- <string name="otp_import_manual_entry">Inserisci manualmente</string>
<string name="otp_import_manual_hint_secret">Segreto</string>
<string name="otp_import_manual_hint_account">Account</string>
<string name="place_shortcut_on_home_screen">Aggiungi scorciatoia alla schermata Home</string>
@@ -351,4 +329,56 @@
<string name="no_keys_imported_dialog_message">Nell\'app non è ancora stata importata nessuna chiave PGP: premi il pulsante qui sotto per scegliere un file di chiave</string>
<string name="biometric_prompt_title_gpg_passphrase_cache">Sblocca le frasi segrete memorizzate</string>
<string name="clear_cached_password_on_screen_off">Cancella la password memorizzata allo spegnimento dello schermo</string>
+
+ <!-- Compose password list -->
+ <string name="list_new_entry">Nuova voce</string>
+ <string name="list_store_root">Archivio password</string>
+ <string name="list_more_options">Altre opzioni</string>
+ <string name="list_clear_search">Cancella ricerca</string>
+
+ <!-- Compose entry view -->
+ <string name="entry_back">Indietro</string>
+ <string name="entry_show_password">Mostra password</string>
+ <string name="entry_hide_password">Nascondi password</string>
+ <string name="entry_copy_name">Copia nome</string>
+
+ <!-- Compose folder picker -->
+ <string name="folder_picker_title">Scegli una cartella</string>
+ <string name="folder_creation_err_failed">Impossibile creare la cartella</string>
+ <string name="folder_picker_new">Nuova cartella</string>
+
+ <!-- Compose editor -->
+ <string name="editor_new_title">Nuova voce</string>
+ <string name="editor_edit_title">Modifica voce</string>
+ <string name="editor_close">Chiudi</string>
+ <string name="editor_folder">Cartella</string>
+ <string name="editor_change_folder">Cambia</string>
+ <string name="editor_otp_qr">OTP da QR</string>
+ <string name="editor_otp_manual">OTP manuale</string>
+ <string name="generator_random">Casuale</string>
+ <string name="generator_use">Usa</string>
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">Ancora nessuna chiave SSH</string>
+ <string name="ssh_key_state_imported">Chiave privata importata</string>
+ <string name="ssh_key_state_generated">Generata su questo dispositivo</string>
+ <string name="git_clone_progress">Clonazione…</string>
+ <string name="connection_mode_none">Nessuna</string>
+ <string name="ssh_key_manage">Gestisci</string>
+ <string name="ssh_key_public_hint">Fornisci questa chiave pubblica al tuo server Git.</string>
+ <string name="pgp_key_needed_message">Importa la chiave privata per cui è cifrato questo archivio, così le sue voci si possono leggere e salvare.</string>
+ <string name="pgp_key_needed_recipients">Destinatari dell\'archivio</string>
+ <string name="store_create_failed">Impossibile creare l\'archivio: %1$s</string>
+ <string name="store_not_empty">la cartella contiene già un archivio</string>
+ <string name="git_clone_delete_failed">Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita.</string>
+ <string name="pgp_key_import_too_large">Il file supera 1 MiB: non è una chiave PGP.</string>
+ <string name="pgp_key_import_downgrade">Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo.</string>
+
+ <!-- Compose settings -->
+ <string name="store_delete_failed">Impossibile eliminare tutto il contenuto della cartella dell\'archivio.</string>
+ <string name="clear_saved_secret_message">Verrà chiesto di nuovo alla prossima operazione Git.</string>
+ <string name="invalid_proxy_port">Inserisci una porta da 1 a 65535, oppure niente</string>
+ <string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string>
+ <string name="git_tool_done">%1$s: completato</string>
+ <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string>
</resources>
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index 432b6fbe..add3de87 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -22,7 +22,6 @@
</plurals>
<string name="delete_directory_progress_text">삭제중…</string>
<string name="move">이동</string>
- <string name="edit">수정</string>
<string name="delete">삭제</string>
<string name="password_exists_title">이미 추가된 비밀번호입니다!</string>
<string name="password_exists_message">%1$s 내용이 %2$s 로 덮어씌워집니다.</string>
@@ -38,7 +37,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">클립보드로 복사됨</string>
<string name="file_toast_text">파일 이름이 필요합니다</string>
- <string name="path_toast_text">파일 경로가 필요합니다</string>
<string name="empty_toast_text">비어있는 비밀번호나 추가 정보를 사용할 수 없습니다</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Git 동작 중 문제가 발생하였습니다</string>
@@ -62,7 +60,6 @@
<string name="crypto_name_hint">이름</string>
<string name="crypto_pass_label">비밀번호</string>
<string name="crypto_extra_label">추가 내용</string>
- <string name="crypto_encrypt_username_label">암호화 계정 이름</string>
<string name="crypto_select">선택</string>
<string name="crypto_save">저장</string>
<string name="crypto_save_and_copy">저장 및 복사</string>
@@ -98,8 +95,6 @@
<string name="pref_directory_structure">암허 파일 정리</string>
<string name="pref_sort_order_title">암호 정렬 순서</string>
<string name="pref_folder_first_sort_order">폴더 우선</string>
- <string name="pref_file_first_sort_order">파일 우선</string>
- <string name="pref_type_independent_sort_order">타입순</string>
<string name="pref_recently_used_sort_order">최신순</string>
<string name="pref_category_autofill_title">자동완성</string>
<string name="pref_autofill_enable_title">자동완성 활성화</string>
@@ -122,7 +117,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">암호 생성</string>
<string name="pwgen_generate">생성</string>
- <string name="pwgen_include">포함</string>
<string name="pwgen_length">길이</string>
<string name="pwgen_numerals">숫자만</string>
<string name="pwgen_symbols">특수문자</string>
@@ -163,7 +157,6 @@
<string name="dialog_yes">예</string>
<string name="dialog_no">아니오</string>
<string name="dialog_cancel">취소</string>
- <string name="dialog_skip">건너뛰기</string>
<string name="git_sync">저장소 동기화</string>
<string name="git_pull">원격에서 pull</string>
<string name="git_push">원격으로 푸시</string>
@@ -171,10 +164,7 @@
<string name="git_log">커밋 로그 보기</string>
<string name="show_password_pref_title">비밀번호 표시하기</string>
<string name="show_password_pref_summary">암호가 해독된 후 비밀번호의 공개 여부를 제어합니다. 그렇다고 해서 클립보드 복사가 비활성화되지는 않습니다.</string>
- <string name="pwd_generate_button">생성</string>
- <string name="refresh_list">목록 새로 고침</string>
<string name="send_plaintext_password_to">비밀번호를 일반 텍스트로 보내는 중…</string>
- <string name="app_icon_hint">앱 아이콘</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">입력할 항목 선택</string>
<string name="oreo_autofill_strict_domain_search">피싱 방지 검색</string>
@@ -201,12 +191,11 @@
<string name="oreo_autofill_password_fill_support">암호 입력</string>
<string name="oreo_autofill_flaky_fill_support">비밀번호 채우기 (때때로 브라우저를 다시 시작해야 할 수 있음)</string>
<string name="oreo_autofill_no_support">지원하지 않음</string>
- <string name="oreo_autofill_enable_dialog_description">Password Store는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string>
+ <string name="oreo_autofill_enable_dialog_description">HardenedPass는 로그인 양식을 작성하고 앱이나 웹사이트에 입력한 자격 증명을 저장하는 기능도 제공합니다.</string>
<string name="oreo_autofill_enable_dialog_instructions">이 기능을 사용하려면 확인을 탭하여 자동 완성 설정으로 이동합니다. 목록에서 암호 저장을 선택하고 확인으로 확인 메시지를 확인합니다.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">자동 완성 지원 하는 설치된 브라우저:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">SSH 개인 키를 열 수 없습니다. 파일이 존재하는지 확인하세요</string>
- <string name="new_password_title">새 암호</string>
<string name="clear_saved_passphrase_ssh">로컬 SSH 키의 저장된 비밀번호 지우기</string>
<string name="clear_saved_passphrase_https">저장된 HTTPS 비밀번호 지우기</string>
<string name="git_operation_remember_passphrase">키 암호 기억하기</string>
@@ -222,7 +211,6 @@
<string name="pref_biometric_auth_title">생체 인증 사용</string>
<string name="pref_biometric_auth_summary">비밀번호 저장소를 활성화하면 앱을 시작할 때 지문을 입력하라는 메시지가 표시됩니다</string>
<string name="pref_biometric_auth_summary_error">지문 하드웨어에 액세스할 수 없거나 누락된 경우</string>
- <string name="pref_title_openkeystore_clear_keyid">저장된 OpenKeyStore SSH 키 ID 지우기</string>
<string name="your_public_key">공개 키</string>
<string name="error_generate_ssh_key">Ssh 키를 생성하는 동안 오류가 발생했습니다.</string>
<string name="pref_show_hidden_title">모든 파일 및 폴더 표시</string>
@@ -230,7 +218,6 @@
<string name="title_create_folder">폴더 생성</string>
<string name="title_rename_folder">폴더 이름 변경</string>
<string name="message_category_error_empty_field">카테고리 이름은 비워 둘 수 없습니다</string>
- <string name="message_category_error_category_exists">카테고리가 이미 있습니다</string>
<string name="message_error_destination_outside_repo">대상은 리포지토리 내에 있어야 합니다</string>
<string name="message_rename_folder">%1$s의 목적지 입력</string>
<string name="button_create">생성</string>
@@ -278,11 +265,9 @@ username@example.com:…</string>
<string name="invalid_gpg_id">.gpg-id를 찾았지만 잘못된 키 ID, 지문 또는 사용자 ID가 포함되어 있습니다</string>
<string name="short_gpg_id">.gpg-id를 찾았지만, 지원되지 않는 짧은 16진수 ID가 포함되어 있습니다.</string>
<string name="invalid_filename_text">파일 이름에 \'/\'가 포함되어서는 안됩니다. 위의 디렉터리를 설정하세요</string>
- <string name="directory_hint">디렉토리</string>
- <string name="new_folder_set_gpg_key">디렉토리의 GPG 키 설정</string>
<!-- GitException messages -->
<string name="git_unknown_error">알 수 없는 오류</string>
- <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 &gt; git 기능\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string>
+ <string name="git_pull_rebase_fail_error">풀이 실패하여 head가 분리된 상태입니다. \"설정 &gt; 저장소 &gt; 로컬 Git 설정 &amp; 툴\"을 사용하여 원격 저장소에 변경 내용을 새 브랜치에 저장하고 컴퓨터에서 충돌을 해결하세요.</string>
<string name="git_pull_merge_fail_error">병합에 실패하여 충돌 상태 입니다. 할 일: 복구 방법을 추가하세요.</string>
<string name="git_push_nff_error">원격에서 푸시가 거부된 경우 다시 푸시하기 전에 풀을 실행합니다. 풀/푸시 대신 동기화를 사용하면 두 가지를 모두 구현할 수 있습니다</string>
<string name="git_push_generic_error">원격에서 푸시를 거부했습니다. 원인: %1$s</string>
@@ -292,16 +277,12 @@ username@example.com:…</string>
<string name="git_break_out_of_detached_success">리베이스하는 동안 충돌이 발생했습니다. 로컬 %1$s 브랜치가 %2$s이라는 다른 브랜치로 푸시되었습니다.\n이 브랜치를 사용하여 컴퓨터에서 충돌을 해결하세요.</string>
<string name="git_break_out_of_detached_unneeded">이 저장소는 리베이스가 아니므로 다른 브랜치로 푸시할 필요가 없습니다.</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">계속하려면 GPG 키를 선택해야 합니다</string>
<string name="folder_creation_err_file_exists">동일한 이름의 파일이 이미 있습니다</string>
<string name="folder_creation_err_folder_exists">이 이름의 폴더가 이미 존재합니다</string>
<!-- Onboarding flow -->
- <string name="let_s_go">시작합니다!</string>
- <string name="select_n_repository_type">저장소 유형 \n선택</string>
<string name="select_repo_type_text">로컬 리포지토리를 만들 것인지, 원격 리포지토리를 복제할 것인지 선택합니다.</string>
<string name="clone_remote_repo">원격 저장소 복제</string>
<string name="create_local_repo">로컬 저장소 생성</string>
- <string name="select_gpg_key_title">GPG 키\n선택</string>
<string name="select_gpg_key_message">스토어를 초기화할 GPG 키를 선택합니다</string>
<string name="gpg_key_select">키 선택</string>
<!-- SSH port validation -->
@@ -322,11 +303,9 @@ username@example.com:…</string>
<string name="clear_saved_host_key_success">저장된 호스트 키를 성공적으로 지웠습니다!</string>
<string name="otp_import_qr_code">QR코드 스캔</string>
<string name="otp_import_from_file">이미지 선택</string>
- <string name="otp_import_manual_entry">직접 입력</string>
<string name="otp_import_manual_hint_secret">비밀</string>
<string name="otp_import_manual_hint_account">계정</string>
<string name="place_shortcut_on_home_screen">홈 화면에 추가</string>
- <string name="password_list_fab_content_description">새 비밀번호 또는 폴더 만들기</string>
<string name="pgp_key_import_failed">PGP 키를 가져오지 못했습니다</string>
<string name="pgp_key_import_failed_replace_message">이 ID를 가진 기존 키가 발견되었습니다. 교체하시겠습니까?</string>
<string name="pgp_key_import_succeeded">PGP 키를 성공적으로 가져왔습니다</string>
diff --git a/app/src/main/res/values-night/colors_material3.xml b/app/src/main/res/values-night/colors_material3.xml
index 22e4e8f0..c26d7ec6 100644
--- a/app/src/main/res/values-night/colors_material3.xml
+++ b/app/src/main/res/values-night/colors_material3.xml
@@ -4,5 +4,4 @@
-->
<resources>
- <color name="list_multiselect_background">#66eeeeee</color>
</resources>
diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml
index a915f67d..4d41b2f6 100644
--- a/app/src/main/res/values-pl-rPL/strings.xml
+++ b/app/src/main/res/values-pl-rPL/strings.xml
@@ -23,7 +23,6 @@
<string name="creation_dialog_text">Zanim dodasz hasło lub rozpoczniesz synchronizację, stwórz albo sklonuj poniżej nowe repozytorium.</string>
<string name="delete_directory_progress_text">Usuwanie…</string>
<string name="move">Przenieś</string>
- <string name="edit">Edytuj</string>
<string name="delete">Usuń</string>
<string name="password_exists_title">Hasło już istnieje!</string>
<string name="password_exists_message">%1$s zostanie nadpisane przez %2$s.</string>
@@ -38,7 +37,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Skopiowano do schowka</string>
<string name="file_toast_text">Podaj nazwę pliku</string>
- <string name="path_toast_text">Podaj ścieżkę do pliku</string>
<string name="empty_toast_text">Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Wystąpił błąd podczas operacji Git</string>
@@ -62,7 +60,6 @@
<string name="crypto_name_hint">Nazwa</string>
<string name="crypto_pass_label">Hasło</string>
<string name="crypto_extra_label">Treść dodatkowa</string>
- <string name="crypto_encrypt_username_label">Szyfruj nazwę użytkownika</string>
<string name="crypto_select">Wybierz</string>
<string name="crypto_save">Zapisz</string>
<string name="crypto_save_and_copy">Zapisz i skopiuj</string>
@@ -97,8 +94,6 @@
<string name="pref_directory_structure">Organizacja plików haseł</string>
<string name="pref_sort_order_title">Kolejność sortowania haseł</string>
<string name="pref_folder_first_sort_order">Najpierw foldery</string>
- <string name="pref_file_first_sort_order">Najpierw pliki</string>
- <string name="pref_type_independent_sort_order">Niezależnie od typu</string>
<string name="pref_recently_used_sort_order">Ostatnio używane</string>
<string name="pref_category_autofill_title">Autouzupełnianie</string>
<string name="pref_autofill_enable_title">Włącz autouzupełnianie</string>
@@ -123,7 +118,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Generuj hasło</string>
<string name="pwgen_generate">Wygeneruj</string>
- <string name="pwgen_include">Załącz</string>
<string name="pwgen_length">Długość</string>
<string name="pwgen_numerals">Cyfry</string>
<string name="pwgen_symbols">Symbole</string>
@@ -164,7 +158,6 @@
<string name="dialog_yes">Tak</string>
<string name="dialog_no">Nie</string>
<string name="dialog_cancel">Anuluj</string>
- <string name="dialog_skip">Pomiń</string>
<string name="git_sync">Synchronizuj repozytorium</string>
<string name="git_pull">Pobierz ze zdalnego repozytorium</string>
<string name="git_push">Wypchnij do zdalnego repozytorium</string>
@@ -172,10 +165,7 @@
<string name="git_log">Pokaż dziennik commitów</string>
<string name="show_password_pref_title">Pokaż hasło</string>
<string name="show_password_pref_summary">Kontroluj widoczność haseł po odszyfrowaniu. To nie wyłącza kopiowania do schowka.</string>
- <string name="pwd_generate_button">Generuj</string>
- <string name="refresh_list">Odśwież listę</string>
<string name="send_plaintext_password_to">Wyślij niezaszyfrowane hasło, używając…</string>
- <string name="app_icon_hint">Ikona aplikacji</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Wybierz wpis do wypełnienia</string>
<string name="oreo_autofill_strict_domain_search">Wyszukiwanie odporne na wyłudzenie</string>
@@ -200,12 +190,11 @@
<string name="oreo_autofill_password_fill_support">Wypełnij hasła</string>
<string name="oreo_autofill_flaky_fill_support">Wypełnij hasła (może wymagać ponownego uruchomienia przeglądarki od czasu do czasu)</string>
<string name="oreo_autofill_no_support">Brak wsparcia</string>
- <string name="oreo_autofill_enable_dialog_description">Password Store może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz Password Store z listy i potwierdź przyciskiem OK.</string>
+ <string name="oreo_autofill_enable_dialog_description">HardenedPass może zaoferować wypełnianie formularzy logowania, a nawet zapisywanie danych logowania, które wpisujesz w aplikacjach lub na stronach internetowych.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Aby włączyć tę funkcję, naciśnij przycisk OK, aby przejść do ustawień autouzupełniania. Następnie wybierz HardenedPass z listy i potwierdź przyciskiem OK.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Obsługa autouzupełniania w zainstalowanych przeglądarkach:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Nie można otworzyć klucza prywatnego ssh, sprawdź czy plik istnieje</string>
- <string name="new_password_title">Nowe hasło</string>
<string name="clear_saved_passphrase_ssh">Wyczyść zapisane hasło dla lokalnego klucza SSH</string>
<string name="clear_saved_passphrase_https">Wyczyść zapisane hasło HTTPS</string>
<string name="git_operation_remember_passphrase">Zapamiętaj hasło klucza</string>
@@ -219,9 +208,8 @@
<string name="biometric_prompt_title">Weryfikacja biometryczna</string>
<string name="biometric_auth_error_reason">Błąd uwierzytelnienia: %s</string>
<string name="pref_biometric_auth_title">Włącz uwierzytelnianie biometryczne</string>
- <string name="pref_biometric_auth_summary">Gdy włączone, Password Store zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string>
+ <string name="pref_biometric_auth_summary">Gdy włączone, HardenedPass zapyta Cię o odcisk palca podczas uruchamiania aplikacji</string>
<string name="pref_biometric_auth_summary_error">Czytnik linii papilarnych jest niedostępny</string>
- <string name="pref_title_openkeystore_clear_keyid">Wyczyść zapamiętany identyfikator klucza SSH OpenKeystore</string>
<string name="your_public_key">Twój klucz publiczny</string>
<string name="error_generate_ssh_key">Błąd podczas generowania klucza SSH</string>
<string name="pref_show_hidden_title">Pokaż ukryte pliki i foldery</string>
@@ -229,7 +217,6 @@
<string name="title_create_folder">Utwórz katalog</string>
<string name="title_rename_folder">Zmień nazwę katalogu</string>
<string name="message_category_error_empty_field">Nazwa kategorii nie może być pusta</string>
- <string name="message_category_error_category_exists">Kategoria już istnieje</string>
<string name="message_error_destination_outside_repo">Miejsce docelowe musi znajdować się w repozytorium</string>
<string name="message_rename_folder">Wprowadź miejsce docelowe dla %1$s</string>
<string name="button_create">Utwórz</string>
@@ -272,11 +259,9 @@
<string name="invalid_gpg_id">Znaleziono .gpg-id, ale zawiera nieprawidłowy identyfikator klucza, odcisk palca lub identyfikator użytkownika</string>
<string name="short_gpg_id">Znaleziono .gpg-id, ale zawarty w nim skrócony identyfikator nie jest obsługiwany</string>
<string name="invalid_filename_text">Nazwa pliku nie może zawierać \'/\', ustaw katalog powyżej</string>
- <string name="directory_hint">Katalog</string>
- <string name="new_folder_set_gpg_key">Ustaw klucz GPG dla katalogu</string>
<!-- GitException messages -->
<string name="git_unknown_error">Nieznany błąd</string>
- <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia &gt; narzędzia Git\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string>
+ <string name="git_pull_rebase_fail_error">Zaciąganie zmian nie powiodło się, nie jesteś na żadnej gałęzi (detached head). Używając \"Ustawienia &gt; Repozytorium &gt; Konfiguracja lokalnego repo Git &amp; narzędzia\", wyślij lokalne zmiany do zdalnego repozytorium na nowej gałęzi i rozwiąż konflikt na komputerze.</string>
<string name="git_pull_merge_fail_error">Scalanie nie powiodło się, jesteś w stanie konfliktu.</string>
<string name="git_push_nff_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium. Zaciągnij zmiany przed ponownym wysłaniem. Możesz użyć synchronizacji zamiast pull/push ponieważ zawiera obu te kroki</string>
<string name="git_push_generic_error">Operacja wypchnięcia zmian została odrzucona przez zdalne repozytorium, powód: %1$s</string>
@@ -289,12 +274,9 @@
<string name="folder_creation_err_file_exists">Plik o tej nazwie już istnieje</string>
<string name="folder_creation_err_folder_exists">Folder o tej nazwie już istnieje</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Zaczynajmy</string>
- <string name="select_n_repository_type">Wybierz \ntyp repozytorium</string>
<string name="select_repo_type_text">Zaznacz, czy chcesz utworzyć lokalne repozytorium, czy sklonować zdalne repozytorium.</string>
<string name="clone_remote_repo">Klonuj zdalne repozytorium</string>
<string name="create_local_repo">Utwórz lokalne repozytorium</string>
- <string name="select_gpg_key_title">Wybierz\nklucz GPG</string>
<string name="select_gpg_key_message">Wybierz klucz GPG, którym zainicjować swoje repozytorium</string>
<string name="gpg_key_select">Wybierz klucz</string>
<!-- SSH port validation -->
@@ -314,11 +296,9 @@
<string name="clear_saved_host_key_success">Pomyślnie wyczyszczono zapisany klucz hosta!</string>
<string name="otp_import_qr_code">Skanuj kod QR</string>
<string name="otp_import_from_file">Wybierz obraz</string>
- <string name="otp_import_manual_entry">Wprowadź ręcznie</string>
<string name="otp_import_manual_hint_secret">Sekret</string>
<string name="otp_import_manual_hint_account">Konto</string>
<string name="place_shortcut_on_home_screen">Utwórz skrót na pulpicie</string>
- <string name="password_list_fab_content_description">Utwórz nowe hasło lub folder</string>
<string name="pgp_key_import_failed">Nie udało się zaimportować klucza PGP</string>
<string name="pgp_key_import_failed_replace_message">Znaleziono istniejący klucz z tym identyfikatorem, czy chcesz go zastąpić?</string>
<string name="pgp_key_import_succeeded">Pomyślnie zaimportowano klucz PGP</string>
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 1d95286d..14e862c5 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -24,7 +24,6 @@
</plurals>
<string name="delete_directory_progress_text">Excluindo…</string>
<string name="move">Mover</string>
- <string name="edit">Editar</string>
<string name="delete">Excluir</string>
<string name="password_exists_title">Senha já existe!</string>
<string name="password_exists_message">Isso irá sobrescrever %1$s com %2$s.</string>
@@ -40,7 +39,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Copiado para a área de transferência</string>
<string name="file_toast_text">Por favor, informe um nome de arquivo</string>
- <string name="path_toast_text">Por favor, forneça o caminho do arquivo</string>
<string name="empty_toast_text">Você não pode usar uma senha vazia ou conteúdo extra vazio</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Ocorreu um erro durante uma operação do Git</string>
@@ -64,7 +62,6 @@
<string name="crypto_name_hint">Nome</string>
<string name="crypto_pass_label">Senha</string>
<string name="crypto_extra_label">Conteúdo extra</string>
- <string name="crypto_encrypt_username_label">Criptografar nome de usuário</string>
<string name="crypto_select">Selecionar</string>
<string name="crypto_save">Salvar</string>
<string name="crypto_save_and_copy">Salvar e Copiar</string>
@@ -99,8 +96,6 @@
<string name="pref_recursive_filter_summary">Encontrar senhas do diretório corrente recursivamente.</string>
<string name="pref_sort_order_title">Ordenação da Senha</string>
<string name="pref_folder_first_sort_order">Pastas primeiro</string>
- <string name="pref_file_first_sort_order">Arquivos primeiro</string>
- <string name="pref_type_independent_sort_order">Tipo independente</string>
<string name="pref_recently_used_sort_order">Usado recentemente</string>
<string name="pref_category_autofill_title">Preenchimento Automático</string>
<string name="pref_autofill_enable_title">Ativar preenchimento automático</string>
@@ -126,7 +121,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Gerar Senha</string>
<string name="pwgen_generate">Gerar</string>
- <string name="pwgen_include">Incluir</string>
<string name="pwgen_length">Tamanho</string>
<string name="pwgen_numerals">Numerais</string>
<string name="pwgen_symbols">Símbolos</string>
@@ -167,7 +161,6 @@
<string name="dialog_yes">Sim</string>
<string name="dialog_no">Não</string>
<string name="dialog_cancel">Cancelar</string>
- <string name="dialog_skip">Pular</string>
<string name="git_sync">Sincronizar repositório</string>
<string name="git_pull">Pull do remoto</string>
<string name="git_push">Push do remoto</string>
@@ -175,10 +168,7 @@
<string name="git_log">Mostrar log de commit</string>
<string name="show_password_pref_title">Mostrar a senha</string>
<string name="show_password_pref_summary">Controle a visibilidade das senhas quando descriptografadas. Isso não desativa a cópia para a área de transferência.</string>
- <string name="pwd_generate_button">Gerar</string>
- <string name="refresh_list">Atualizar lista</string>
<string name="send_plaintext_password_to">Enviar senha como texto simples usando…</string>
- <string name="app_icon_hint">Ícone do aplicativo</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Selecione a entrada para preencher</string>
<string name="oreo_autofill_strict_domain_search">Busca resistente a phishing</string>
@@ -191,7 +181,7 @@
<string name="oreo_autofill_save_passwords_dont_match">As senhas não coincidem</string>
<string name="oreo_autofill_generate_password">Gerar senha…</string>
<string name="oreo_autofill_max_matches_reached">Número máximo de correspondências (%1$d) atingidas; limpar correspondências antes de adicionar novas.</string>
- <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no Password Store:</string>
+ <string name="oreo_autofill_warning_publisher_header">O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no HardenedPass:</string>
<string name="oreo_autofill_warning_publisher_footer"><b>O aplicativo atualmente instalado pode estar tentando roubar suas credenciais fingindo ser um aplicativo confiável.</b>\n\nTente desinstalar e reinstalar o aplicativo de uma fonte confiável, como a Play Store, Amazon Appstore, F-Droid ou a loja do fabricante do seu telefone.</string>
<string name="oreo_autofill_warning_publisher_install_time">Instalado: %1$s</string>
<string name="oreo_autofill_warning_publisher_advanced_info_button">Informações avançadas</string>
@@ -205,12 +195,11 @@
<string name="oreo_autofill_password_fill_support">Preencher as senhas</string>
<string name="oreo_autofill_flaky_fill_support">Preencher senhas (pode ser necessário reiniciar o navegador de vez em quando)</string>
<string name="oreo_autofill_no_support">Sem suporte</string>
- <string name="oreo_autofill_enable_dialog_description">O Password Store pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione Password Store na lista e confirme na tela confirmação com OK.</string>
+ <string name="oreo_autofill_enable_dialog_description">O HardenedPass pode oferecer para preencher formulários de login e até mesmo salvar credenciais inseridas em aplicativos ou em sites.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Para ativar esse recurso, toque em OK para ir para as configurações de preenchimento automático. Lá, selecione HardenedPass na lista e confirme na tela confirmação com OK.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Suporte ao preenchimento automático com navegadores instalados:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Não foi possível abrir a chave privada ssh, por favor verifique se o arquivo existe</string>
- <string name="new_password_title">Nova senha</string>
<string name="clear_saved_passphrase_ssh">Limpar a frase secreta salva para chave SSH local</string>
<string name="clear_saved_passphrase_https">Limpar senha HTTPS salva</string>
<string name="git_operation_remember_passphrase">Lembrar senha da chave</string>
@@ -224,9 +213,8 @@
<string name="biometric_prompt_title">Confirmação Biométrica</string>
<string name="biometric_auth_error_reason">Falha de autenticação: %s</string>
<string name="pref_biometric_auth_title">Ativar autenticação biométrica</string>
- <string name="pref_biometric_auth_summary">Quando ativado, o Password Store irá pedir a sua impressão digital ao iniciar o aplicativo</string>
+ <string name="pref_biometric_auth_summary">Quando ativado, o HardenedPass irá pedir a sua impressão digital ao iniciar o aplicativo</string>
<string name="pref_biometric_auth_summary_error">Hardware de impressão digital não acessível ou ausente</string>
- <string name="pref_title_openkeystore_clear_keyid">Limpar ID de chave SSH lembrada do OpenKeystore</string>
<string name="your_public_key">Sua chave pública</string>
<string name="error_generate_ssh_key">Erro ao tentar gerar a chave SSH</string>
<string name="pref_show_hidden_title">Mostrar todos os arquivos e pastas</string>
@@ -234,7 +222,6 @@
<string name="title_create_folder">Criar pasta</string>
<string name="title_rename_folder">Renomear pasta</string>
<string name="message_category_error_empty_field">O nome da categoria não pode ser vazio</string>
- <string name="message_category_error_category_exists">Nome da categoria já existe</string>
<string name="message_error_destination_outside_repo">O destino deve estar dentro do repositório</string>
<string name="message_rename_folder">Insira o destino para %1$s</string>
<string name="button_create">Criar</string>
@@ -280,11 +267,9 @@
<string name="invalid_gpg_id">Encontrado .gpg-id, mas contém um ID de chave, fingerprint ou ID de usuário inválidos</string>
<string name="short_gpg_id">Encontrado .gpg-id, mas contém uma ID hexadecimal curta, que não é suportada</string>
<string name="invalid_filename_text">Nome do arquivo não deve conter \'/\', defina o diretório acima</string>
- <string name="directory_hint">Diretório</string>
- <string name="new_folder_set_gpg_key">Definir chave GPG para diretório</string>
<!-- GitException messages -->
<string name="git_unknown_error">Erro desconhecido</string>
- <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"configurações &gt; utils\" do git, salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string>
+ <string name="git_pull_rebase_fail_error">O pull falhou, você está em uma Head avulsa. Usando \"Configurações &gt; Repositório &gt; Configuração local do Git &amp; utilitários\", salve suas alterações no remoto em uma nova branch e resolva o conflito no seu computador.</string>
<string name="git_pull_merge_fail_error">Falha ao realizar o merge, você está em estado de conflito.</string>
<string name="git_push_nff_error">Push rejeitado pelo remoto, execute o pull antes de fazer push novamente. Você pode usar Sincronização em vez de pull/push conforme implementa ambos</string>
<string name="git_push_generic_error">Push rejeitado pelo remoto, razão: %1$s</string>
@@ -294,16 +279,12 @@
<string name="git_break_out_of_detached_success">Houve um conflito ao executar o rebase. Houve um push de branch %1$s local para outra branch chamada %2$s\n Use esta branch para resolver o conflito no seu computador</string>
<string name="git_break_out_of_detached_unneeded">O repositório não está sendo rebased, não há necessidade de fazer push para outro branch</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">A seleção de uma chave GPG é necessária para prosseguir</string>
<string name="folder_creation_err_file_exists">Já existe um arquivo com esse nome</string>
<string name="folder_creation_err_folder_exists">Já existe uma pasta com esse nome</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Vamos lá</string>
- <string name="select_n_repository_type">Selecione \nTipo de Repositório</string>
<string name="select_repo_type_text">Selecione se você quer criar um repositório local ou clonar um repositório remoto.</string>
<string name="clone_remote_repo">Clonar repositório remoto</string>
<string name="create_local_repo">Criar repositório local</string>
- <string name="select_gpg_key_title">Selecione\nChave GPG</string>
<string name="select_gpg_key_message">Selecione uma chave GPG para inicializar o armazenamento com</string>
<string name="gpg_key_select">Selecione a chave</string>
<!-- SSH port validation -->
@@ -324,11 +305,9 @@
<string name="clear_saved_host_key_success">Chave de host limpa com sucesso!</string>
<string name="otp_import_qr_code">Escanear QR code</string>
<string name="otp_import_from_file">Selecione uma imagem</string>
- <string name="otp_import_manual_entry">Inserir manualmente</string>
<string name="otp_import_manual_hint_secret">Segredo</string>
<string name="otp_import_manual_hint_account">Conta</string>
<string name="place_shortcut_on_home_screen">Criar atalho na tela inicial</string>
- <string name="password_list_fab_content_description">Criar nova senha ou pasta</string>
<string name="pgp_key_import_failed">Falha ao importar a chave PGP</string>
<string name="pgp_key_import_failed_replace_message">Uma chave existente com esse ID foi encontrada, você deseja substituí-lo?</string>
<string name="pgp_key_import_succeeded">Chave PGP importada com sucesso</string>
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index c10bdcd8..ddc33aa3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -28,7 +28,6 @@
</plurals>
<string name="delete_directory_progress_text">Удаляется…</string>
<string name="move">Переместить</string>
- <string name="edit">Редактировать</string>
<string name="delete">Удалить</string>
<string name="password_exists_title">Пароль уже существует!</string>
<string name="password_exists_message">Это перезапишет %1$s на %2$s</string>
@@ -44,7 +43,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Скопировано в буфер обмена</string>
<string name="file_toast_text">Пожалуйста, укажите имя файла</string>
- <string name="path_toast_text">Пожалуйста, задайте путь к файлу</string>
<string name="empty_toast_text">Вы не можете использовать пустой пароль или пустое поле информации</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Произошла ошибка выполнения операции Git</string>
@@ -68,7 +66,6 @@
<string name="crypto_name_hint">название</string>
<string name="crypto_pass_label">Пароль</string>
<string name="crypto_extra_label">Дополнительно</string>
- <string name="crypto_encrypt_username_label">Зашифровать имя пользователя</string>
<string name="crypto_select">Выбрать</string>
<string name="crypto_save">Сохранить</string>
<string name="crypto_save_and_copy">Сохранить и скопировать</string>
@@ -102,8 +99,6 @@
<string name="pref_recursive_filter_summary">Рекурсивный поиск паролей в текущей директории</string>
<string name="pref_sort_order_title">Порядок сортировки паролей</string>
<string name="pref_folder_first_sort_order">Сначала папки</string>
- <string name="pref_file_first_sort_order">Сначала файлы</string>
- <string name="pref_type_independent_sort_order">Типонезависимый</string>
<string name="pref_recently_used_sort_order">Недавно использованные</string>
<string name="pref_category_autofill_title">Автозаполнение</string>
<string name="pref_autofill_enable_title">Включить автозаполнение</string>
@@ -119,7 +114,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Сгенерировать пароль</string>
<string name="pwgen_generate">Сгенерировать</string>
- <string name="pwgen_include">Включая</string>
<string name="pwgen_length">Длина</string>
<string name="pwgen_numerals">Цифры</string>
<string name="pwgen_symbols">Символы</string>
@@ -160,7 +154,6 @@
<string name="dialog_yes">Да</string>
<string name="dialog_no">Нет</string>
<string name="dialog_cancel">Отмена</string>
- <string name="dialog_skip">Пропустить</string>
<string name="git_sync">Синхронизировать репозиторий</string>
<string name="git_pull">Пулл с удаленного сервера</string>
<string name="git_push">Пуш на удаленный сервер</string>
@@ -168,10 +161,7 @@
<string name="git_log">Показать журнал изменений</string>
<string name="show_password_pref_title">Показывать пароли</string>
<string name="show_password_pref_summary">Видимость расшифрованных паролей, не влияет на копирование в буфер.</string>
- <string name="pwd_generate_button">Сгенерировать</string>
- <string name="refresh_list">Обновить список</string>
<string name="send_plaintext_password_to">Поделиться паролем в открытом виде с помощью</string>
- <string name="app_icon_hint">Иконка приложения</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Выберите запись для заполнения</string>
<string name="oreo_autofill_strict_domain_search">Устойчивый к фишингу поиск</string>
@@ -199,11 +189,10 @@
<string name="oreo_autofill_flaky_fill_support">Заполнить учетные данные (время от времени может требоваться перезапуск браузера)</string>
<string name="oreo_autofill_no_support">Не поддерживается</string>
<string name="oreo_autofill_enable_dialog_description">Хранилище паролей может предложить заполнить формы входа и даже сохранить учетные данные в приложениях или на веб-сайтах.</string>
- <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите Password Store из списка и подтвердите запрос подтверждения, нажав ОК.</string>
+ <string name="oreo_autofill_enable_dialog_instructions">Чтобы включить эту функцию, нажмите ОК, чтобы перейти к настройкам автозаполнения. Там выберите HardenedPass из списка и подтвердите запрос подтверждения, нажав ОК.</string>
<string name="oreo_autofill_enable_dialog_installed_browsers">Поддержка автозаполнения установленными браузерами:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Невозможно открыть приватный ключ ssh, пожалуйста проверьте, что файл существует</string>
- <string name="new_password_title">Новый пароль</string>
<string name="clear_saved_passphrase_ssh">Очистить сохраненную кодовую фразу для локального SSH ключа</string>
<string name="clear_saved_passphrase_https">Удалить сохраненный пароль HTTPS</string>
<string name="git_operation_remember_passphrase">Запомнить кодовую фразу</string>
@@ -217,9 +206,8 @@
<string name="biometric_prompt_title">Запрос биометрии</string>
<string name="biometric_auth_error_reason">Ошибка аутентификации: %s</string>
<string name="pref_biometric_auth_title">Включить биометрическую аутентификацию</string>
- <string name="pref_biometric_auth_summary">Когда ключено, Password Store будет запрашивать ваш опечаток пальца при каждом запуске приложения</string>
+ <string name="pref_biometric_auth_summary">Когда ключено, HardenedPass будет запрашивать ваш опечаток пальца при каждом запуске приложения</string>
<string name="pref_biometric_auth_summary_error">Сенсор отпечатка пальца не доступен или отсутствует</string>
- <string name="pref_title_openkeystore_clear_keyid">Очистить сохраненный SSH Key идентификатор OpenKystortore</string>
<string name="your_public_key">Ваш публичный ключ</string>
<string name="error_generate_ssh_key">Возникла ошибка при попытке генерации ssh ключа</string>
<string name="pref_show_hidden_title">Показать все файлы и директории</string>
@@ -227,7 +215,6 @@
<string name="title_create_folder">Создать папку</string>
<string name="title_rename_folder">Переименовать папку</string>
<string name="message_category_error_empty_field">Название категории не может быть пустым</string>
- <string name="message_category_error_category_exists">Название категории уже существует</string>
<string name="message_error_destination_outside_repo">Путь должен указывать на область внутри репозитория</string>
<string name="message_rename_folder">Введите адрес назначения для %1$s</string>
<string name="button_create">Создать</string>
@@ -272,11 +259,9 @@
<string name="exporting_passwords">Экспорт паролей…</string>
<string name="invalid_gpg_id">Найден .gpg-id, но он содержит неверный ID ключа, fingerprint или ID пользователя</string>
<string name="invalid_filename_text">Имя файла не должно содержать \'/\', укажите директорию выше</string>
- <string name="directory_hint">Директория</string>
- <string name="new_folder_set_gpg_key">Установить GPG ключ для каталога</string>
<!-- GitException messages -->
<string name="git_unknown_error">Неизвестная ошибка</string>
- <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"настройки &gt; утилиты git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string>
+ <string name="git_pull_rebase_fail_error">Не удалось получить изменения, вы находитесь в состоянии \"оторванной головы\". Используйте \"Настройки &gt; Репозиторий &gt; Конфигурация локального Git\", сохраните ваши изменения в новую удаленную ветку и разрешите конфликты на своем компьютере.</string>
<string name="git_pull_merge_fail_error">Слияние не удалось, вы находитесь в конфликтующем состоянии. ВТО: Добавьте метод восстановления.</string>
<string name="git_push_nff_error">Запись изменений была отклонена удаленным репозиторием, сначала пполучите изменения перед повторной записью. Вы можете использовать Синхронизацию вместо получения/записи изменений, т.к. она реализует оба подхда.</string>
<string name="git_push_generic_error">Запись изменений была отклонена удаленным репозиторием, причина: %1$s</string>
@@ -286,16 +271,12 @@
<string name="git_break_out_of_detached_success">При попытке rebase\'а возник конфликт. Ваша локальная ветка %1$s была запушена %2$s\n Используйте эту ветку для разрешения конфликтов с помощью компьютера</string>
<string name="git_break_out_of_detached_unneeded">Не нужно пушить в другую ветку - rebase не был запущен</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">Для продолжения необходимо выбрать GPG-ключ</string>
<string name="folder_creation_err_file_exists">Файл с таким именем уже существует</string>
<string name="folder_creation_err_folder_exists">Папка с таким именем уже существует</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Поехали</string>
- <string name="select_n_repository_type">Выберите \nтип репозитория</string>
<string name="select_repo_type_text">Создать локальное хранилище, или клонировать удаленный репозиторий.</string>
<string name="clone_remote_repo">Клонировать удаленный репозиторий</string>
<string name="create_local_repo">Создать локальное хранилище</string>
- <string name="select_gpg_key_title">Выберите\nGPG ключ</string>
<string name="select_gpg_key_message">Выберите GPG ключ для инициализации хранилища</string>
<string name="gpg_key_select">Выберите ключ</string>
<!-- SSH port validation -->
@@ -316,10 +297,8 @@
<string name="clear_saved_host_key_success">Сохраненный ключ хоста был успешно удален!</string>
<string name="otp_import_qr_code">Отсканировать QR-код</string>
<string name="otp_import_from_file">Выберите изображение</string>
- <string name="otp_import_manual_entry">Ввести вручную</string>
<string name="otp_import_manual_hint_account">Учетная запись</string>
<string name="place_shortcut_on_home_screen">Разместить ярлык на рабочем столе</string>
- <string name="password_list_fab_content_description">Создать новый пароль или папку</string>
<string name="pgp_key_import_failed">Не удалось импортировать PGP-ключ</string>
<string name="pgp_key_import_succeeded">PGP ключ успешно импортирован</string>
</resources>
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index b20749b3..3f6abeb3 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -23,7 +23,6 @@
</plurals>
<string name="delete_directory_progress_text">正在删除…</string>
<string name="move">更多</string>
- <string name="edit">编辑</string>
<string name="delete">删除</string>
<string name="password_exists_title">密码已经存在!</string>
<string name="password_exists_message">这将用%2$s覆盖%1$s</string>
@@ -39,7 +38,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">复制到剪切板</string>
<string name="file_toast_text">请提供文件名</string>
- <string name="path_toast_text">请提供文件路径</string>
<string name="empty_toast_text">您不能使用空密码或空额外内容</string>
<!-- Git Async Task -->
<string name="jgit_error_dialog_title">Git操作过程中发生错误</string>
@@ -63,7 +61,6 @@
<string name="crypto_name_hint">名称</string>
<string name="crypto_pass_label">密码</string>
<string name="crypto_extra_label">附加内容</string>
- <string name="crypto_encrypt_username_label">加密用户名</string>
<string name="crypto_select">选择</string>
<string name="crypto_save">保存</string>
<string name="crypto_save_and_copy">保存并复制</string>
@@ -98,8 +95,6 @@
<string name="pref_recursive_filter_summary">递归式寻找当前目录的密码</string>
<string name="pref_sort_order_title">密码排序顺序</string>
<string name="pref_folder_first_sort_order">目录优先</string>
- <string name="pref_file_first_sort_order">文件优先</string>
- <string name="pref_type_independent_sort_order">类型无关</string>
<string name="pref_recently_used_sort_order">最近使用</string>
<string name="pref_category_autofill_title">自动填充</string>
<string name="pref_autofill_enable_title">启用自动填充</string>
@@ -122,7 +117,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">生成密码</string>
<string name="pwgen_generate">生成</string>
- <string name="pwgen_include">包含</string>
<string name="pwgen_length">长度</string>
<string name="pwgen_numerals">数字</string>
<string name="pwgen_symbols">标识</string>
@@ -168,7 +162,6 @@
<string name="dialog_yes">同意</string>
<string name="dialog_no">否定</string>
<string name="dialog_cancel">取消</string>
- <string name="dialog_skip">跳过</string>
<string name="git_sync">同步存储库</string>
<string name="git_pull">拉取远程数据</string>
<string name="git_push">推送数据到远程</string>
@@ -176,10 +169,7 @@
<string name="git_log">显示提交日志</string>
<string name="show_password_pref_title">显示密码</string>
<string name="show_password_pref_summary">控制密码解密后的可见性,这不会禁用复制到剪贴板</string>
- <string name="pwd_generate_button">生成</string>
- <string name="refresh_list">刷新列表</string>
<string name="send_plaintext_password_to">使用明文发送密码…</string>
- <string name="app_icon_hint">应用图标</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">选择要填写的条目</string>
<string name="oreo_autofill_strict_domain_search">防钓鱼搜索</string>
@@ -212,7 +202,6 @@
<string name="oreo_autofill_enable_dialog_installed_browsers">已安装支持自动填充的浏览器:</string>
<!-- Autofill -->
<string name="ssh_key_does_not_exist">无法打开SSH私钥,请检查文件是否存在</string>
- <string name="new_password_title">新密码</string>
<string name="clear_saved_passphrase_ssh">清除本地已保存的SSH密钥密码</string>
<string name="clear_saved_passphrase_https">清除保存的HTTPS密码</string>
<string name="git_operation_remember_passphrase">记住密钥密码</string>
@@ -225,9 +214,8 @@
<string name="biometric_prompt_title">生物识别提示</string>
<string name="biometric_auth_error_reason">验证失败: %s</string>
<string name="pref_biometric_auth_title">启用生物识别认证</string>
- <string name="pref_biometric_auth_summary">如果启用,Password Store 将在启动应用时提示您使用指纹进行身份验证</string>
+ <string name="pref_biometric_auth_summary">如果启用,HardenedPass 将在启动应用时提示您使用指纹进行身份验证</string>
<string name="pref_biometric_auth_summary_error">指纹硬件无法访问或丢失</string>
- <string name="pref_title_openkeystore_clear_keyid">清除记住的 OpenKeystore SSH 密钥ID</string>
<string name="your_public_key">你的公开密钥</string>
<string name="error_generate_ssh_key">尝试生成SSH密钥时出错</string>
<string name="pref_show_hidden_title">显示所有文件和目录</string>
@@ -235,7 +223,6 @@
<string name="title_create_folder">清除目录</string>
<string name="title_rename_folder">重命名目录</string>
<string name="message_category_error_empty_field">类别名称不能为空</string>
- <string name="message_category_error_category_exists">类别名称已存在</string>
<string name="message_error_destination_outside_repo">目标必须在存储库中</string>
<string name="message_rename_folder">输入%1$s的目标</string>
<string name="button_create">创建</string>
@@ -282,8 +269,6 @@ personal.com</string>
<string name="invalid_gpg_id">找到了 .gpg-id,但它包含无效的密钥ID、指纹或用户ID</string>
<string name="short_gpg_id">找到 .gpg-id 但它包含一个不受支持的短十六进制ID。</string>
<string name="invalid_filename_text">文件名不能包含\'/\'</string>
- <string name="directory_hint">目录</string>
- <string name="new_folder_set_gpg_key">为目录设置GPG密钥</string>
<!-- GitException messages -->
<string name="git_unknown_error">未知错误</string>
<string name="git_pull_merge_fail_error">合并失败,你处于冲突状态。</string>
@@ -296,18 +281,12 @@ personal.com</string>
使用此分支解决计算机上的冲突</string>
<string name="git_break_out_of_detached_unneeded">存储库没有变更,无需推送到另一个分支</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">选择一个 GPG 密钥才能继续</string>
<string name="folder_creation_err_file_exists">该名称的文件已存在</string>
<string name="folder_creation_err_folder_exists">该名称的文件夹已存在</string>
<!-- Onboarding flow -->
- <string name="let_s_go">开始配置</string>
- <string name="select_n_repository_type">选择仓库模式</string>
<string name="select_repo_type_text">你是要同步仓库还是现在创建一个仓库呢?</string>
<string name="clone_remote_repo">同步远程仓库</string>
<string name="create_local_repo">创建本地仓库</string>
- <string name="select_gpg_key_title">选择
-GPG
-密钥</string>
<string name="select_gpg_key_message">选择一个GPG密钥来初始化您的仓库</string>
<string name="gpg_key_select">选择密钥</string>
<!-- SSH port validation -->
@@ -329,11 +308,9 @@ GPG
<string name="clear_saved_host_key_success">已成功清除已保存的主机密钥!</string>
<string name="otp_import_qr_code">扫描二维码</string>
<string name="otp_import_from_file">选择图像</string>
- <string name="otp_import_manual_entry">手动输入</string>
<string name="otp_import_manual_hint_secret">安全码 (Secret)</string>
<string name="otp_import_manual_hint_account">账户</string>
<string name="place_shortcut_on_home_screen">在主屏幕上放置快捷方式</string>
- <string name="password_list_fab_content_description">创建新密码或文件夹</string>
<string name="pgp_key_import_failed">导入 PGP 密钥失败</string>
<string name="pgp_key_import_failed_replace_message">发现了 ID 相同的现有密钥,您要替换它吗?</string>
<string name="pgp_key_import_succeeded">成功导入 PGP 密钥</string>
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
index 9f44710b..2891987d 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -6,14 +6,10 @@
<resources>
<string-array name="sort_order_entries">
<item>@string/pref_folder_first_sort_order</item>
- <item>@string/pref_file_first_sort_order</item>
- <item>@string/pref_type_independent_sort_order</item>
<item>@string/pref_recently_used_sort_order</item>
</string-array>
<string-array name="sort_order_values">
<item>FOLDER_FIRST</item>
- <item>FILE_FIRST</item>
- <item>INDEPENDENT</item>
<item>RECENTLY_USED</item>
</string-array>
<string-array name="pwgen_provider_labels">
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
deleted file mode 100644
index a1d3c974..00000000
--- a/app/src/main/res/values/attrs.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<resources>
- <declare-styleable name="Multiselected">
- <attr name="state_multiselected" format="boolean" />
- </declare-styleable>
-</resources>
diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml
index 3a7341b5..ecd34921 100644
--- a/app/src/main/res/values/colors_material3.xml
+++ b/app/src/main/res/values/colors_material3.xml
@@ -60,5 +60,4 @@
<color name="error">#BA1B1B</color>
<!-- Multiselect color -->
- <color name="list_multiselect_background">#668eacbb</color>
</resources>
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
deleted file mode 100644
index d6939e63..00000000
--- a/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<resources>
- <!-- Default screen margins, per the Android Design guidelines. -->
- <dimen name="activity_horizontal_margin">16dp</dimen>
- <dimen name="activity_vertical_margin">16dp</dimen>
- <dimen name="fab_compat_margin">16dp</dimen>
- <dimen name="normal_margin">8dp</dimen>
- <dimen name="bottom_sheet_item_height">56dp</dimen>
- <dimen name="onboarding_icon_margin_top">100dp</dimen>
- <dimen name="onboarding_button_margin_top">140dp</dimen>
- <dimen name="onboarding_desc_margin_top">48dp</dimen>
-</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 07f24aa5..f1a858fd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -19,7 +19,6 @@
<string name="dialog_delete">Delete directory</string>
<string name="dialog_do_not_delete">Cancel</string>
<string name="title_activity_git_clone">Repository information</string>
- <string name="title_activity_git_config" translatable="false">Git configuration</string>
<string name="title_activity_git_log">Commit log</string>
<!-- Password Store -->
@@ -30,7 +29,6 @@
</plurals>
<string name="delete_directory_progress_text">Deleting…</string>
<string name="move">Move</string>
- <string name="edit">Edit</string>
<string name="delete">Delete</string>
<string name="password_exists_title">Password already exists!</string>
<string name="password_exists_message">This will overwrite %1$s with %2$s.</string>
@@ -48,7 +46,6 @@
<!-- PGPHandler -->
<string name="clipboard_copied_text">Copied to clipboard</string>
<string name="file_toast_text">Please provide a file name</string>
- <string name="path_toast_text">Please provide a file path</string>
<string name="empty_toast_text">You cannot use an empty password or empty extra content</string>
<!-- Git Async Task -->
@@ -80,7 +77,6 @@
<string name="crypto_username_hint">Username</string>
<string name="crypto_pass_label">Password</string>
<string name="crypto_extra_label">Extra content</string>
- <string name="crypto_encrypt_username_label">Encrypt username</string>
<string name="crypto_select">Select</string>
<string name="crypto_save">Save</string>
<string name="crypto_save_and_copy">Save and Copy</string>
@@ -119,8 +115,6 @@
<string name="pref_directory_structure">Password file organization</string>
<string name="pref_sort_order_title">Password sort order</string>
<string name="pref_folder_first_sort_order">Folders first</string>
- <string name="pref_file_first_sort_order">Files first</string>
- <string name="pref_type_independent_sort_order">Type independent</string>
<string name="pref_recently_used_sort_order">Recently used</string>
<string name="pref_category_autofill_title">Autofill</string>
<string name="pref_autofill_enable_title">Enable Autofill</string>
@@ -149,7 +143,6 @@
<!-- PasswordGenerator fragment -->
<string name="pwgen_title">Generate Password</string>
<string name="pwgen_generate">Generate</string>
- <string name="pwgen_include">Include</string>
<string name="pwgen_length">Length</string>
<string name="pwgen_numerals">Numerals</string>
<string name="pwgen_symbols">Symbols</string>
@@ -195,7 +188,6 @@
<string name="dialog_yes">Yes</string>
<string name="dialog_no">No</string>
<string name="dialog_cancel">Cancel</string>
- <string name="dialog_skip">Skip</string>
<string name="git_sync">Synchronize repository</string>
<string name="git_pull">Pull from remote</string>
<string name="git_push">Push to remote</string>
@@ -203,10 +195,7 @@
<string name="git_log">Show commit log</string>
<string name="show_password_pref_title">Show the password</string>
<string name="show_password_pref_summary">Control the visibility of the passwords once decrypted. This does not disable copying to clipboard.</string>
- <string name="pwd_generate_button">Generate</string>
- <string name="refresh_list">Refresh list</string>
<string name="send_plaintext_password_to">Send password as plaintext using…</string>
- <string name="app_icon_hint">App icon</string>
<!-- Oreo Autofill -->
<string name="oreo_autofill_select_and_fill_into">Select entry to fill into</string>
@@ -223,7 +212,7 @@
<string name="oreo_autofill_warning_publisher_header">This app\'s publisher has changed since you first associated a HardenedPass entry with it:</string>
<string name="oreo_autofill_warning_publisher_footer"><b>The currently installed app may be trying to steal your credentials by pretending to be a trusted app.</b>\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store.</string>
<string name="oreo_autofill_warning_publisher_install_time">Installed: %1$s</string>
- <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s“</string>
+ <string name="oreo_autofill_warning_publisher_app_name" translatable="false">“%1$s”</string>
<string name="oreo_autofill_warning_publisher_advanced_info_template" translatable="false">Package:\n%1$s\n\nHash:\n%2$s</string>
<string name="oreo_autofill_warning_publisher_advanced_info_button">Advanced information</string>
<string name="oreo_autofill_warning_publisher_changed_disable_autofill_button">Keep Autofill disabled</string>
@@ -242,7 +231,6 @@
<!-- Autofill -->
<string name="ssh_key_does_not_exist">Unable to open the ssh private key, please check that the file exists</string>
- <string name="new_password_title">New password</string>
<string name="clear_saved_passphrase_ssh">Clear saved passphrase for local SSH key</string>
<string name="clear_saved_passphrase_https">Clear saved HTTPS password</string>
<string name="git_operation_remember_passphrase">Remember key passphrase</string>
@@ -258,8 +246,6 @@
<string name="pref_biometric_auth_title">Enable biometric authentication</string>
<string name="pref_biometric_auth_summary">When enabled, HardenedPass will prompt you for your fingerprint when launching the app</string>
<string name="pref_biometric_auth_summary_error">Fingerprint hardware not accessible or missing</string>
- <string name="pref_biometric_auth_summary_disabled_platform">Feature unavailable due to a platform bug</string>
- <string name="pref_title_openkeystore_clear_keyid">Clear remembered OpenKeystore SSH Key ID</string>
<string name="your_public_key">Your public key</string>
<string name="error_generate_ssh_key">Error while trying to generate the ssh-key</string>
<string name="pref_show_hidden_title">Show all files and folders</string>
@@ -267,7 +253,6 @@
<string name="title_create_folder">Create folder</string>
<string name="title_rename_folder">Rename folder</string>
<string name="message_category_error_empty_field">Category name can\'t be empty</string>
- <string name="message_category_error_category_exists">Category name already exists</string>
<string name="message_error_destination_outside_repo">Destination must be within the repository</string>
<string name="message_rename_folder">Enter destination for %1$s</string>
<string name="button_create">Create</string>
@@ -315,12 +300,10 @@
<string name="invalid_gpg_id">Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID</string>
<string name="short_gpg_id">Found .gpg-id, but it contains a short hex ID, which is not supported</string>
<string name="invalid_filename_text">File name must not contain \'/\', set directory above</string>
- <string name="directory_hint">Directory</string>
- <string name="new_folder_set_gpg_key">Set GPG key for directory</string>
<!-- GitException messages -->
<string name="git_unknown_error">Unknown error</string>
- <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using "settings > git utils", save your changes to the remote in a new branch and resolve the conflict on your computer.</string>
+ <string name="git_pull_rebase_fail_error">Pull has failed, you\'re in a detached head. Using \"Settings > Repository > Local Git config &amp; utilities\", save your changes to the remote in a new branch and resolve the conflict on your computer.</string>
<string name="git_pull_merge_fail_error">Merge has failed, you\'re in a conflicting state.</string>
<string name="git_push_nff_error">Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both</string>
@@ -332,17 +315,13 @@
<string name="git_break_out_of_detached_unneeded">The repository is not rebasing, no need to push to another branch</string>
<!-- GPG key selection in folder creation -->
- <string name="gpg_key_select_mandatory">Selecting a GPG key is necessary to proceed</string>
<string name="folder_creation_err_file_exists">A file by that name already exists</string>
<string name="folder_creation_err_folder_exists">A folder by that name already exists</string>
<!-- Onboarding flow -->
- <string name="let_s_go">Let\'s Go</string>
- <string name="select_n_repository_type">Select \nRepository Type</string>
<string name="select_repo_type_text">Select if you want to create a local repo or clone a remote repo.</string>
<string name="clone_remote_repo">Clone Remote Repo</string>
<string name="create_local_repo">Create Local Repo</string>
- <string name="select_gpg_key_title">Select\nGPG Key</string>
<string name="select_gpg_key_message">Select a GPG key to initialize your store with</string>
<string name="gpg_key_select">Select key</string>
@@ -365,11 +344,9 @@
<string name="clear_saved_host_key_success">Successfully cleared saved host key!</string>
<string name="otp_import_qr_code">Scan QR code</string>
<string name="otp_import_from_file">Choose an image</string>
- <string name="otp_import_manual_entry">Enter manually</string>
<string name="otp_import_manual_hint_secret">Secret</string>
<string name="otp_import_manual_hint_account">Account</string>
<string name="place_shortcut_on_home_screen">Place shortcut on home screen</string>
- <string name="password_list_fab_content_description">Create new password or folder</string>
<string name="pgp_key_import_failed">Failed to import PGP key</string>
<string name="pgp_key_import_failed_replace_message">An existing key with this ID was found, do you want to replace it?</string>
<string name="pgp_key_import_succeeded">Successfully imported PGP key</string>
@@ -385,4 +362,57 @@
<string name="no_keys_imported_dialog_message">There are no PGP keys imported in the app yet, press the button below to pick a key file</string>
<string name="biometric_prompt_title_gpg_passphrase_cache">Unlock passphrase cache</string>
<string name="clear_cached_password_on_screen_off">Clear cached password on screen-off</string>
+
+ <!-- Compose password list -->
+ <string name="list_new_entry">New entry</string>
+ <string name="list_store_root">Password store</string>
+ <string name="list_more_options">More options</string>
+ <string name="list_clear_search">Clear search</string>
+
+ <!-- Compose entry view -->
+ <string name="entry_back">Back</string>
+ <string name="entry_show_password">Show password</string>
+ <string name="entry_hide_password">Hide password</string>
+ <string name="entry_copy_name">Copy name</string>
+
+ <!-- Compose folder picker -->
+ <string name="folder_picker_title">Choose a folder</string>
+ <string name="folder_creation_err_failed">Could not create the folder</string>
+ <string name="folder_picker_new">New folder</string>
+
+ <!-- Compose editor -->
+ <string name="editor_new_title">New entry</string>
+ <string name="editor_edit_title">Edit entry</string>
+ <string name="editor_close">Close</string>
+ <string name="editor_folder">Folder</string>
+ <string name="editor_change_folder">Change</string>
+ <string name="editor_otp_qr">OTP from QR</string>
+ <string name="editor_otp_manual">Manual OTP</string>
+ <string name="generator_random">Random</string>
+ <string name="generator_diceware" translatable="false">Diceware</string>
+ <string name="generator_use">Use</string>
+
+ <!-- Compose setup -->
+ <string name="ssh_key_state_none">No SSH key yet</string>
+ <string name="ssh_key_state_imported">Imported private key</string>
+ <string name="ssh_key_state_generated">Generated on this device</string>
+ <string name="git_clone_progress">Cloning…</string>
+ <string name="connection_mode_none">None</string>
+ <string name="ssh_key_manage">Manage</string>
+ <string name="ssh_key_public_hint">Provide this public key to your Git server.</string>
+ <string name="pgp_key_needed_message">Import the private key this store is encrypted for, so its entries can be read and saved.</string>
+ <string name="pgp_key_needed_recipients">Store recipients</string>
+ <string name="store_create_failed">Could not create the store: %1$s</string>
+ <string name="store_not_empty">the folder already holds a store</string>
+ <string name="git_clone_delete_failed">Could not empty the store folder, so the clone did not start.</string>
+ <string name="pgp_key_import_too_large">The file is larger than 1 MiB: not a PGP key.</string>
+ <string name="pgp_key_import_downgrade">A private key with this ID is already imported. Its public key alone cannot replace it: delete the private key first if you really want to.</string>
+
+ <!-- Compose settings -->
+ <string name="store_delete_failed">Could not delete everything in the store folder.</string>
+ <string name="clear_saved_secret_message">It will be asked again at the next Git operation.</string>
+ <string name="invalid_proxy_port">Enter a port from 1 to 65535, or nothing</string>
+ <string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string>
+ <string name="git_tool_done">%1$s: done</string>
+ <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string>
</resources>
diff --git a/app/src/main/res/values/styles_material3.xml b/app/src/main/res/values/styles_material3.xml
deleted file mode 100644
index 329905e1..00000000
--- a/app/src/main/res/values/styles_material3.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
- ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved.
- ~ SPDX-License-Identifier: GPL-3.0-only
- -->
-
-<resources>
-
- <style name="APSThemeM3.Toolbar" parent="Widget.Material3.Toolbar">
- <item name="android:background">?android:colorBackground</item>
- </style>
-
-</resources>
diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml
index 7e34b472..0ec74a45 100644
--- a/app/src/main/res/values/themes_material3.xml
+++ b/app/src/main/res/values/themes_material3.xml
@@ -10,8 +10,12 @@
<item name="android:windowLightStatusBar">@bool/light_status_bar</item>
<item name="android:statusBarColor">?android:colorBackground</item>
<item name="android:navigationBarColor">?android:colorBackground</item>
- <item name="toolbarStyle">@style/APSThemeM3.Toolbar</item>
- <item name="bottomSheetDialogTheme">@style/APSThemeM3.BottomSheetDialog</item>
+ </style>
+
+ <!-- AppCompat activities whose whole UI is Compose -->
+ <style name="NoActionBarThemeM3" parent="AppThemeM3">
+ <item name="windowActionBar">false</item>
+ <item name="windowNoTitle">true</item>
</style>
<style name="NoBackgroundThemeM3" parent="@style/AppThemeM3">
@@ -37,12 +41,6 @@
<item name="windowNoTitle">true</item>
</style>
- <style name="APSThemeM3.BottomSheetDialog" parent="ThemeOverlay.Material3.BottomSheetDialog">
- <item name="android:windowIsFloating">false</item>
- <item name="android:statusBarColor">@android:color/transparent</item>
- <item name="android:navigationBarColor">@android:color/transparent</item>
- </style>
-
<style name="APSThemeM3" parent="Theme.Material3.DayNight">
<item name="colorPrimary">@color/md_theme_light_primary</item>
<item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
diff --git a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt
new file mode 100644
index 00000000..39f7f358
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.autofill
+
+import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult
+import app.passwordstore.util.settings.DirectoryStructure
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AutofillModelTest {
+
+ private val root = File("/store")
+
+ private fun rows(structure: DirectoryStructure, vararg paths: String) =
+ autofillRows(paths.map { File(root, it) }, root, structure).map {
+ Triple(it.path, it.label, it.subtitle)
+ }
+
+ @Test
+ fun fileBasedNamesTheIdentifier() {
+ assertEquals(
+ listOf(
+ Triple("/store/work/example.org/john@doe.org.gpg", "example.org", "john@doe.org · work"),
+ Triple("/store/example.org/john.gpg", "example.org", "john"),
+ Triple("/store/example.org.gpg", "example.org", null),
+ ),
+ rows(
+ DirectoryStructure.FileBased,
+ "work/example.org/john@doe.org.gpg",
+ "example.org/john.gpg",
+ "example.org.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun directoryBasedFallsBackToTheAccountPart() {
+ assertEquals(
+ listOf(
+ Triple(
+ "/store/example.org/john@doe.org/password.gpg",
+ "example.org",
+ "john@doe.org/password",
+ ),
+ Triple("/store/Temporary PIN.gpg", "Temporary PIN", null),
+ ),
+ rows(
+ DirectoryStructure.DirectoryBased,
+ "example.org/john@doe.org/password.gpg",
+ "Temporary PIN.gpg",
+ ),
+ )
+ }
+
+ @Test
+ fun encryptedUsernameShowsTheFolders() {
+ assertEquals(
+ listOf(Triple("/store/work/example.org.gpg", "example.org", "work")),
+ rows(DirectoryStructure.EncryptedUsername, "work/example.org.gpg"),
+ )
+ }
+
+ @Test
+ fun rowsKeepTheRankingOrder() {
+ assertEquals(
+ listOf("b.org", "a.org"),
+ rows(DirectoryStructure.FileBased, "b.org.gpg", "a.org.gpg").map { it.second },
+ )
+ }
+
+ @Test
+ fun passphraseIsCachedOnlyAfterTheScreenLock() {
+ assertTrue(cachesPassphrase(true, BiometricResult.Success(null)))
+ val notConfirmed =
+ listOf(
+ BiometricResult.CanceledByUser,
+ BiometricResult.CanceledBySystem,
+ BiometricResult.HardwareUnavailableOrDisabled,
+ BiometricResult.Failure(null, "failed"),
+ BiometricResult.Retry,
+ )
+ for (auth in notConfirmed) assertFalse(cachesPassphrase(true, auth), auth.toString())
+ assertFalse(cachesPassphrase(false, BiometricResult.Success(null)))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt
new file mode 100644
index 00000000..fee6bf72
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.crypto
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** Pins the path helpers' behavior across their move out of BasePGPActivity. */
+class CryptoExtensionsTest {
+
+ @Test
+ fun parentPathIsRelativeToTheStore() {
+ assertEquals("/social/", getParentPath("/store/social/facebook.gpg", "/store"))
+ assertEquals("/", getParentPath("/store/example.org.gpg", "/store"))
+ }
+
+ @Test
+ fun longNameJoinsFolderAndName() {
+ // StoreActions passes absolute folders, PasswordItem its path relative to the store.
+ assertEquals("work/x", getLongName("/store/work", "/store", "x"))
+ assertEquals("social/facebook", getLongName("/social/", "/store", "facebook"))
+ assertEquals("x", getLongName("/store", "/store", "x"))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
new file mode 100644
index 00000000..160434c9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.editor
+
+import app.passwordstore.R
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.passgen.random.PasswordOption
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class EditorModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun editingWithoutChangesKeepsThePassFile() {
+ val files =
+ listOf(
+ "hunter2\nusername: alice\nurl: https://example.org\n",
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n",
+ )
+ for (content in files) assertEquals(content, passContent(editorForm(entry(content), "", "x")))
+ }
+
+ @Test
+ fun formFromAnEntrySplitsItsFields() {
+ val form =
+ editorForm(entry("hunter2\nusername: alice\nurl: https://example.org\n"), "work", "mail")
+ assertEquals(EditorForm("mail", "work", "hunter2", "alice", "url: https://example.org\n"), form)
+ }
+
+ @Test
+ fun newEntryContent() {
+ assertEquals(
+ "pw\nusername: bob\nnote: x",
+ passContent(EditorForm(name = "n", password = "pw", username = "bob", extra = "note: x")),
+ )
+ assertEquals("pw\n", passContent(EditorForm(name = "n", password = "pw")))
+ }
+
+ @Test
+ fun formErrors() {
+ assertEquals(R.string.file_toast_text, formError(EditorForm(name = " ", password = "pw")))
+ assertEquals(
+ R.string.invalid_filename_text,
+ formError(EditorForm(name = "a/b", password = "pw")),
+ )
+ assertEquals(R.string.empty_toast_text, formError(EditorForm(name = "a", username = "bob")))
+ assertNull(formError(EditorForm(name = "a", extra = "note")))
+ assertNull(formError(EditorForm(name = "a", password = "pw")))
+ }
+
+ @Test
+ fun entryFileTrimsTheNameAndUsesTheFolder() {
+ assertEquals(
+ File(root, "work/mail.gpg"),
+ 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
+ fun overwritesOnlyAnotherFile() {
+ val taken = File(root, "a.gpg").apply { writeText("x") }
+ assertFalse(overwrites(File(root, "work/b.gpg"), null))
+ assertTrue(overwrites(taken, null))
+ assertFalse(overwrites(taken, taken))
+ assertTrue(overwrites(taken, File(root, "work/b.gpg")))
+ }
+
+ @Test
+ fun writeEntryCreatesTheFolderAndReplacesTheFile() {
+ val target = File(root, "new/dir/a.gpg")
+ writeEntry(target, byteArrayOf(1))
+ writeEntry(target, byteArrayOf(2, 3))
+ assertContentEquals(byteArrayOf(2, 3), target.readBytes())
+ assertEquals(listOf("a.gpg"), target.parentFile!!.list()!!.toList())
+ }
+
+ @Test
+ fun linesGoOnTheirOwnLine() {
+ assertEquals("otpauth://x", withLine("", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a", "otpauth://x"))
+ assertEquals("a\notpauth://x", withLine("a\n", "otpauth://x"))
+ }
+
+ @Test
+ fun manualTotpUriIsReadBackByTheParser() {
+ val uri = totpUri(" JBSW Y3DP EHPK 3PXP ", "Example Co")!!
+ assertEquals("JBSWY3DPEHPK3PXP", UriTotpFinder().findSecret(uri))
+ assertEquals("Example Co", UriTotpFinder().findIssuer(uri))
+ assertTrue(entry("PLACEHOLDER\n$uri").hasTotp())
+ assertEquals("otpauth://totp?secret=JBSWY3DPEHPK3PXP", totpUri("JBSWY3DPEHPK3PXP", " "))
+ assertNull(totpUri(" ", "Example Co"))
+ }
+
+ @Test
+ fun randomOptionsFollowTheCheckboxes() {
+ assertEquals(listOf(PasswordOption.FullyRandom), RandomSpec().options())
+ assertEquals(
+ listOf(
+ PasswordOption.NoDigits,
+ PasswordOption.AtLeastOneSymbol,
+ PasswordOption.NoUppercaseLetters,
+ PasswordOption.NoLowercaseLetters,
+ PasswordOption.NoAmbiguousCharacters,
+ ),
+ RandomSpec(
+ numerals = false,
+ symbols = true,
+ uppercase = false,
+ lowercase = false,
+ ambiguous = false,
+ pronounceable = true,
+ )
+ .options(),
+ )
+ }
+
+ @Test
+ fun randomPasswordOrWhyNot() {
+ assertEquals(20, randomPassword(RandomSpec()).value.length)
+ assertEquals(
+ R.string.pwgen_no_chars_error,
+ randomPassword(RandomSpec(numerals = false, uppercase = false, lowercase = false)).error,
+ )
+ assertEquals(
+ R.string.pwgen_length_too_short_error,
+ randomPassword(RandomSpec(length = 2)).error,
+ )
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt
new file mode 100644
index 00000000..290c51a9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.entry
+
+import app.passwordstore.data.passfile.PasswordEntry
+import app.passwordstore.data.passfile.Totp
+import app.passwordstore.util.time.UserClock
+import app.passwordstore.util.totp.UriTotpFinder
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+class EntryModelTest {
+
+ private fun entry(content: String) =
+ PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray())
+
+ @Test
+ fun firstGroupAndExtrasInFileOrder() {
+ val fields =
+ entryFields(entry("hunter2\nusername: alice\nurl: https://example.org\npin: 1234\n"))
+ assertEquals("hunter2", fields.password)
+ assertEquals("alice", fields.username)
+ assertEquals(listOf("url" to "https://example.org", "pin" to "1234"), fields.extras)
+ }
+
+ @Test
+ fun blankPasswordAndMissingUsernameAreLeftOut() {
+ val fields = entryFields(entry("\nnote: none\n"))
+ assertNull(fields.password)
+ assertNull(fields.username)
+ assertEquals(listOf("note" to "none"), fields.extras)
+ }
+
+ @Test
+ fun otpUriIsNotAnExtraField() {
+ val fields =
+ entryFields(
+ entry(
+ "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n"
+ )
+ )
+ assertEquals("s3cret", fields.password)
+ assertTrue(fields.extras.isEmpty())
+ }
+
+ @Test
+ fun fractionLeftOfTheOtpPeriod() {
+ assertEquals(1f, Totp("123456", 30.seconds, 30.seconds).fractionLeft())
+ assertEquals(0.5f, Totp("123456", 15.seconds, 30.seconds).fractionLeft())
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
new file mode 100644
index 00000000..cf12deef
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class ListModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ private fun file(path: String) =
+ File(root, path).apply {
+ parentFile!!.mkdirs()
+ writeText("x")
+ }
+
+ private fun folder(path: String) = File(root, path).apply { mkdirs() }
+
+ @Test
+ fun openingAChildFolderPushesIt() {
+ assertEquals(0 to listOf("work"), folderMoves("", "work"))
+ }
+
+ @Test
+ fun breadcrumbJumpsOnlyPop() {
+ assertEquals(1 to emptyList<String>(), folderMoves("work/servers", "work"))
+ assertEquals(2 to emptyList<String>(), folderMoves("work/servers", ""))
+ }
+
+ @Test
+ fun searchResultsRebuildTheStackFromTheSharedParent() {
+ assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail"))
+ assertEquals(
+ 0 to listOf("work/servers", "work/servers/eu"),
+ folderMoves("work", "work/servers/eu"),
+ )
+ }
+
+ @Test
+ fun sameFolderMovesNothing() {
+ assertEquals(0 to emptyList<String>(), folderMoves("work", "work"))
+ }
+
+ @Test
+ fun browsingShowsNamesAndFolderCounts() {
+ val work = folder("work")
+ file("work/mail.gpg")
+ file("work/notes.txt")
+ folder("work/servers")
+ val site = file("site.gpg")
+
+ val rows = listRows(listOf(work, site), root)
+
+ assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders)
+ assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries)
+ }
+
+ @Test
+ fun searchResultsShowWhereTheyAre() {
+ val servers = folder("work/servers")
+ val vpn = file("work/vpn.gpg")
+
+ val rows = listRows(listOf(vpn, servers), root)
+
+ assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders)
+ assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries)
+ }
+
+ @Test
+ fun keepsTheGivenOrderInsideEachGroup() {
+ val b = file("b.gpg")
+ val a = file("a.gpg")
+ val z = folder("z")
+ val y = folder("y")
+
+ val rows = listRows(listOf(b, z, a, y), root)
+
+ assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path })
+ assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path })
+ }
+
+ @Test
+ fun pickerOffersFoldersByName() {
+ folder("b")
+ folder("A")
+ folder(".hidden")
+ folder(".git")
+ file("c.gpg")
+ assertEquals(listOf("A", "b"), subfolders(root, showHidden = false).map { it.name })
+ assertEquals(listOf(".hidden", "A", "b"), subfolders(root, showHidden = true).map { it.name })
+ assertEquals(emptyList<File>(), subfolders(File(root, "missing"), showHidden = false))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt
new file mode 100644
index 00000000..8a1ae1d9
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.list
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class StoreActionsTest {
+
+ @Test
+ fun deleteDoesNotFollowSymlinks() {
+ val root = Files.createTempDirectory("aps").toFile()
+ val outsideDir = File(root, "outside").apply { mkdirs() }
+ val outsideFile = File(outsideDir, "secret.gpg").apply { writeText("x") }
+ val folder = File(root, "store/folder").apply { mkdirs() }
+ File(folder, "login.gpg").writeText("x")
+ Files.createSymbolicLink(File(folder, "link").toPath(), outsideDir.toPath())
+
+ assertTrue(folder.deleteWithoutFollowingLinks())
+
+ assertFalse(folder.exists())
+ assertTrue(outsideFile.exists())
+ }
+}
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. <danix@danix.xyz>
+ * 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<GitCommit>(), gitLog(null))
+ repo().use { git ->
+ // No commit yet: JGit's NoHeadException is an empty log, not a crash.
+ assertEquals(emptyList<GitCommit>(), 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/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt
new file mode 100644
index 00000000..9ccddbd3
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.setup
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.util.settings.AuthMode
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+class SetupModelTest {
+
+ private val root = Files.createTempDirectory("aps").toFile()
+
+ @AfterTest
+ fun cleanUp() {
+ root.deleteRecursively()
+ }
+
+ @Test
+ fun urlProblems() {
+ assertEquals(UrlProblem.HttpsPort, urlProblem("https://example.org:8443/store.git"))
+ assertEquals(UrlProblem.SshScheme, urlProblem("git@example.org:2222/srv/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org/store.git"))
+ assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org:9418/store.git"))
+ assertNull(urlProblem("ssh://git@example.org:2222/srv/store.git"))
+ assertNull(urlProblem("git@example.org:srv/store.git"))
+ assertNull(urlProblem("https://example.org/store.git"))
+ assertNull(urlProblem("http://example.org:8080/store.git"))
+ }
+
+ @Test
+ fun urlFixes() {
+ assertEquals(
+ "https://example.org/store.git",
+ fixUrl("https://example.org:8443/store.git", UrlProblem.HttpsPort),
+ )
+ assertEquals(
+ "ssh://git@example.org:2222/srv/store.git",
+ fixUrl("git@example.org:2222/srv/store.git", UrlProblem.SshScheme),
+ )
+ assertNull(fixUrl("git://example.org/store.git", UrlProblem.GitScheme))
+ }
+
+ @Test
+ fun authModesFollowTheUrl() {
+ val ssh = listOf(AuthMode.SshKey, AuthMode.Password)
+ val http = listOf(AuthMode.None, AuthMode.Password)
+ assertEquals(ssh, authModes("ssh://git@example.org/store.git"))
+ assertEquals(ssh, authModes("git@example.org:store.git"))
+ assertEquals(ssh, authModes(""))
+ assertEquals(http, authModes("https://example.org/store.git"))
+ assertEquals(http, authModes("http://example.org/store.git"))
+ assertEquals(AuthMode.None, fitAuthMode("https://example.org/x", AuthMode.SshKey))
+ assertEquals(AuthMode.Password, fitAuthMode("https://example.org/x", AuthMode.Password))
+ assertEquals(AuthMode.SshKey, fitAuthMode("git@example.org:x", AuthMode.None))
+ assertEquals(AuthMode.Password, fitAuthMode("git@example.org:x", AuthMode.Password))
+ }
+
+ @Test
+ fun cloneTargets() {
+ val store = File(root, "store")
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ store.mkdirs()
+ assertEquals(CloneTarget.Empty, cloneTarget(store))
+ File(store, ".git").mkdirs()
+ assertEquals(CloneTarget.OnlyGit, cloneTarget(store))
+ File(store, "a.gpg").writeText("x")
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ File(store, ".git").deleteRecursively()
+ assertEquals(CloneTarget.Occupied, cloneTarget(store))
+ }
+
+ @Test
+ fun storeRecipientsSkipBlankAndInvalidLines() {
+ val ids =
+ storeRecipients(
+ "4882E8257A3700D43119B8D3F8993A789CDFB014\n\n Alice <alice@example.org> \nnot an id\n"
+ )
+ assertEquals(listOf("f8993a789cdfb014", "alice@example.org"), ids.map(PGPIdentifier::toString))
+ 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 =
+ listOf("f8993a789cdfb014", "0x79E8208280490C77").map {
+ assertIs<PGPIdentifier.KeyId>(PGPIdentifier.fromString(it))
+ }
+ val text = gpgIdText(ids)
+ assertEquals("F8993A789CDFB014\n79E8208280490C77\n", text)
+ // pass and this app read it back as the same key IDs.
+ assertEquals(ids, storeRecipients(text))
+ }
+
+ @Test
+ fun gpgIdIsReadOnlyFromASmallRegularFile() {
+ assertNull(readGpgId(root))
+ File(root, ".gpg-id").writeText("F8993A789CDFB014\n")
+ assertEquals("F8993A789CDFB014\n", readGpgId(root))
+
+ val linked = File(root, "linked").apply { mkdirs() }
+ val outside = File(root, "outside").apply { writeText("F8993A789CDFB014\n") }
+ Files.createSymbolicLink(File(linked, ".gpg-id").toPath(), outside.toPath())
+ assertNull(readGpgId(linked))
+
+ val big = File(root, "big").apply { mkdirs() }
+ File(big, ".gpg-id").writeText("A".repeat(64 * 1024 + 1))
+ assertNull(readGpgId(big))
+
+ val folder = File(root, "folder").apply { File(this, ".gpg-id").mkdirs() }
+ assertNull(readGpgId(folder))
+ }
+
+ @Test
+ fun createdForMatchesOnlyTheChosenKeysGpgId() {
+ val key = PGPIdentifier.fromString("0xf8993a789cdfb014") as PGPIdentifier.KeyId
+ assertEquals(false, createdFor(root, key))
+ File(root, ".gpg-id").writeText(gpgIdText(listOf(key)))
+ assertEquals(true, createdFor(root, key))
+ File(root, ".gpg-id").writeText("F8993A789CDFB014\nB950AE2813841585\n")
+ assertEquals(false, createdFor(root, key))
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt
new file mode 100644
index 00000000..1a9914fb
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.util.git.sshj
+
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class SshKeyFileTest {
+
+ private fun pem(type: String) =
+ listOf("-----BEGIN $type-----", "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU", "-----END $type-----")
+
+ @Test
+ fun acceptsSshPrivateKeys() {
+ assertTrue(isSshPrivateKey(pem("OPENSSH PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("RSA PRIVATE KEY")))
+ assertTrue(isSshPrivateKey(pem("EC PRIVATE KEY")))
+ }
+
+ @Test
+ fun refusesOtherFiles() {
+ // The step 4 finding: an OpenPGP key block has the same markers and was accepted.
+ assertFalse(isSshPrivateKey(pem("PGP PRIVATE KEY BLOCK")))
+ assertFalse(isSshPrivateKey(pem("PGP PUBLIC KEY BLOCK")))
+ assertFalse(isSshPrivateKey(listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA test@example.org")))
+ assertFalse(isSshPrivateKey(emptyList()))
+ assertFalse(isSshPrivateKey(pem("OPENSSH PRIVATE KEY").dropLast(1)))
+ }
+}
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. <danix@danix.xyz>
+ * 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. <danix@danix.xyz>
+ * 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))
+ }
+}