diff options
Diffstat (limited to 'app/src')
106 files changed, 1132 insertions, 900 deletions
diff --git a/app/src/debug/ic_launcher-playstore.png b/app/src/debug/ic_launcher-playstore.png Binary files differdeleted file mode 100644 index d93ce3cf..00000000 --- a/app/src/debug/ic_launcher-playstore.png +++ /dev/null diff --git a/app/src/debug/res/drawable/ic_launcher_background.xml b/app/src/debug/res/drawable/ic_launcher_background.xml index f64ed7e3..d6bd4668 100644 --- a/app/src/debug/res/drawable/ic_launcher_background.xml +++ b/app/src/debug/res/drawable/ic_launcher_background.xml @@ -9,6 +9,6 @@ android:viewportWidth="432" android:viewportHeight="432"> <path - android:fillColor="#5E0033" + android:fillColor="#4B2E83" android:pathData="M0,0h432v432h-432z" /> </vector> diff --git a/app/src/debug/res/drawable/ic_launcher_foreground.xml b/app/src/debug/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 2cdef53f..00000000 --- a/app/src/debug/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,63 +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 - --> - -<vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" - android:width="108dp" - android:height="108dp" - android:viewportWidth="432" - android:viewportHeight="432"> - <path android:pathData="M128.18,195.79C126.46,181.89 137.12,169.6 150.91,169.6H281.09C294.91,169.6 305.59,181.95 303.8,195.88L292.58,283.72C291.09,295.32 281.37,304 269.86,304H161.8C150.26,304 140.51,295.27 139.07,283.63L128.18,195.79Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:endX="216" - android:endY="304" - android:startX="216" - android:startY="169.6" - android:type="linear"> - <item - android:color="#FFFFA800" - android:offset="0" /> - <item - android:color="#FFFF6341" - android:offset="1" /> - </gradient> - </aapt:attr> - </path> - <path android:pathData="M245.33,169.6V160C245.33,144.09 232.2,131.2 216,131.2C199.8,131.2 186.67,144.09 186.67,160V169.6H167.11V160C167.11,133.49 189,112 216,112C243,112 264.89,133.49 264.89,160V169.6H245.33Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:centerX="216" - android:centerY="169.6" - android:gradientRadius="33.6" - android:type="radial"> - <item - android:color="#FFD1004B" - android:offset="0.24" /> - <item - android:color="#FFFFD749" - android:offset="0.75" /> - </gradient> - </aapt:attr> - </path> - <path - android:fillColor="#FFD749" - android:pathData="M245.33,169.6h19.56v38.4h-19.56z" /> - <path - android:fillColor="#FFD749" - android:pathData="M245.33,217.6h19.56v48h-19.56z" /> - <path - android:fillColor="#FFD749" - android:pathData="M239.11,205.19l13.83,13.58l-34.57,33.94l-13.83,-13.58z" /> - <path - android:fillColor="#FFFFF0" - android:pathData="M235.56,208a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#FFFFF0" - android:pathData="M235.56,275.2a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#FFFFF0" - android:pathData="M204.54,252.71m-13.83,-13.58a19.56,19.2 0,1 1,27.66 27.15a19.56,19.2 0,1 1,-27.66 -27.15" /> -</vector> diff --git a/app/src/debug/res/mipmap-anydpi/ic_launcher.xml b/app/src/debug/res/mipmap-anydpi/ic_launcher.xml deleted file mode 100644 index 22bd3428..00000000 --- a/app/src/debug/res/mipmap-anydpi/ic_launcher.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 - --> - -<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> - <background android:drawable="@drawable/ic_launcher_background" /> - <foreground android:drawable="@drawable/ic_launcher_foreground" /> - <monochrome android:drawable="@drawable/ic_launcher_foreground_monochrome" /> -</adaptive-icon> diff --git a/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/app/src/debug/res/mipmap-hdpi/ic_launcher.png Binary files differdeleted file mode 100644 index a3b28da5..00000000 --- a/app/src/debug/res/mipmap-hdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/app/src/debug/res/mipmap-mdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 0be8ba3c..00000000 --- a/app/src/debug/res/mipmap-mdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 02a4af49..00000000 --- a/app/src/debug/res/mipmap-xhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 9d32c97c..00000000 --- a/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index d7d4ef18..00000000 --- a/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15d6447e..0eabbab4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,14 +30,12 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/AppThemeM3" - tools:ignore="GoogleAppIndexingWarning" - tools:targetApi="s"> + tools:ignore="GoogleAppIndexingWarning"> <activity android:name=".ui.main.LaunchActivity" android:configChanges="orientation|screenSize" - android:exported="true" - android:theme="@style/NoActionBarThemeM3"> + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> @@ -49,7 +47,6 @@ android:name=".ui.main.MainActivity" android:configChanges="orientation|screenSize" android:exported="false" - android:theme="@style/NoActionBarThemeM3" android:windowSoftInputMode="adjustResize" /> <activity @@ -101,7 +98,6 @@ android:name=".ui.autofill.AutofillSaveActivity" android:configChanges="orientation|screenSize" android:exported="false" - android:theme="@style/NoActionBarThemeM3" android:windowSoftInputMode="adjustResize" /> <activity android:name=".ui.autofill.AutofillPublisherChangedActivity" diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png Binary files differdeleted file mode 100644 index 8de23a46..00000000 --- a/app/src/main/ic_launcher-playstore.png +++ /dev/null diff --git a/app/src/main/java/app/passwordstore/Application.kt b/app/src/main/java/app/passwordstore/Application.kt index bda61265..dbeb4ffb 100644 --- a/app/src/main/java/app/passwordstore/Application.kt +++ b/app/src/main/java/app/passwordstore/Application.kt @@ -4,14 +4,13 @@ */ package app.passwordstore +import android.app.UiModeManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences -import android.os.Build import android.os.StrictMode -import androidx.appcompat.app.AppCompatDelegate.* import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.injection.context.FilesDirPath import app.passwordstore.injection.prefs.SettingsPreferences @@ -24,7 +23,6 @@ import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.settings.removeSecurityCryptoData import app.passwordstore.util.settings.runMigrations -import com.google.android.material.color.DynamicColors import dagger.hilt.android.HiltAndroidApp import java.util.concurrent.Executors import javax.inject.Inject @@ -65,7 +63,6 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere removeSecurityCryptoData(this, prefs) runMigrations(filesDirPath, prefs, gitSettings) proxyUtils.setDefaultProxy() - DynamicColors.applyToActivitiesIfAvailable(this) setupScreenOffHandler() } @@ -92,49 +89,35 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere } private fun setVmPolicy() { - val builder = + StrictMode.setVmPolicy( StrictMode.VmPolicy.Builder() .detectActivityLeaks() .detectFileUriExposure() .detectLeakedClosableObjects() .detectLeakedRegistrationObjects() .detectLeakedSqlLiteObjects() - - builder.detectContentUriWithoutPermission() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.detectCredentialProtectedWhileLocked().detectImplicitDirectBoot() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - builder.detectNonSdkApiUsage() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - builder.detectIncorrectContextUse().detectUnsafeIntentLaunch() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - builder.penaltyListener(Executors.newSingleThreadExecutor()) { violation -> - logcat(VERBOSE) { violation.stackTraceToString() } - } - } else { - builder.penaltyLog() - } - - val policy = builder.build() - StrictMode.setVmPolicy(policy) + .detectContentUriWithoutPermission() + .detectCredentialProtectedWhileLocked() + .detectImplicitDirectBoot() + .detectNonSdkApiUsage() + .detectIncorrectContextUse() + .detectUnsafeIntentLaunch() + .penaltyListener(Executors.newSingleThreadExecutor()) { violation -> + logcat(VERBOSE) { violation.stackTraceToString() } + } + .build() + ) } private fun setNightMode() { - setDefaultNightMode( - when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { - "light" -> MODE_NIGHT_NO - "dark" -> MODE_NIGHT_YES - "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM - else -> MODE_NIGHT_AUTO_BATTERY - } - ) + getSystemService(UiModeManager::class.java) + .setApplicationNightMode( + when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) { + "light" -> UiModeManager.MODE_NIGHT_NO + "dark" -> UiModeManager.MODE_NIGHT_YES + else -> UiModeManager.MODE_NIGHT_AUTO + } + ) } companion object { diff --git a/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt index 46e61397..1717e4cc 100644 --- a/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt +++ b/app/src/main/java/app/passwordstore/injection/AutofillResponseBuilderModule.kt @@ -5,8 +5,6 @@ package app.passwordstore.injection -import android.os.Build -import app.passwordstore.util.autofill.Api26AutofillResponseBuilder import app.passwordstore.util.autofill.Api30AutofillResponseBuilder import app.passwordstore.util.autofill.AutofillResponseBuilder import dagger.Module @@ -21,11 +19,6 @@ object AutofillResponseBuilderModule { @Provides @Reusable - fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Api30AutofillResponseBuilder.Factory - } else { - Api26AutofillResponseBuilder.Factory - } - } + fun provideAutofillResponseBuilder(): AutofillResponseBuilder.Factory = + Api30AutofillResponseBuilder.Factory } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 7ba4df74..3b62de4c 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -9,14 +9,12 @@ 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 android.widget.Toast 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 @@ -25,6 +23,7 @@ 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.FragmentActivity import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff import app.passwordstore.R @@ -71,7 +70,7 @@ import logcat.logcat * FLAG_SECURE window shows only the "no keys" and passphrase dialogs. */ @AndroidEntryPoint -class AutofillDecryptActivity : AppCompatActivity() { +class AutofillDecryptActivity : FragmentActivity() { @SettingsPreferences @Inject lateinit var settings: SharedPreferences @Inject lateinit var repository: CryptoRepository @@ -368,11 +367,7 @@ class AutofillDecryptActivity : AppCompatActivity() { context, decryptFileRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index c607b81f..474005b5 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -8,19 +8,18 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender -import android.os.Build import android.os.Bundle import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository @@ -44,7 +43,7 @@ import logcat.LogPriority.ERROR import logcat.logcat @AndroidEntryPoint -class AutofillFilterView : AppCompatActivity() { +class AutofillFilterView : FragmentActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -74,11 +73,7 @@ class AutofillFilterView : AppCompatActivity() { context, matchAndDecryptFileRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } @@ -133,7 +128,6 @@ class AutofillFilterView : AppCompatActivity() { return } } - supportActionBar?.hide() setResult(RESULT_CANCELED) val isWeb = formOrigin is FormOrigin.Web 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 803896a5..74637ca1 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -9,14 +9,13 @@ import android.content.Context import android.content.Intent import android.content.IntentSender import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.service.autofill.FillResponse import android.text.format.DateUtils import android.view.autofill.AutofillManager import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.IntentCompat +import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.util.autofill.AutofillMatcher @@ -31,7 +30,7 @@ import com.github.michaelbull.result.runCatching import logcat.LogPriority.ERROR import logcat.logcat -class AutofillPublisherChangedActivity : AppCompatActivity() { +class AutofillPublisherChangedActivity : FragmentActivity() { companion object { @@ -54,11 +53,7 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { context, publisherChangedRequestCode++, intent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_CANCEL_CURRENT - }, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, ) .intentSender } @@ -79,7 +74,6 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { finish() return } - supportActionBar?.hide() val (packageInfo, appInfo, hash) = runCatching { Triple( 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 eb533411..2ed0adef 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -12,14 +12,15 @@ import android.os.Bundle import android.view.WindowManager import android.view.autofill.AutofillManager import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentActivity import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.ui.compose.theme.APSTheme import app.passwordstore.ui.editor.EditorDestination import app.passwordstore.ui.editor.EditorForm import app.passwordstore.ui.editor.SaveResult import app.passwordstore.ui.editor.passContent +import app.passwordstore.ui.prompts.PromptHost import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder @@ -38,7 +39,7 @@ import logcat.logcat * password, fills it in. */ @AndroidEntryPoint -class AutofillSaveActivity : AppCompatActivity() { +class AutofillSaveActivity : FragmentActivity() { companion object { @@ -139,6 +140,7 @@ class AutofillSaveActivity : AppCompatActivity() { onSaved = ::finishSaved, generate = generate, ) + PromptHost(this@AutofillSaveActivity) } } } diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt index 67343a2e..626ec130 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -7,9 +7,7 @@ package app.passwordstore.ui.crypto import android.content.ClipData import android.content.Intent -import android.os.Build import android.os.PersistableBundle -import androidx.annotation.StringRes import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier @@ -17,7 +15,6 @@ 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 @@ -51,23 +48,15 @@ fun getLongName(fullPath: String, repositoryPath: String, basename: String): Str } } -/** - * 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, -) { +/** Copies provided [text] to the clipboard; [sensitive] text is hidden from its preview. */ +fun FragmentActivity.copyTextToClipboard(text: String?, sensitive: Boolean = true) { val clipboard = clipboard ?: return val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - 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)) + if (sensitive) { + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } } + clipboard.setPrimaryClip(clip) } /** diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt index b5a961d3..617d804a 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt @@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts.GetContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.compose.foundation.layout.Box import androidx.compose.material3.AlertDialog +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -34,14 +35,15 @@ 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.git.base.BaseGitActivity import app.passwordstore.ui.list.FolderPicker +import app.passwordstore.ui.list.commitAndPush 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 @@ -142,18 +144,21 @@ fun EditorDestination( R.string.password_creation_file_delete_fail_message, it.nameWithoutExtension, ), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) } } // ponytail: commit failures are not shown, as for the list's move and delete. A commit cut // short by recreation runs again; one that already went through finds nothing to commit. - activity.commitChange( + val message = 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"), ) - ) + // ponytail: AutofillSaveActivity closes right after saving, which would cancel a push, so its + // saves stay local until the next push or sync. + if (activity is BaseGitActivity) activity.commitAndPush(message) + else activity.commitChange(message) keyboard?.hide() focusManager.clearFocus() viewModel.left() diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt index 6d2fbaec..fe5ee772 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -10,8 +10,6 @@ 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 @@ -168,12 +166,8 @@ constructor( 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) - } + ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) + .copy(Bitmap.Config.ARGB_8888, false) val pixels = IntArray(bitmap.width * bitmap.height) bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) val source = RGBLuminanceSource(bitmap.width, bitmap.height, pixels) diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt index 32872761..f7181c4e 100644 --- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -5,10 +5,11 @@ package app.passwordstore.ui.git.base import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.git.ErrorMessages @@ -25,10 +26,8 @@ import com.github.michaelbull.result.Err import com.github.michaelbull.result.Result import com.github.michaelbull.result.andThen import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat import net.schmizz.sshj.common.DisconnectReason @@ -37,11 +36,11 @@ import net.schmizz.sshj.transport.TransportException import net.schmizz.sshj.userauth.UserAuthException /** - * Abstract [AppCompatActivity] that holds some information that is commonly shared across + * Abstract [FragmentActivity] that holds some information that is commonly shared across * git-related tasks and makes sense to be held here. */ @AndroidEntryPoint -abstract class BaseGitActivity : AppCompatActivity() { +abstract class BaseGitActivity : FragmentActivity() { /** Enum of possible Git operations than can be run through [launchGitOperation]. */ enum class GitOp { @@ -100,23 +99,17 @@ abstract class BaseGitActivity : AppCompatActivity() { finish() } - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + suspend fun promptOnErrorHandler(err: Throwable) { val error = rootCauseException(err) if (!isExplicitlyUserInitiatedError(error)) { gitPrefs.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } logcat { error.asLog() } - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { onPromptDone() } - show() - } - } - } else { - onPromptDone() + prompts.ask( + title = getString(R.string.jgit_error_dialog_title), + message = ErrorMessages[error], + confirm = getString(R.string.dialog_ok), + ) } } diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index 54a7e66c..97092716 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -73,6 +73,7 @@ fun ListDestination( onNewEntry: () -> Unit, onClone: () -> Unit, onSettings: () -> Unit, + onAbout: () -> Unit, onNoStore: () -> Unit, modifier: Modifier = Modifier, viewModel: SearchableRepositoryViewModel = hiltViewModel(), @@ -159,6 +160,7 @@ fun ListDestination( }, onGitOp = { op -> ifInitialized { runGit(op) } }, onSettings = onSettings, + onAbout = onAbout, onNew = { dialog = ListDialog.Create }, onMove = { dialog = ListDialog.Move }, onDelete = { diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt index 1b359d5b..593c744b 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt @@ -91,6 +91,7 @@ fun ListScreen( onSync: () -> Unit, onGitOp: (GitOp) -> Unit, onSettings: () -> Unit, + onAbout: () -> Unit, onNew: () -> Unit, onMove: () -> Unit, onDelete: () -> Unit, @@ -133,7 +134,7 @@ fun ListScreen( SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin) } else { Column(horizontalAlignment = Alignment.CenterHorizontally) { - SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus) + SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, onAbout, searchFocus) BreadcrumbBar( crumbsFor(path), stringResource(R.string.list_store_root), @@ -238,6 +239,7 @@ private fun SearchField( onQueryChange: (String) -> Unit, onGitOp: (GitOp) -> Unit, onSettings: () -> Unit, + onAbout: () -> Unit, focus: FocusRequester, ) { val focusManager = LocalFocusManager.current @@ -282,6 +284,13 @@ private fun SearchField( onSettings() }, ) + DropdownMenuItem( + text = { Text(stringResource(R.string.about)) }, + onClick = { + menu = false + onAbout() + }, + ) } } } diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 3bdede73..f300a814 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -6,11 +6,15 @@ package app.passwordstore.ui.list import android.content.Context +import androidx.compose.material3.SnackbarDuration import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.git.base.BaseGitActivity +import app.passwordstore.ui.prompts.Choice +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.extensions.base64 import app.passwordstore.util.extensions.commitChange @@ -18,15 +22,17 @@ 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.extensions.snackbar import app.passwordstore.util.settings.PasswordSortOrder import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import java.io.File import java.nio.file.Files -import kotlin.coroutines.resume -import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR +import logcat.asLog import logcat.logcat /** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */ @@ -65,6 +71,27 @@ internal fun File.deleteWithoutFollowingLinks(): Boolean { return delete() } +/** + * Commits the store and, when the user asked for it, pushes without holding up the caller. A failed + * push only says so: the commit stays local, and [BaseGitActivity.promptOnErrorHandler] would + * delete the saved credentials over what may just be a missing network. + * + * ponytail: a push cut off by recreation is not resumed; the next one sends everything. An SSH key + * behind biometrics prompts on each change. + */ +suspend fun BaseGitActivity.commitAndPush(message: String) { + commitChange(message).onSuccess { + if (gitSettings.pushAfterCommit && gitSettings.url != null) { + lifecycleScope.launch { + launchGitOperation(BaseGitActivity.GitOp.PUSH).onFailure { + logcat(ERROR) { it.asLog() } + snackbar(getString(R.string.git_push_after_commit_failed), SnackbarDuration.Long) + } + } + } + } +} + /** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ suspend fun BaseGitActivity.deleteItems(files: List<File>) { val root = PasswordRepository.getRepositoryDirectory() @@ -73,7 +100,7 @@ suspend fun BaseGitActivity.deleteItems(files: List<File>) { files.forEach { it.deleteWithoutFollowingLinks() } AutofillMatcher.updateMatches(applicationContext, delete = deleted) } - commitChange( + commitAndPush( getString( R.string.git_commit_remove_text, files.joinToString(", ") { it.toRelativeString(root) }, @@ -117,14 +144,14 @@ suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) { val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/") getString(R.string.git_commit_move_multiple_text, relative) } - commitChange(message) + commitAndPush(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)) + commitAndPush(getString(R.string.git_commit_move_text, from.name, to.name)) } /** Carries the "recently used" time of [from] over to [to]. */ @@ -152,28 +179,21 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File): B } } 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() + logcat(ERROR) { "Something went wrong while moving a file." } + prompts.ask( + title = getString(R.string.password_move_error_title), + message = getString(R.string.password_move_error_message, source, destination), + confirm = getString(R.string.dialog_ok), + ) } return moved } /** Shows [message] under [title] and suspends until answered; true when confirmed. */ -// 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() } - } +private suspend fun BaseGitActivity.confirm(title: String, message: String): Boolean = + prompts.ask( + title = title, + message = message, + confirm = getString(R.string.dialog_ok), + cancel = getString(R.string.dialog_cancel), + ) == Choice.Confirm diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index c0af5742..54aaf9de 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -9,8 +9,8 @@ import android.content.Intent import android.os.Bundle import android.os.Handler import android.os.Looper -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result @@ -19,7 +19,7 @@ import app.passwordstore.util.settings.PreferenceKeys import java.io.File @SuppressLint("CustomSplashScreen") -class LaunchActivity : AppCompatActivity() { +class LaunchActivity : FragmentActivity() { private var prompted = 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 index 462bdb18..5037e646 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -29,6 +29,8 @@ import app.passwordstore.ui.entry.EntryViewModel import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.ui.list.ListDestination import app.passwordstore.ui.list.folderMoves +import app.passwordstore.ui.prompts.PromptHost +import app.passwordstore.ui.settings.AboutRoute import app.passwordstore.ui.settings.SettingsRoute import app.passwordstore.ui.settings.settingsGraph import app.passwordstore.ui.setup.GitServerRoute @@ -70,8 +72,8 @@ import kotlinx.serialization.Serializable /** * 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. + * [BaseGitActivity] (a FragmentActivity, which BiometricPrompt needs) because the git operations + * require one; it hosts no fragments. */ @AndroidEntryPoint class MainActivity : BaseGitActivity() { @@ -129,6 +131,7 @@ class MainActivity : BaseGitActivity() { onSettings = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute) }, + onAbout = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(AboutRoute) }, onNoStore = { // The store is gone (deleted from Settings): setup starts over. if (nav.currentBackStackEntry?.id == entry.id) @@ -199,6 +202,7 @@ class MainActivity : BaseGitActivity() { setupGraph(this@MainActivity, nav) settingsGraph(this@MainActivity, nav) } + PromptHost(this@MainActivity) } } } @@ -263,7 +267,7 @@ class MainActivity : BaseGitActivity() { */ // 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. + // key" dialog could navigate in-graph if git operations could reach the nav controller. fun setupIntent(context: Context, start: SetupStart): Intent = Intent(context, MainActivity::class.java).putExtra(EXTRA_SETUP, start.name) } diff --git a/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt new file mode 100644 index 00000000..1af3f01c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/prompts/Prompts.kt @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import app.passwordstore.R +import app.passwordstore.ui.compose.R as composeR +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine + +/** The button that answered a [Question]. Dismissing the dialog answers [Cancel]. */ +enum class Choice { + Confirm, + Other, + Cancel, +} + +/** What the user entered in a [CredentialRequest]. */ +class CredentialAnswer(val text: String, val remember: Boolean) + +/** A dialog waiting for its answer; [cancelled] is the answer to dismissing it. */ +sealed class Prompt<T>(private val cancelled: T, private val deliver: (T) -> Unit) { + internal fun answer(value: T) = deliver(value) + + internal fun cancel() = deliver(cancelled) +} + +class Question( + val title: String, + val message: String, + val confirm: String, + val cancel: String?, + val other: String?, + deliver: (Choice) -> Unit, +) : Prompt<Choice>(Choice.Cancel, deliver) + +class CredentialRequest( + val message: String, + val hint: String, + val remember: String, + val error: String?, + deliver: (CredentialAnswer?) -> Unit, +) : Prompt<CredentialAnswer?>(null, deliver) + +/** A snackbar; each one is a distinct object, so showing the same text again shows it again. */ +class Snack(val message: String, val length: SnackbarDuration) + +/** + * The dialogs and the snackbar of one activity, for code with no composable of its own (git + * operations and store actions), drawn by [PromptHost]. Reach it through [prompts]. + */ +class Prompts : ViewModel() { + + private val queue = mutableStateListOf<Prompt<*>>() + + /** Set once the activity is gone for good: nothing draws a new dialog any more. */ + private var closed = false + + /** The dialog on screen. The others wait for it to be answered. */ + val current: Prompt<*>? + get() = queue.firstOrNull() + + var snack by mutableStateOf<Snack?>(null) + private set + + /** Shows a dialog and suspends until it is answered. Cancelling the caller closes it. */ + suspend fun ask( + title: String, + message: String, + confirm: String, + cancel: String? = null, + other: String? = null, + ): Choice = suspendCancellableCoroutine { cont -> + val question = Question(title, message, confirm, cancel, other) { cont.resume(it) } + queue += question + cont.invokeOnCancellation { queue.remove(question) } + } + + /** + * Asks for a credential. Not suspending: the SSH thread waits on [deliver], and no cancellation + * reaches it, so this dialog outlives a recreated activity and is drawn again by the new one. + */ + fun askCredential( + message: String, + hint: String, + remember: String, + error: String?, + deliver: (CredentialAnswer?) -> Unit, + ) { + if (closed) { + deliver(null) + return + } + queue += CredentialRequest(message, hint, remember, error, deliver) + } + + /** Answers [prompt], once: a double tap must not resume its caller twice. */ + fun <T> answer(prompt: Prompt<T>, value: T) { + if (queue.remove(prompt)) prompt.answer(value) + } + + fun cancel(prompt: Prompt<*>) { + if (queue.remove(prompt)) prompt.cancel() + } + + fun cancelAll() { + queue.toList().forEach(::cancel) + } + + /** Shows [message] in place of any current snackbar, as the View snackbar did. */ + fun snackbar(message: String, length: SnackbarDuration = SnackbarDuration.Short): Snack = + Snack(message, length).also { snack = it } + + /** Hides [shown], unless another snackbar replaced it. */ + fun dismiss(shown: Snack) { + if (snack === shown) snack = null + } + + internal fun close() { + closed = true + cancelAll() + } + + override fun onCleared() = close() +} + +/** The [Prompts] of this activity; it survives recreation. */ +val FragmentActivity.prompts: Prompts + get() = ViewModelProvider(this)[Prompts::class.java] + +/** Draws the [Prompts] of [activity]. Place it after the activity's content, inside `APSTheme`. */ +@Composable +fun PromptHost(activity: FragmentActivity, modifier: Modifier = Modifier) { + val prompts = remember(activity) { activity.prompts } + val snackbars = remember { SnackbarHostState() } + val snack = prompts.snack + // A new snack cancels this effect, which takes the old one off the screen. + LaunchedEffect(snack) { + if (snack != null) { + snackbars.showSnackbar(snack.message, duration = snack.length) + prompts.dismiss(snack) + } + } + Box(modifier.fillMaxSize().navigationBarsPadding().imePadding(), Alignment.BottomCenter) { + SnackbarHost(snackbars) + } + val prompt = prompts.current + // Keyed: a retry is a new request and starts with an empty field. + key(prompt) { + when (prompt) { + is Question -> QuestionDialog(prompt, prompts) + is CredentialRequest -> CredentialDialog(prompt, prompts) + null -> {} + } + } +} + +@Composable +private fun QuestionDialog(question: Question, prompts: Prompts) { + AlertDialog( + onDismissRequest = { prompts.cancel(question) }, + title = { Text(question.title) }, + text = { Text(question.message) }, + confirmButton = { + TextButton(onClick = { prompts.answer(question, Choice.Confirm) }) { Text(question.confirm) } + }, + dismissButton = { + // No Row: the dialog's button area wraps its children when they do not fit. + question.cancel?.let { + TextButton(onClick = { prompts.answer(question, Choice.Cancel) }) { Text(it) } + } + question.other?.let { + TextButton(onClick = { prompts.answer(question, Choice.Other) }) { Text(it) } + } + }, + ) +} + +/** The credential lives only in `remember` and the dialog's window is FLAG_SECURE. */ +@Composable +private fun CredentialDialog(request: CredentialRequest, prompts: Prompts) { + // remember, never rememberSaveable: the credential must not reach saved state. + var text by remember { mutableStateOf("") } + var keep by remember { mutableStateOf(true) } + var revealed by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(request.error) } + val focus = remember { FocusRequester() } + fun submit() = prompts.answer(request, CredentialAnswer(text, keep)) + AlertDialog( + onDismissRequest = { prompts.cancel(request) }, + properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), + title = { Text(stringResource(R.string.passphrase_dialog_title)) }, + text = { + Column { + Text(request.message) + OutlinedTextField( + value = text, + onValueChange = { + text = it + error = null + }, + modifier = Modifier.focusRequester(focus), + label = { Text(request.hint) }, + supportingText = error?.let { { Text(it) } }, + isError = error != null, + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + painterResource( + if (revealed) composeR.drawable.baseline_visibility_off_24 + else composeR.drawable.baseline_visibility_24 + ), + contentDescription = + stringResource( + if (revealed) R.string.entry_hide_password else R.string.entry_show_password + ), + ) + } + }, + visualTransformation = + if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + singleLine = true, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + ) + Row( + Modifier.toggleable(value = keep, role = Role.Checkbox, onValueChange = { keep = it }), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = keep, onCheckedChange = null) + Text(request.remember) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + }, + confirmButton = { TextButton(onClick = ::submit) { Text(stringResource(R.string.dialog_ok)) } }, + dismissButton = { + TextButton(onClick = { prompts.cancel(request) }) { + Text(stringResource(R.string.dialog_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt new file mode 100644 index 00000000..4a366390 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.fragment.app.FragmentActivity +import app.passwordstore.BuildConfig +import app.passwordstore.R +import app.passwordstore.ui.compose.theme.SpacingMedium +import app.passwordstore.ui.crypto.copyTextToClipboard +import app.passwordstore.util.extensions.getPackageInfoCompat +import app.passwordstore.util.extensions.snackbar +import java.security.MessageDigest + +private const val REPOSITORY = "https://github.com/danixland/HardenedPass" +private const val DOCUMENTATION = "https://danix.xyz/hardenedpass" +private const val KO_FI = "https://ko-fi.com/K6Y520C9I6" + +/** + * What ships in the APK and its license, by hand: update it when a dependency comes or goes. A + * generating plugin would be one more build dependency for a list this short. + */ +private val LICENSES = + listOf( + "AndroidX, Jetpack Compose" to "Apache-2.0", + "Apache Commons Codec, Curtains, Guava, JavaEWAH, JSpecify, uri-kmp" to "Apache-2.0", + "Bouncy Castle" to "Bouncy Castle Licence (MIT)", + "Dagger, Hilt" to "Apache-2.0", + "EFF Diceware word list" to "CC-BY", + "JGit" to "EDL-1.0 (BSD-3-Clause)", + "kotlin-result" to "ISC", + "Kotlin, kotlinx" to "Apache-2.0", + "LeakCanary plumber, logcat" to "Apache-2.0", + "Manrope" to "OFL-1.1", + "PGPainless" to "Apache-2.0", + "Public Suffix List" to "MPL-2.0", + "SLF4J" to "MIT", + "sshj" to "Apache-2.0", + "sublime-fuzzy" to "MIT", + "ZXing, zxing-android-embedded" to "Apache-2.0", + ) + +/** `0.3.0 (300)`, followed by the [commit] on the debug builds that have one. */ +internal fun versionLine(name: String, code: Int, commit: String): String = + if (commit.isEmpty()) "$name ($code)" else "$name ($code) · $commit" + +/** The changelog at the tag of [versionName], without a suffix like `-debug`. */ +internal fun changelogUrl(versionName: String): String = + "$REPOSITORY/blob/v${versionName.substringBefore('-')}/CHANGELOG.md" + +/** SHA-256 of [certificate] as `apksigner` and `keytool` users compare it, colon separated. */ +internal fun certificateFingerprint(certificate: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(certificate).joinToString(":") { "%02X".format(it) } + +/** The certificate this copy was really signed with, so it can be compared with the published. */ +private fun Context.signingFingerprint(): String? = + packageManager + .getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + .signingInfo + ?.apkContentsSigners + ?.firstOrNull() + ?.let { certificateFingerprint(it.toByteArray()) } + +/** The about screen. Links leave for the browser: the app itself makes no connection for them. */ +@Composable +fun AboutDestination( + activity: FragmentActivity, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val fingerprint = remember { activity.signingFingerprint() } + AboutScreen( + version = + versionLine(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT), + changelog = changelogUrl(BuildConfig.VERSION_NAME), + fingerprint = fingerprint, + onCopy = { activity.copyTextToClipboard(it, sensitive = false) }, + onOpen = { url -> + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) + } catch (_: ActivityNotFoundException) { + activity.snackbar(message = activity.getString(R.string.about_no_browser)) + } + }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun AboutScreen( + version: String, + changelog: String, + fingerprint: String?, + onCopy: (String) -> Unit, + onOpen: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + SettingsScaffold(stringResource(R.string.about), onBack, modifier) { + Column( + Modifier.fillMaxWidth().padding(SpacingMedium), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(SpacingMedium), + ) { + Box(Modifier.size(96.dp).clip(CircleShape)) { + Image(painterResource(R.drawable.ic_launcher_background), contentDescription = null) + Image(painterResource(R.drawable.ic_launcher_foreground), contentDescription = null) + } + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.about_summary), textAlign = TextAlign.Center) + } + AboutRow(stringResource(R.string.about_version), version) { onCopy(version) } + LinkRow(stringResource(R.string.about_docs)) { onOpen(DOCUMENTATION) } + LinkRow(stringResource(R.string.about_changelog)) { onOpen(changelog) } + LinkRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) } + LinkRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") } + // White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi + // asks that the logo itself stays as it is. + Button( + onClick = { onOpen(KO_FI) }, + modifier = Modifier.fillMaxWidth().padding(SpacingMedium), + colors = + ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Image( + painterResource(R.drawable.ic_kofi_symbol), + contentDescription = null, + modifier = Modifier.height(24.dp), + ) + Box(Modifier.width(SpacingMedium)) + Text(stringResource(R.string.about_support)) + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_privacy_title)) }, + supportingContent = { Text(stringResource(R.string.about_privacy)) }, + colors = rowColors(), + ) + if (fingerprint != null) + ListItem( + headlineContent = { Text(stringResource(R.string.about_certificate)) }, + supportingContent = { Text(fingerprint, fontFamily = FontFamily.Monospace) }, + modifier = Modifier.clickable(role = Role.Button) { onCopy(fingerprint) }, + colors = rowColors(), + ) + AboutRow(stringResource(R.string.about_license), stringResource(R.string.about_license_text)) { + onOpen("$REPOSITORY/blob/master/LICENSE.txt") + } + ListItem( + headlineContent = { Text(stringResource(R.string.about_libraries)) }, + colors = rowColors(), + ) + LICENSES.forEach { (name, license) -> + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(license) }, + colors = rowColors(), + ) + } + } +} + +@Composable +private fun AboutRow( + title: String, + summary: String? = null, + trailing: (@Composable () -> Unit)? = null, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(title) }, + supportingContent = summary?.let { { Text(it) } }, + trailingContent = trailing, + modifier = Modifier.clickable(role = Role.Button, onClick = onClick), + colors = rowColors(), + ) +} + +/** A row that leaves for the browser, and says so. */ +@Composable +private fun LinkRow(title: String, summary: String? = null, onClick: () -> Unit) { + AboutRow( + title = title, + summary = summary, + trailing = { + Icon( + painterResource(R.drawable.ic_open_in_new_24dp), + contentDescription = stringResource(R.string.about_opens_browser), + ) + }, + onClick = onClick, + ) +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt index d008eea3..ed07c389 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/GitConfigScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -54,7 +55,6 @@ 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 /** @@ -223,7 +223,7 @@ fun GitConfigDestination( } activity.snackbar( message = activity.getString(R.string.git_tool_done, activity.getString(label)), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) viewModel.done = null } diff --git a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt index fb2bfe93..10f82178 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/ProxyViewModel.kt @@ -6,7 +6,6 @@ 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 @@ -19,10 +18,7 @@ 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 || + return InetAddresses.isNumericAddress(text) || (!text.contains(':') && !text.contains('/') && Patterns.DOMAIN_NAME.matcher(text).matches()) } diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt index c3809b36..05dc6b65 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt @@ -38,6 +38,7 @@ import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.ui.compose.theme.SpacingLarge @@ -52,6 +53,7 @@ import app.passwordstore.util.services.PasswordExportService import app.passwordstore.util.settings.PreferenceKeys import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel +import kotlinx.coroutines.launch private enum class SettingsDialog { Autofill, @@ -125,6 +127,16 @@ fun SettingsSectionDestination( ) } + val exportLog = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) + activity.lifecycleScope.launch { + val message = + if (viewModel.exportLog(uri)) R.string.log_exported else R.string.log_export_failed + activity.snackbar(message = activity.getString(message)) + } + } + // 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) } @@ -206,6 +218,7 @@ fun SettingsSectionDestination( SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository SettingsAction.Export -> export.launch(null) + SettingsAction.ExportLog -> exportLog.launch("hardenedpass-log.txt") else -> onOpen(action) } }, diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt index ae4e277b..e45166e0 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -30,6 +30,9 @@ import kotlinx.serialization.Serializable /** The store's commit log. */ @Serializable data object GitLogRoute +/** Version, links, certificate and licenses. */ +@Serializable data object AboutRoute + /** HTTP(S) proxy settings. */ @Serializable data object ProxyRoute @@ -47,6 +50,9 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController onBack = { close(entry) }, ) } + composable<AboutRoute> { entry -> + AboutDestination(activity = activity, onBack = { close(entry) }) + } composable<SettingsSectionRoute> { entry -> SettingsSectionDestination( activity = activity, @@ -64,7 +70,8 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController // Handled by the destination itself. SettingsAction.ClearSecret, SettingsAction.DeleteRepository, - SettingsAction.Export -> Unit + SettingsAction.Export, + SettingsAction.ExportLog -> Unit } }, onDeleted = { diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt index eca6770c..54250f74 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -9,6 +9,7 @@ import app.passwordstore.util.extensions.hash import app.passwordstore.util.extensions.time import app.passwordstore.util.git.GitCommit import java.io.IOException +import java.io.OutputStream import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -77,3 +78,17 @@ fun gitLog(repo: Repository?): List<GitCommit> { emptyList() } } + +/** + * Writes [header], then what logcat holds for this app: Android shows an app only its own lines, + * and those of its earlier runs and crashes until the buffer rotates. + */ +fun writeAppLog(header: String, out: OutputStream) { + out.write(header.toByteArray()) + val logcat = + ProcessBuilder("logcat", "-d", "-b", "main,crash", "-v", "threadtime") + .redirectErrorStream(true) + .start() + logcat.inputStream.use { it.copyTo(out) } + logcat.waitFor() +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt index 7d45ad74..2e7840ad 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.ui.compose.GroupedSurface import app.passwordstore.util.features.Feature +import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable @@ -62,12 +63,13 @@ enum class SettingsAction { ClearSecret, DeleteRepository, Export, + ExportLog, } /** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsScaffold( +internal fun SettingsScaffold( title: String, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -229,7 +231,7 @@ fun SettingsSectionScreen( summary = stringResource( R.string.pref_clipboard_timeout_summary, - state.clipboardTimeout.ifEmpty { "45" }, + state.clipboardTimeout.ifEmpty { Constants.DEFAULT_DECRYPTION_TIMEOUT.toString() }, ), keyboardType = KeyboardType.Number, ) @@ -257,6 +259,12 @@ fun SettingsSectionScreen( else R.string.pref_rebase_on_pull_summary ), ) + PreferenceSwitch( + title = stringResource(R.string.pref_push_after_commit_title), + checked = state.pushAfterCommit, + onCheckedChange = { onBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, it) }, + summary = stringResource(R.string.pref_push_after_commit_summary), + ) if (state.isGitRepo) { PreferenceAction( title = stringResource(R.string.pref_edit_git_server_settings), @@ -317,6 +325,11 @@ fun SettingsSectionScreen( onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) }, summary = stringResource(R.string.pref_debug_logging_summary), ) + PreferenceAction( + title = stringResource(R.string.pref_export_log_title), + summary = stringResource(R.string.pref_export_log_summary), + onClick = { onAction(SettingsAction.ExportLog) }, + ) } SettingsSection.Pgp -> { PreferenceAction( diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt index ef604f1f..90aecb2a 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt @@ -8,6 +8,8 @@ package app.passwordstore.ui.settings import android.app.Application import android.content.SharedPreferences import android.content.pm.ShortcutManager +import android.net.Uri +import android.os.Build import androidx.annotation.StringRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,6 +18,7 @@ import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.repo.PasswordRepository @@ -31,12 +34,16 @@ 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.io.IOException 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 +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat /** What the settings sections show: stored values, and the state rows depend on. */ data class SettingsState( @@ -55,6 +62,7 @@ data class SettingsState( val showPassword: Boolean, val copyOnDecrypt: Boolean, val rebaseOnPull: Boolean, + val pushAfterCommit: Boolean, val isGitRepo: Boolean, val httpsRemote: Boolean, val canShowSshKey: Boolean, @@ -110,6 +118,27 @@ constructor( state = read() } + /** Saves the app's log to [uri]; false when that failed. */ + suspend fun exportLog(uri: Uri): Boolean = + withContext(dispatcherProvider.io()) { + val header = + "HardenedPass ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}), " + + "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MODEL}\n\n" + try { + val out = + getApplication<Application>().contentResolver.openOutputStream(uri) + ?: throw IOException("No stream for the chosen file") + out.use { writeAppLog(header, it) } + true + } catch (e: IOException) { + logcat(ERROR) { e.asLog() } + false + } catch (e: SecurityException) { + logcat(ERROR) { e.asLog() } + false + } + } + fun putBoolean(key: String, value: Boolean) { prefs.edit { putBoolean(key, value) } } @@ -205,6 +234,7 @@ constructor( showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true), copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false), rebaseOnPull = gitSettings.rebaseOnPull, + pushAfterCommit = gitSettings.pushAfterCommit, isGitRepo = PasswordRepository.isGitRepo(), httpsRemote = gitSettings.url?.startsWith("https") == true, canShowSshKey = SshKey.canShowSshPublicKey, diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt index fd440996..4b24375b 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -60,7 +61,6 @@ 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 /** @@ -210,7 +210,7 @@ fun GitServerDestination( BackHandler(enabled = viewModel.progress != null) {} fun say(message: String) { - activity.snackbar(message = message, length = Snackbar.LENGTH_LONG) + activity.snackbar(message = message, length = SnackbarDuration.Long) } // ponytail: a theme or locale change cancels the clone with the activity's scope, like every diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt index 11fd979c..6004ec90 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -59,7 +60,6 @@ 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 @@ -269,7 +269,7 @@ fun PgpKeysDestination( if (error != null) { activity.snackbar( message = activity.getString(R.string.store_create_failed, error), - length = Snackbar.LENGTH_LONG, + length = SnackbarDuration.Long, ) } else { // A failed commit is reported like any git error; the store exists either way, and diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt index f93fc9be..390f1160 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt @@ -195,7 +195,7 @@ fun SshKeyDestination( busy = viewModel.busy, onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) }, onGenerate = onGenerate, - onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } }, + onCopy = { publicKey?.let { activity.copyTextToClipboard(it, sensitive = false) } }, onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } }, onBack = { if (!viewModel.busy) onClose() }, ) 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 98f2229a..8f912879 100644 --- a/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt +++ b/app/src/main/java/app/passwordstore/util/auth/BiometricAuthenticator.kt @@ -5,7 +5,6 @@ package app.passwordstore.util.auth import android.app.KeyguardManager -import android.os.Build import androidx.annotation.StringRes import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators @@ -23,14 +22,10 @@ object BiometricAuthenticator { /** * Keystore keys that require user authentication are unlocked only by the device credential or a * Class 3 (strong) biometric, so a weak biometric would pass the prompt and then fail to sign or - * decrypt with `User not authenticated`. Android 9 and 10 do not support combining - * [Authenticators.BIOMETRIC_STRONG] with [Authenticators.DEVICE_CREDENTIAL], so they keep the - * weak class. + * decrypt with `User not authenticated`. */ private val VALID_AUTHENTICATORS = - Authenticators.DEVICE_CREDENTIAL or - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Authenticators.BIOMETRIC_STRONG - else Authenticators.BIOMETRIC_WEAK + Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_STRONG /** * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt deleted file mode 100644 index f38011a7..00000000 --- a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package app.passwordstore.util.autofill - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillRequest -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import app.passwordstore.ui.autofill.AutofillDecryptActivity -import app.passwordstore.ui.autofill.AutofillFilterView -import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity -import app.passwordstore.ui.autofill.AutofillSaveActivity -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import java.io.File -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat - -class Api26AutofillResponseBuilder private constructor(form: FillableForm) : - AutofillResponseBuilder { - - object Factory : AutofillResponseBuilder.Factory { - override fun create(form: FillableForm): AutofillResponseBuilder = - Api26AutofillResponseBuilder(form) - } - - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - @Suppress("DEPRECATION") - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - build() - } - } - - private fun makeMatchDataset(context: Context, file: File): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makeSearchDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) - } - - private fun makeGenerateDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, emptyList()) - val intentSender = - AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, - publisherChangedException, - fillResponseAfterReset, - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: - // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) { "saveFlags must not be null" } - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { - var datasetCount = 0 - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context)?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setHeader( - makeRemoteView( - context, - makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)), - ) - ) - } - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - /** Creates and returns a suitable [FillResponse] to the Autofill framework. */ - override fun fillCredentials(context: Context, fillRequest: FillRequest, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin) - .fold( - success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) }, - failure = { e -> - logcat(ERROR) { e.asLog() } - callback.onSuccess(makePublisherChangedResponse(context, e)) - }, - ) - } -} diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt index 55679b21..bf41327f 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt @@ -31,7 +31,6 @@ import logcat.asLog import logcat.logcat /** Implements [AutofillResponseBuilder]'s methods for API 30 and above */ -@RequiresApi(Build.VERSION_CODES.R) class Api30AutofillResponseBuilder private constructor(form: FillableForm) : AutofillResponseBuilder { diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt index dbaa5675..b4b3d017 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt @@ -194,7 +194,7 @@ class AutofillMatcher { .minus(oldNewPathMap.values) .map { match -> val newPath = oldNewPathMap[match] ?: return@map match - logcat { "Updating match for $key: $match --> $newPath" } + logcat { "Updating match for $key" } newPath } .toSet() diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt index bfa47d74..a98aefc5 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt @@ -6,7 +6,6 @@ package app.passwordstore.util.autofill import android.content.Context -import android.os.Build import android.os.Bundle import android.service.autofill.Dataset import android.service.autofill.FillCallback @@ -34,19 +33,11 @@ interface AutofillResponseBuilder { action: AutofillAction, ): Dataset { val scenario = AutofillScenario.fromClientState(clientState) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are rarely shown. // FIXME: We should clone the original dataset here and add the credentials to be filled // in. Otherwise, the entry in the cached list of datasets will be overwritten by the // fill-in dataset without any visual representation. This causes it to be missing from // the Autofill suggestions shown after the user clears the filled out form fields. - val builder = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - @Suppress("DEPRECATION") Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) - } - return builder.run { + return Dataset.Builder().run { if (scenario != null) fillWith(scenario, action, credentials) else logcat(LogPriority.ERROR) { "Failed to recover scenario from client state" } build() diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index 59fd5cca..321c111a 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -10,7 +10,6 @@ import android.content.Context import android.content.Intent import android.content.res.Resources import android.graphics.drawable.Icon -import android.os.Build import android.service.autofill.InlinePresentation import android.view.View import android.widget.RemoteViews @@ -47,8 +46,6 @@ fun makeInlinePresentation( imeSpec: InlinePresentationSpec, metadata: DatasetMetadata, ): InlinePresentation? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null - if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null val launchIntent = @@ -56,11 +53,7 @@ fun makeInlinePresentation( context, 0, Intent(context, LaunchActivity::class.java), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - }, + PendingIntent.FLAG_MUTABLE, ) val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { @@ -101,8 +94,6 @@ fun makeGenerateAndFillMetadata(context: Context) = R.drawable.ic_autofill_new_password, ) -fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) - fun makeWarningMetadata(context: Context) = DatasetMetadata( context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), diff --git a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt index f4d43e95..e23ec896 100644 --- a/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt +++ b/app/src/main/java/app/passwordstore/util/crypto/KeystorePreferences.kt @@ -7,7 +7,6 @@ package app.passwordstore.util.crypto import android.content.Context import android.content.SharedPreferences -import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.security.keystore.StrongBoxUnavailableException @@ -66,18 +65,14 @@ fun keystoreAesKey( .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .apply(configure) - .apply { - if (useStrongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(true) - } - } + .setIsStrongBoxBacked(useStrongBox) .build() return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { init(spec) generateKey() } } - if (!strongBox || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return generate(false) + if (!strongBox) return generate(false) return try { generate(true) } catch (_: StrongBoxUnavailableException) { @@ -91,14 +86,10 @@ fun keystoreAesKey( */ fun KeyGenParameterSpec.Builder.requireUserAuthentication(seconds: Int) { setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters( - seconds, - KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, - ) - } else { - @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(seconds) - } + setUserAuthenticationParameters( + seconds, + KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, + ) } /** diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt index 6528334d..e24f96ca 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt @@ -16,21 +16,21 @@ import android.content.pm.PackageManager.ApplicationInfoFlags import android.content.pm.PackageManager.PackageInfoFlags import android.os.Build import android.util.Base64 -import android.view.View import android.view.autofill.AutofillManager +import androidx.compose.material3.SnackbarDuration import androidx.core.content.getSystemService import androidx.fragment.app.FragmentActivity import app.passwordstore.BuildConfig -import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.Snack +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.crypto.keystorePreferences import app.passwordstore.util.git.operation.GitOperation import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.google.android.material.snackbar.Snackbar import logcat.logcat -/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */ +/** Get an instance of [AutofillManager] */ val Context.autofillManager: AutofillManager? get() = getSystemService() @@ -70,24 +70,19 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab git.commit().setAll(true).setMessage(message), ) - override fun preExecute(): Boolean { - logcat { "Committing with message: '$message'" } + override suspend fun preExecute(): Boolean { + logcat { "Committing" } return true } } .execute() } -/** Show a [Snackbar] in a [FragmentActivity]. */ +/** Shows a snackbar through this activity's [prompts]; it must host a `PromptHost`. */ fun FragmentActivity.snackbar( - view: View = findViewById(android.R.id.content), message: String, - length: Int = Snackbar.LENGTH_SHORT, -): Snackbar { - val snackbar = Snackbar.make(view, message, length) - snackbar.show() - return snackbar -} + length: SnackbarDuration = SnackbarDuration.Short, +): Snack = prompts.snackbar(message, length) /** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */ fun SharedPreferences.getString(key: String): String? = getString(key, null) diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt index feb2cb2d..6d497bce 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -6,8 +6,10 @@ package app.passwordstore.util.git import android.widget.Toast +import androidx.compose.material3.SnackbarDuration import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.extensions.unsafeLazy @@ -17,7 +19,6 @@ import app.passwordstore.util.git.operation.GitOperation import app.passwordstore.util.settings.GitSettings import com.github.michaelbull.result.Result import com.github.michaelbull.result.runCatching -import com.google.android.material.snackbar.Snackbar import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -45,10 +46,10 @@ class GitCommandExecutor( suspend fun execute(): Result<Unit, Throwable> { val gitSettings = hiltEntryPoint.gitSettings() val dispatcherProvider = hiltEntryPoint.dispatcherProvider() - val snackbar = + val running = activity.snackbar( message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, + length = SnackbarDuration.Indefinite, ) // Count the number of uncommitted files var nbChanges = 0 @@ -123,7 +124,7 @@ class GitCommandExecutor( } } } - .also { snackbar.dismiss() } + .also { activity.prompts.dismiss(running) } } @EntryPoint diff --git a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt index 4b4913d0..a08f2a35 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/BreakOutOfDetached.kt @@ -4,17 +4,17 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.RebaseCommand import org.eclipse.jgit.api.ResetCommand import org.eclipse.jgit.lib.RepositoryState -class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class BreakOutOfDetached(callingActivity: FragmentActivity) : GitOperation(callingActivity) { private val merging = repository.repositoryState == RepositoryState.MERGING private val localBranch = PasswordRepository.getCurrentBranch() @@ -50,16 +50,14 @@ class BreakOutOfDetached(callingActivity: AppCompatActivity) : GitOperation(call } } - override fun preExecute() = + override suspend fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage( - callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded) - ) - // Only closes the dialog: the calling activity is the app's main screen. - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok), null) - .show() + val resources = callingActivity.resources + callingActivity.prompts.ask( + title = resources.getString(R.string.git_abort_and_push_title), + message = resources.getString(R.string.git_break_out_of_detached_unneeded), + confirm = resources.getString(R.string.dialog_ok), + ) false } else { true diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt index 0386f674..0d74835f 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/CloneOperation.kt @@ -4,7 +4,7 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.Git import org.eclipse.jgit.api.GitCommand @@ -14,7 +14,7 @@ import org.eclipse.jgit.api.GitCommand * @param uri URL to clone the repository from * @param callingActivity the calling activity */ -class CloneOperation(callingActivity: AppCompatActivity, uri: String) : +class CloneOperation(callingActivity: FragmentActivity, uri: String) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt index e0ecb170..a0d6d16d 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/CredentialFinder.kt @@ -5,24 +5,17 @@ package app.passwordstore.util.git.operation -import android.annotation.SuppressLint import android.content.SharedPreferences -import android.view.LayoutInflater -import android.view.WindowManager import androidx.annotation.StringRes import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.injection.prefs.GitPreferences +import app.passwordstore.ui.prompts.Prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.sshj.InteractivePasswordFinder import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -32,6 +25,7 @@ import kotlin.coroutines.resume class CredentialFinder( private val callingActivity: FragmentActivity, + private val prompts: Prompts, private val authMode: AuthMode, dispatcherProvider: DispatcherProvider, ) : InteractivePasswordFinder(dispatcherProvider) { @@ -71,42 +65,18 @@ class CredentialFinder( if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } val storedCredential = gitOperationPrefs.getString(credentialPref, null) if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = - dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = - dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity) - .run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { putString(credentialPref, credential) } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } - setOnCancelListener { cont.resume(null) } - create() - } - .run { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - show() + val resources = callingActivity.resources + prompts.askCredential( + message = resources.getString(messageRes), + hint = resources.getString(hintRes), + remember = resources.getString(rememberRes), + error = if (isRetry) resources.getString(errorRes) else null, + ) { answer -> + if (answer?.remember == true) { + gitOperationPrefs.edit { putString(credentialPref, answer.text) } } + cont.resume(answer?.text) + } } else { cont.resume(storedCredential) } diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt index c1487fbf..64467f3e 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GcOperation.kt @@ -5,7 +5,7 @@ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.internal.storage.file.FileRepository import org.eclipse.jgit.internal.storage.file.GC @@ -14,7 +14,7 @@ import org.eclipse.jgit.internal.storage.file.GC * Run an aggressive garbage collection job on the repository, expiring every loose object to * achieve the best compression. */ -class GcOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class GcOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) { override val requiresAuth: Boolean = false override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index 25d99d6d..f4ed7cd1 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -5,11 +5,12 @@ package app.passwordstore.util.git.operation import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.prompts.Choice +import app.passwordstore.ui.prompts.prompts import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser @@ -24,7 +25,6 @@ import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -63,8 +63,6 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { EntryPointAccessors.fromApplication<GitOperationEntryPoint>(callingActivity) protected val repository = PasswordRepository.repository!! protected val git = Git(repository) - private val authActivity - get() = callingActivity as AppCompatActivity private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { @@ -131,22 +129,22 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { return operationResult } - private fun onMissingSshKeyFile() { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.button_label_import)) { _, _ - -> - getSshKey(false) - } - .setNegativeButton( - callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate) - ) { _, _ -> - getSshKey(true) - } - // 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() + private suspend fun onMissingSshKeyFile() { + val resources = callingActivity.resources + val choice = + callingActivity.prompts.ask( + title = resources.getString(R.string.ssh_preferences_dialog_title), + message = resources.getString(R.string.ssh_preferences_dialog_text), + confirm = resources.getString(R.string.button_label_import), + cancel = resources.getString(R.string.dialog_cancel), + other = resources.getString(R.string.ssh_preferences_dialog_generate), + ) + when (choice) { + Choice.Confirm -> getSshKey(false) + Choice.Other -> getSshKey(true) + // Only closes the dialog: the calling activity is the app's own screen now. + Choice.Cancel -> {} + } } suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { @@ -169,7 +167,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } when (result) { is Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + registerAuthProviders(SshAuthMethod.SshKey(callingActivity)) } is CanceledByUser -> { return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) @@ -189,7 +187,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } } } else { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + registerAuthProviders(SshAuthMethod.SshKey(callingActivity)) } } else { onMissingSshKeyFile() @@ -202,11 +200,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { HttpsCredentialsProvider( CredentialFinder( callingActivity, + callingActivity.prompts, AuthMode.Password, hiltEntryPoint.dispatcherProvider(), ) ) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) + registerAuthProviders(SshAuthMethod.Password(callingActivity), httpsCredentialProvider) } AuthMode.None -> {} } @@ -214,7 +213,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } /** Called before execution of the Git operation. Return false to cancel. */ - open fun preExecute() = true + open suspend fun preExecute() = true private suspend fun postExecute() { withContext(hiltEntryPoint.dispatcherProvider().io()) { sshSessionFactory?.close() } diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt index 1966c4ab..4738d7ee 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/PullOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class PullOperation(callingActivity: AppCompatActivity, rebase: Boolean) : +class PullOperation(callingActivity: FragmentActivity, rebase: Boolean) : GitOperation(callingActivity) { /** diff --git a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt index e013b401..a998cda5 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/PushOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class PushOperation(callingActivity: AppCompatActivity) : GitOperation(callingActivity) { +class PushOperation(callingActivity: FragmentActivity) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = arrayOf(git.push().setPushAll().setRemote("origin")) diff --git a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt index 7ca1282b..904c62ee 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/ResetToRemoteOperation.kt @@ -4,12 +4,12 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode.TRACK import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.ResetCommand -class ResetToRemoteOperation(callingActivity: AppCompatActivity, remoteBranch: String) : +class ResetToRemoteOperation(callingActivity: FragmentActivity, remoteBranch: String) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt index 25078e8a..c598b829 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/SyncOperation.kt @@ -4,10 +4,10 @@ */ package app.passwordstore.util.git.operation -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity import org.eclipse.jgit.api.GitCommand -class SyncOperation(callingActivity: AppCompatActivity, rebase: Boolean) : +class SyncOperation(callingActivity: FragmentActivity, rebase: Boolean) : GitOperation(callingActivity) { override val commands: Array<GitCommand<out Any>> = diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 7c8f5ba5..e2a2c2dd 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -7,7 +7,6 @@ package app.passwordstore.util.git.sshj import android.content.Context import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.provider.OpenableColumns import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyInfo @@ -157,9 +156,7 @@ object SshKey { context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) } private val isStrongBoxSupported by unsafeLazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - else false + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) } private enum class Type(val value: String) { @@ -198,9 +195,7 @@ object SshKey { setKeySize(256) setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(isStrongBoxSupported) - } + setIsStrongBoxBacked(isStrongBoxSupported) }, ), } @@ -316,17 +311,7 @@ object SshKey { val parameterSpec = KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { apply(algorithm.applyToSpec) - if (requireAuthentication) { - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters( - 30, - KeyProperties.AUTH_DEVICE_CREDENTIAL or KeyProperties.AUTH_BIOMETRIC_STRONG, - ) - } else { - @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30) - } - } + if (requireAuthentication) requireUserAuthentication(30) build() } val keyPair = diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 51657b02..631e416f 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -5,7 +5,9 @@ package app.passwordstore.util.git.sshj import android.util.Base64 -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import app.passwordstore.ui.prompts.Prompts +import app.passwordstore.ui.prompts.prompts import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.operation.CredentialFinder import app.passwordstore.util.settings.AuthMode @@ -42,10 +44,16 @@ import org.eclipse.jgit.transport.SshSessionFactory import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.util.FS -sealed class SshAuthMethod(val activity: AppCompatActivity) { - class Password(activity: AppCompatActivity) : SshAuthMethod(activity) +sealed class SshAuthMethod(val activity: FragmentActivity) { + /** + * The activity's [Prompts], taken while it is alive: the SSH thread reads it later, and asking an + * activity that was finished meanwhile for it would create one nothing draws. + */ + val prompts = activity.prompts + + class Password(activity: FragmentActivity) : SshAuthMethod(activity) - class SshKey(activity: AppCompatActivity) : SshAuthMethod(activity) + class SshKey(activity: FragmentActivity) : SshAuthMethod(activity) } abstract class InteractivePasswordFinder(private val dispatcherProvider: DispatcherProvider) : @@ -156,7 +164,14 @@ private class SshjSession( ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) if (!ssh.isConnected) throw IOException() val passwordAuth = - AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password, dispatcherProvider)) + AuthPassword( + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.Password, + dispatcherProvider, + ) + ) when (authMethod) { is SshAuthMethod.Password -> { ssh.auth(username, passwordAuth) @@ -166,7 +181,12 @@ private class SshjSession( AuthPublickey( SshKey.provide( ssh, - CredentialFinder(authMethod.activity, AuthMode.SshKey, dispatcherProvider), + CredentialFinder( + authMethod.activity, + authMethod.prompts, + AuthMode.SshKey, + dispatcherProvider, + ), ) ) ssh.auth(username, pubkeyAuth, passwordAuth) diff --git a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt index c62e60a8..5ac2e0db 100644 --- a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt +++ b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt @@ -12,7 +12,8 @@ import logcat.logcat /** An implementation of [AbstractLogger] that forwards logging calls to [logcat]. */ class LogcatLogger(name: String) : AbstractLogger(name) { - override fun isTraceEnabled() = true + // sshj's trace level dumps every SSH packet in the clear, passwords included. Never log it. + override fun isTraceEnabled() = false override fun isDebugEnabled() = true @@ -27,11 +28,11 @@ class LogcatLogger(name: String) : AbstractLogger(name) { @Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") - override fun t(message: String, t: Throwable?, vararg args: Any?) { - logcat(name, LogPriority.VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") } - } + override fun t(message: String, t: Throwable?, vararg args: Any?) = Unit override fun d(message: String, t: Throwable?, vararg args: Any?) { + // JGit's FileSnapshot writes thousands of lines per sync, pushing the rest out of the buffer. + if (name.endsWith(".FileSnapshot")) return logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") } } diff --git a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt index b4bb0438..3347da78 100644 --- a/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt +++ b/app/src/main/java/app/passwordstore/util/services/ClipboardService.kt @@ -12,7 +12,6 @@ import android.app.PendingIntent import android.app.Service import android.content.ClipData import android.content.Intent -import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService @@ -121,11 +120,7 @@ class ClipboardService : Service() { this, 0, clearIntent, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ) val notification = createNotification(pendingIntent, clearTimeMs) diff --git a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt index beeb4be7..a8b525d5 100644 --- a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt +++ b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt @@ -5,7 +5,6 @@ package app.passwordstore.util.services import android.content.Context -import android.os.Build import android.os.CancellationSignal import android.service.autofill.AutofillService import android.service.autofill.FillCallback @@ -74,16 +73,12 @@ class OreoAutofillService : AutofillService() { return } if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - callback.onSuccess( - FillResponse.Builder().run { - disableAutofill(DISABLE_AUTOFILL_DURATION_MS) - build() - } - ) - } else { - callback.onSuccess(null) - } + callback.onSuccess( + FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + } + ) return } val formToFill = diff --git a/app/src/main/java/app/passwordstore/util/settings/Constants.kt b/app/src/main/java/app/passwordstore/util/settings/Constants.kt index a0c4b038..2d5760e4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Constants.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Constants.kt @@ -6,5 +6,5 @@ package app.passwordstore.util.settings object Constants { - const val DEFAULT_DECRYPTION_TIMEOUT = 60 + const val DEFAULT_DECRYPTION_TIMEOUT = 45 } diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index 004028c4..0052f79c 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -125,6 +125,12 @@ constructor( settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) } } + var pushAfterCommit + get() = settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false) + set(value) { + settings.edit { putBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, value) } + } + sealed class UpdateConnectionSettingsResult { class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt index ecc7a51c..9fd2658d 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -86,6 +86,7 @@ object PreferenceKeys { const val PROXY_PASSWORD = "proxy_password" const val REBASE_ON_PULL = "rebase_on_pull" + const val PUSH_AFTER_COMMIT = "push_after_commit" const val DICEWARE_SEPARATOR = "diceware_separator" const val DICEWARE_LENGTH = "diceware_length" diff --git a/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp Binary files differnew file mode 100644 index 00000000..f31847d8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp diff --git a/app/src/main/res/drawable/ic_action_new_folder.xml b/app/src/main/res/drawable/ic_action_new_folder.xml index 6db26eaf..7bccd8a6 100644 --- a/app/src/main/res/drawable/ic_action_new_folder.xml +++ b/app/src/main/res/drawable/ic_action_new_folder.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_action_new_password.xml b/app/src/main/res/drawable/ic_action_new_password.xml index 1b3df410..f204ea59 100644 --- a/app/src/main/res/drawable/ic_action_new_password.xml +++ b/app/src/main/res/drawable/ic_action_new_password.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_action_secure_24dp.xml b/app/src/main/res/drawable/ic_action_secure_24dp.xml index 9eb00ee4..5a62a473 100644 --- a/app/src/main/res/drawable/ic_action_secure_24dp.xml +++ b/app/src/main/res/drawable/ic_action_secure_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_delete_24dp.xml b/app/src/main/res/drawable/ic_delete_24dp.xml index 27865f8d..151f808e 100644 --- a/app/src/main/res/drawable/ic_delete_24dp.xml +++ b/app/src/main/res/drawable/ic_delete_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_edit_24dp.xml b/app/src/main/res/drawable/ic_edit_24dp.xml index 8bcfb841..b5f43cfd 100644 --- a/app/src/main/res/drawable/ic_edit_24dp.xml +++ b/app/src/main/res/drawable/ic_edit_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index e83fc4a3..cd894791 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -9,6 +9,6 @@ android:viewportWidth="432" android:viewportHeight="432"> <path - android:fillColor="#00375E" + android:fillColor="#2A2F38" android:pathData="M0,0h432v432h-432z" /> </vector> diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 550a5531..04ef7ff9 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,63 +1,18 @@ <?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. + ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz> ~ SPDX-License-Identifier: GPL-3.0-only --> <vector xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:aapt="http://schemas.android.com/aapt" android:width="108dp" android:height="108dp" - android:viewportWidth="432" - android:viewportHeight="432"> - <path android:pathData="M128.18,195.79C126.46,181.89 137.12,169.6 150.91,169.6H281.09C294.91,169.6 305.59,181.95 303.8,195.88L292.58,283.72C291.09,295.32 281.37,304 269.86,304H161.8C150.26,304 140.51,295.27 139.07,283.63L128.18,195.79Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:endX="216" - android:endY="304" - android:startX="216" - android:startY="169.6" - android:type="linear"> - <item - android:color="#FF2AE9DD" - android:offset="0" /> - <item - android:color="#FF0BAFAF" - android:offset="1" /> - </gradient> - </aapt:attr> - </path> - <path android:pathData="M245.33,169.6V160C245.33,144.09 232.2,131.2 216,131.2C199.8,131.2 186.67,144.09 186.67,160V169.6H167.11V160C167.11,133.49 189,112 216,112C243,112 264.89,133.49 264.89,160V169.6H245.33Z"> - <aapt:attr name="android:fillColor"> - <gradient - android:centerX="216" - android:centerY="169.6" - android:gradientRadius="33.6" - android:type="radial"> - <item - android:color="#FF0086D1" - android:offset="0.24" /> - <item - android:color="#FF77E7FF" - android:offset="0.75" /> - </gradient> - </aapt:attr> - </path> + android:viewportWidth="108" + android:viewportHeight="108"> <path - android:fillColor="#77E7FF" - android:pathData="M245.33,169.6h19.56v38.4h-19.56z" /> + android:fillColor="#FF7A45" + android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" /> <path - android:fillColor="#77E7FF" - android:pathData="M245.33,217.6h19.56v48h-19.56z" /> - <path - android:fillColor="#77E7FF" - android:pathData="M239.11,205.19l13.83,13.58l-34.57,33.94l-13.83,-13.58z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M235.56,208a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M235.56,275.2a19.56,19.2 0,1 0,39.11 0a19.56,19.2 0,1 0,-39.11 0z" /> - <path - android:fillColor="#D1FEFF" - android:pathData="M204.54,252.71m-13.83,-13.58a19.56,19.2 0,1 1,27.66 27.15a19.56,19.2 0,1 1,-27.66 -27.15" /> + android:fillColor="#B8C4CF" + android:fillType="evenOdd" + android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" /> </vector> diff --git a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml index 74af9a81..ce263547 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground_monochrome.xml @@ -1,14 +1,18 @@ -<!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz> ~ SPDX-License-Identifier: GPL-3.0-only --> -<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" - android:viewportWidth="108" - android:viewportHeight="108" +<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" - android:height="108dp"> + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#FFFFFF" + android:pathData="M39,42V36a15,15 0 0 1 30,0v6h-6V36a9,9 0 0 0 -18,0v6Z" /> <path - android:pathData="M44.556 46.3997L44.556 44.5001C44.556 39.2533 48.7844 35 54.0005 35C59.2165 35 63.4449 39.2533 63.4449 44.5001L63.4444 46.4001L66.5738 46.3997C69.2445 46.3997 71.3066 48.8438 70.9624 51.6012L68.7933 68.9857C68.5068 71.2819 66.6283 73 64.4045 73L43.5297 73C41.2994 73 39.4177 71.272 39.1389 68.9681L37.0354 51.5836C36.7024 48.8318 38.7625 46.3997 41.4263 46.3997L44.556 46.3997ZM59.6671 44.5001C59.6671 41.3521 57.1301 38.8 54.0005 38.8C50.8708 38.8 48.3338 41.3521 48.3338 44.5001L48.3338 46.3997L59.6671 46.4001L59.6667 50.7076C58.5375 51.3647 57.7778 52.5928 57.7778 53.9993C57.7778 54.0436 57.7785 54.0877 57.78 54.1316L52.765 59.1762C51.5047 58.8377 50.1043 59.1658 49.1156 60.1603C47.6403 61.6443 47.6403 64.0504 49.1156 65.5344C50.5909 67.0184 52.9829 67.0184 54.4582 65.5344C55.447 64.5397 55.7731 63.1309 55.4364 61.8631L59.6667 57.6079L59.6667 64.0078C58.5375 64.6648 57.7778 65.8929 57.7778 67.2995C57.7778 69.3982 59.4691 71.0995 61.5556 71.0995C63.642 71.0995 65.3333 69.3982 65.3333 67.2995C65.3333 65.8929 64.5736 64.6648 63.4444 64.0078L63.4444 57.291C64.5736 56.6339 65.3333 55.4059 65.3333 53.9993C65.3333 52.5928 64.5736 51.3647 63.4444 50.7076L63.4444 46.4001L59.6671 46.4001L59.6671 44.5001Z" - android:fillColor="#FFFFFF" /> + android:fillColor="#FFFFFF" + android:fillType="evenOdd" + android:pathData="M32,40H76V58C76,71 66,80 54,86C42,80 32,71 32,58ZM61.800,54.233L61.800,67.767A4.6,4.6 0 1 1 58.200,67.767L58.200,58.102L50.568,62.463A4.6,4.6 0 1 1 48.782,59.337L57.932,54.109A4.6,4.6 0 1 1 61.800,54.233Z" /> </vector> diff --git a/app/src/main/res/drawable/ic_move_folder_24dp.xml b/app/src/main/res/drawable/ic_move_folder_24dp.xml index 3df87291..e12ddaee 100644 --- a/app/src/main/res/drawable/ic_move_folder_24dp.xml +++ b/app/src/main/res/drawable/ic_move_folder_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_open_in_new_24dp.xml b/app/src/main/res/drawable/ic_open_in_new_24dp.xml new file mode 100644 index 00000000..b82ebe62 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_new_24dp.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:autoMirrored="true" + android:tint="?android:attr/colorControlNormal" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/white" + android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" /> +</vector> diff --git a/app/src/main/res/drawable/ic_push_pin_24dp.xml b/app/src/main/res/drawable/ic_push_pin_24dp.xml index c542d784..41f61e0a 100644 --- a/app/src/main/res/drawable/ic_push_pin_24dp.xml +++ b/app/src/main/res/drawable/ic_push_pin_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_round_import_export.xml b/app/src/main/res/drawable/ic_round_import_export.xml index 2f243b22..7ab2aa1c 100644 --- a/app/src/main/res/drawable/ic_round_import_export.xml +++ b/app/src/main/res/drawable/ic_round_import_export.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_search_24dp.xml b/app/src/main/res/drawable/ic_search_24dp.xml index bb454f5b..666883b6 100644 --- a/app/src/main/res/drawable/ic_search_24dp.xml +++ b/app/src/main/res/drawable/ic_search_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/drawable/ic_share_24dp.xml b/app/src/main/res/drawable/ic_share_24dp.xml index 2295122c..83d28c8d 100644 --- a/app/src/main/res/drawable/ic_share_24dp.xml +++ b/app/src/main/res/drawable/ic_share_24dp.xml @@ -6,7 +6,7 @@ <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" - android:tint="?attr/colorControlNormal" + android:tint="?android:attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> <path diff --git a/app/src/main/res/layout/git_credential_layout.xml b/app/src/main/res/layout/git_credential_layout.xml deleted file mode 100644 index 8fc527e9..00000000 --- a/app/src/main/res/layout/git_credential_layout.xml +++ /dev/null @@ -1,37 +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="16dp"> - - <com.google.android.material.textfield.TextInputLayout - android:id="@+id/git_auth_passphrase_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - app:endIconMode="password_toggle" - app:errorEnabled="true" - app:hintEnabled="true"> - - <com.google.android.material.textfield.TextInputEditText - android:id="@+id/git_auth_credential" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="@string/ssh_keygen_passphrase" - android:inputType="textPassword" /> - - <requestFocus /> - </com.google.android.material.textfield.TextInputLayout> - - <com.google.android.material.checkbox.MaterialCheckBox - android:id="@+id/git_auth_remember_credential" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/git_operation_remember_passphrase" - app:checkedState="checked" /> -</LinearLayout> diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differdeleted file mode 100644 index a1133420..00000000 --- a/app/src/main/res/mipmap-hdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differdeleted file mode 100644 index b9e28b18..00000000 --- a/app/src/main/res/mipmap-mdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 353fe9c4..00000000 --- a/app/src/main/res/mipmap-xhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 148db12b..00000000 --- a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differdeleted file mode 100644 index 2c1e2b05..00000000 --- a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +++ /dev/null diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a0a20dc7..7baf4113 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -37,7 +37,6 @@ <string name="git_commit_move_multiple_text">Verschiebe mehrere Passwörter nach %1$s.</string> <string name="git_commit_gpg_id">Initialisiere PGP-IDs in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">In die Zwischenablage kopiert</string> <string name="file_toast_text">Bitte setzen Sie einen Dateinamen.</string> <string name="empty_toast_text">Sie können kein leeres Passwort oder leere Extra-Angaben setzen.</string> <!-- Git Async Task --> @@ -232,7 +231,6 @@ <string name="pref_app_theme_title">App-Farbthema</string> <string name="pref_app_theme_value_light">Hell</string> <string name="pref_app_theme_value_dark">Dunkel</string> - <string name="pref_app_theme_value_battery_saver">Durch Energiesparmodus gesetzt</string> <string name="pref_app_theme_value_follow_system">Systemstandard</string> <string name="connection_mode_ssh_key">SSH-Schlüssel</string> <string name="connection_mode_basic_authentication">Passwort</string> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d4d4d424..57be2b30 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -36,7 +36,6 @@ <string name="git_commit_move_text">Renommer %1$sà %2$s. </string> <string name="git_commit_move_multiple_text">Déplacement de mots de passe vers %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copié dans le presse-papiers</string> <string name="file_toast_text">Veuillez fournir un nom de fichier</string> <string name="empty_toast_text">Vous ne pouvez pas utiliser un mot de passe vide ou des données supplémentaires vide</string> <!-- Git Async Task --> @@ -220,7 +219,6 @@ <string name="pref_app_theme_title">Thème de l\'application</string> <string name="pref_app_theme_value_light">Clair</string> <string name="pref_app_theme_value_dark">Sombre</string> - <string name="pref_app_theme_value_battery_saver">Défini par l\'économiseur de batterie</string> <string name="pref_app_theme_value_follow_system">Valeur par défaut du système</string> <string name="connection_mode_ssh_key">Clé SSH</string> <string name="connection_mode_basic_authentication">Mot de passe</string> diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index f966230c..b7a59428 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -37,7 +37,6 @@ <string name="git_commit_move_multiple_text">Mover varios contrasinais a %1$s.</string> <string name="git_commit_gpg_id">Iniciar cos IDs GPG en %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiado ao portapapeis</string> <string name="file_toast_text">Debes proporcionar un nome de ficheiro</string> <string name="empty_toast_text">Non podes deixar baleiro o contrasinal ou o contido extra</string> <!-- Git Async Task --> @@ -234,7 +233,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid <string name="pref_app_theme_title">Decorado da App</string> <string name="pref_app_theme_value_light">Claro</string> <string name="pref_app_theme_value_dark">Escuro</string> - <string name="pref_app_theme_value_battery_saver">Establecido polo Aforrador de enerxía</string> <string name="pref_app_theme_value_follow_system">Por omisión do sistema</string> <string name="connection_mode_ssh_key">Chave SSH</string> <string name="connection_mode_basic_authentication">Contrasinal</string> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e7c05909..fed8adec 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -36,7 +36,6 @@ <string name="git_commit_move_text">Rinomina %1$s in %2$s.</string> <string name="git_commit_move_multiple_text">Sposta più password in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiato negli appunti</string> <string name="file_toast_text">Inserisci un nome per il file</string> <string name="empty_toast_text">Non puoi usare una password vuota o dei contenuti extra vuoti</string> <!-- Git Async Task --> @@ -215,7 +214,6 @@ <string name="pref_app_theme_title">Tema dell\'App</string> <string name="pref_app_theme_value_light">Chiaro</string> <string name="pref_app_theme_value_dark">Scuro</string> - <string name="pref_app_theme_value_battery_saver">Impostato dal Risparmio Energetico</string> <string name="pref_app_theme_value_follow_system">Predefinito del sistema</string> <string name="connection_mode_ssh_key">Chiave SSH</string> <string name="connection_mode_basic_authentication">Password</string> @@ -290,6 +288,13 @@ <string name="pref_rebase_on_pull_title">Rebase al pull</string> <string name="pref_rebase_on_pull_summary">Durante pull o sincronizzazione, crea un merge commit con le modifiche del remote</string> <string name="pref_rebase_on_pull_summary_on">Durante pull o sincronizzazione, esegui il rebase dei commit che non sono sul remote</string> + <string name="pref_export_log_title">Esporta log</string> + <string name="pref_export_log_summary">Salva in un file i log di questa app. Possono mostrare nomi delle voci, siti, app e il tuo server git: leggi il file prima di condividerlo</string> + <string name="log_exported">Log salvato</string> + <string name="log_export_failed">Impossibile salvare il log</string> + <string name="pref_push_after_commit_title">Push dopo ogni modifica</string> + <string name="pref_push_after_commit_summary">Esegui il push sul repository remoto dopo aver salvato, spostato o eliminato nell\'app</string> + <string name="git_push_after_commit_failed">Salvato sul dispositivo, ma push non riuscito. Sincronizza per riprovare</string> <string name="pref_disable_sync_on_pull_title">Disattiva la sincronizzazione trascinando</string> <string name="pref_disable_sync_on_pull_summary">Trascinare verso il basso l\'elenco delle password non avvia una sincronizzazione Git</string> <string name="pref_import_pgp_key_title">Importa chiave PGP</string> @@ -381,4 +386,20 @@ <string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string> <string name="git_tool_done">%1$s: completato</string> <string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string> + <string name="about">Informazioni</string> + <string name="about_summary">Un client Android per pass, il gestore di password standard di Unix. Un fork mantenuto di Android Password Store.</string> + <string name="about_version">Versione</string> + <string name="about_docs">Documentazione</string> + <string name="about_changelog">Novità</string> + <string name="about_source">Codice sorgente</string> + <string name="about_issues">Segnala un problema</string> + <string name="about_support">Sostieni su Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string> + <string name="about_certificate">Certificato di firma (SHA-256)</string> + <string name="about_license">Licenza</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright degli autori di Android Password Store e dei contributori di HardenedPass.</string> + <string name="about_libraries">Licenze open source</string> + <string name="about_opens_browser">Si apre nel browser</string> + <string name="about_no_browser">Nessun browser trovato per aprire questo link</string> </resources> diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index add3de87..f6535e05 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -35,7 +35,6 @@ <string name="git_commit_move_multiple_text">여러개의 비밀번호를 %1$s 로 옮겼습니다.</string> <string name="git_commit_gpg_id">%1$s의 GPG ID를 초기화 합니다.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">클립보드로 복사됨</string> <string name="file_toast_text">파일 이름이 필요합니다</string> <string name="empty_toast_text">비어있는 비밀번호나 추가 정보를 사용할 수 없습니다</string> <!-- Git Async Task --> @@ -228,7 +227,6 @@ <string name="pref_app_theme_title">앱 테마</string> <string name="pref_app_theme_value_light">밝은 테마</string> <string name="pref_app_theme_value_dark">어두운 테마</string> - <string name="pref_app_theme_value_battery_saver">배터리 절약모드 설정</string> <string name="pref_app_theme_value_follow_system">시스템 기본값</string> <string name="connection_mode_ssh_key">SSH 키</string> <string name="connection_mode_basic_authentication">비밀번호</string> diff --git a/app/src/main/res/values-night/themes_material3.xml b/app/src/main/res/values-night/themes_material3.xml index 548afa5f..22b62088 100644 --- a/app/src/main/res/values-night/themes_material3.xml +++ b/app/src/main/res/values-night/themes_material3.xml @@ -5,32 +5,7 @@ <resources> - <style name="APSThemeM3" parent="Theme.Material3.DayNight"> - <item name="colorPrimary">@color/md_theme_dark_primary</item> - <item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item> - <item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item> - <item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item> - <item name="colorSecondary">@color/md_theme_dark_secondary</item> - <item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item> - <item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item> - <item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item> - <item name="colorTertiary">@color/md_theme_dark_tertiary</item> - <item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item> - <item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item> - <item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item> - <item name="colorError">@color/md_theme_dark_error</item> - <item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item> - <item name="colorOnError">@color/md_theme_dark_onError</item> - <item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item> - <item name="android:colorBackground">@color/md_theme_dark_background</item> - <item name="colorOnBackground">@color/md_theme_dark_onBackground</item> - <item name="colorSurface">@color/md_theme_dark_surface</item> - <item name="colorOnSurface">@color/md_theme_dark_onSurface</item> - <item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item> - <item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item> - <item name="colorOutline">@color/md_theme_dark_outline</item> - <item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item> - <item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item> - <item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item> + <style name="APSThemeM3" parent="android:Theme.Material.NoActionBar"> + <item name="android:colorBackground">@android:color/system_neutral2_900</item> </style> </resources> diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 4d41b2f6..d130bc70 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -35,7 +35,6 @@ <string name="git_commit_move_text">Zmień nazwę %1$s na %2$s.</string> <string name="git_commit_move_multiple_text">Przenieś hasła do %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Skopiowano do schowka</string> <string name="file_toast_text">Podaj nazwę pliku</string> <string name="empty_toast_text">Nie możesz użyć pustego hasła lub pustej dodatkowej zawartości</string> <!-- Git Async Task --> @@ -227,7 +226,6 @@ <string name="pref_app_theme_title">Motyw aplikacji</string> <string name="pref_app_theme_value_light">Jasny</string> <string name="pref_app_theme_value_dark">Ciemny</string> - <string name="pref_app_theme_value_battery_saver">Ustawiany przez funkcję oszczędzania energii</string> <string name="pref_app_theme_value_follow_system">Domyślny</string> <string name="connection_mode_ssh_key">Klucz SSH</string> <string name="connection_mode_basic_authentication">Hasło</string> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 14e862c5..4ee08b50 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -37,7 +37,6 @@ <string name="git_commit_move_multiple_text">Mova múltiplas senhas para %1$s.</string> <string name="git_commit_gpg_id">Inicializar IDs GPG em %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copiado para a área de transferência</string> <string name="file_toast_text">Por favor, informe um nome de arquivo</string> <string name="empty_toast_text">Você não pode usar uma senha vazia ou conteúdo extra vazio</string> <!-- Git Async Task --> @@ -232,7 +231,6 @@ <string name="pref_app_theme_title">Tema do aplicativo</string> <string name="pref_app_theme_value_light">Claro</string> <string name="pref_app_theme_value_dark">Escuro</string> - <string name="pref_app_theme_value_battery_saver">Configurado pela Economia de bateria</string> <string name="pref_app_theme_value_follow_system">Padrão do sistema</string> <string name="connection_mode_ssh_key">Chave SSH</string> <string name="connection_mode_basic_authentication">Senha</string> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ddc33aa3..a966640d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -41,7 +41,6 @@ <string name="git_commit_move_multiple_text">Переместить несколько паролей в %1$s.</string> <string name="git_commit_gpg_id">Инициализация GPG ID через %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Скопировано в буфер обмена</string> <string name="file_toast_text">Пожалуйста, укажите имя файла</string> <string name="empty_toast_text">Вы не можете использовать пустой пароль или пустое поле информации</string> <!-- Git Async Task --> @@ -225,7 +224,6 @@ <string name="pref_app_theme_title">Тема приложения</string> <string name="pref_app_theme_value_light">Светлая</string> <string name="pref_app_theme_value_dark">Темная</string> - <string name="pref_app_theme_value_battery_saver">Задается экономией батареи</string> <string name="pref_app_theme_value_follow_system">Системная</string> <string name="connection_mode_ssh_key">SSH ключ</string> <string name="connection_mode_basic_authentication">Пароль</string> diff --git a/app/src/main/res/values-v29/arrays.xml b/app/src/main/res/values-v29/arrays.xml deleted file mode 100644 index aa78032c..00000000 --- a/app/src/main/res/values-v29/arrays.xml +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <string-array name="app_theme_options"> - <item>@string/pref_app_theme_value_light</item> - <item>@string/pref_app_theme_value_dark</item> - <item>@string/pref_app_theme_value_follow_system</item> - </string-array> - <string-array name="app_theme_values"> - <item>light</item> - <item>dark</item> - <item>follow_system</item> - </string-array> -</resources> diff --git a/app/src/main/res/values-v29/prefs.xml b/app/src/main/res/values-v29/prefs.xml deleted file mode 100644 index 383b5f8b..00000000 --- a/app/src/main/res/values-v29/prefs.xml +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?><!-- - ~ Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - ~ SPDX-License-Identifier: GPL-3.0-only - --> - -<resources> - <string name="app_theme_def" translatable="false">follow_system</string> -</resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3f6abeb3..dae54d05 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -36,7 +36,6 @@ <string name="git_commit_move_multiple_text">将多个密码移至%1$s</string> <string name="git_commit_gpg_id">初始化 %1$s 中的 GPG ID</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">复制到剪切板</string> <string name="file_toast_text">请提供文件名</string> <string name="empty_toast_text">您不能使用空密码或空额外内容</string> <!-- Git Async Task --> @@ -233,7 +232,6 @@ <string name="pref_app_theme_title">应用主题</string> <string name="pref_app_theme_value_light">浅色</string> <string name="pref_app_theme_value_dark">深色</string> - <string name="pref_app_theme_value_battery_saver">由省电模式设定</string> <string name="pref_app_theme_value_follow_system">系统默认</string> <string name="connection_mode_ssh_key">SSH密钥</string> <string name="connection_mode_basic_authentication">密码</string> diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 2891987d..dde1a99f 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -33,12 +33,12 @@ <string-array name="app_theme_options"> <item>@string/pref_app_theme_value_light</item> <item>@string/pref_app_theme_value_dark</item> - <item>@string/pref_app_theme_value_battery_saver</item> + <item>@string/pref_app_theme_value_follow_system</item> </string-array> <string-array name="app_theme_values"> <item>light</item> <item>dark</item> - <item>battery_saver</item> + <item>follow_system</item> </string-array> </resources> diff --git a/app/src/main/res/values/colors_material3.xml b/app/src/main/res/values/colors_material3.xml index ecd34921..47ffbc6a 100644 --- a/app/src/main/res/values/colors_material3.xml +++ b/app/src/main/res/values/colors_material3.xml @@ -5,59 +5,5 @@ <resources> <color name="md_theme_light_primary">#006591</color> - <color name="md_theme_light_onPrimary">#FFFFFF</color> - <color name="md_theme_light_primaryContainer">#C7E6FF</color> - <color name="md_theme_light_onPrimaryContainer">#001E30</color> - <color name="md_theme_light_secondary">#4F606E</color> - <color name="md_theme_light_onSecondary">#FFFFFF</color> - <color name="md_theme_light_secondaryContainer">#D3E5F5</color> - <color name="md_theme_light_onSecondaryContainer">#0B1D29</color> - <color name="md_theme_light_tertiary">#006494</color> - <color name="md_theme_light_onTertiary">#FFFFFF</color> - <color name="md_theme_light_tertiaryContainer">#C8E6FF</color> - <color name="md_theme_light_onTertiaryContainer">#001E31</color> - <color name="md_theme_light_error">#BA1B1B</color> - <color name="md_theme_light_errorContainer">#FFDAD4</color> - <color name="md_theme_light_onError">#FFFFFF</color> - <color name="md_theme_light_onErrorContainer">#410001</color> - <color name="md_theme_light_background">#FCFCFF</color> - <color name="md_theme_light_onBackground">#1A1C1E</color> - <color name="md_theme_light_surface">#FCFCFF</color> - <color name="md_theme_light_onSurface">#1A1C1E</color> - <color name="md_theme_light_surfaceVariant">#DDE3EA</color> - <color name="md_theme_light_onSurfaceVariant">#41474D</color> - <color name="md_theme_light_outline">#72787E</color> - <color name="md_theme_light_inverseOnSurface">#F0F0F3</color> - <color name="md_theme_light_inverseSurface">#2E3133</color> - <color name="md_theme_light_primaryInverse">#85CEFF</color> - <color name="md_theme_dark_primary">#85CEFF</color> - <color name="md_theme_dark_onPrimary">#00344E</color> - <color name="md_theme_dark_primaryContainer">#004C6F</color> - <color name="md_theme_dark_onPrimaryContainer">#C7E6FF</color> - <color name="md_theme_dark_secondary">#B7C9D9</color> - <color name="md_theme_dark_onSecondary">#21323E</color> - <color name="md_theme_dark_secondaryContainer">#384956</color> - <color name="md_theme_dark_onSecondaryContainer">#D3E5F5</color> - <color name="md_theme_dark_tertiary">#8ACEFF</color> - <color name="md_theme_dark_onTertiary">#003450</color> - <color name="md_theme_dark_tertiaryContainer">#004B70</color> - <color name="md_theme_dark_onTertiaryContainer">#C8E6FF</color> - <color name="md_theme_dark_error">#FFB4A9</color> - <color name="md_theme_dark_errorContainer">#930006</color> - <color name="md_theme_dark_onError">#680003</color> - <color name="md_theme_dark_onErrorContainer">#FFDAD4</color> - <color name="md_theme_dark_background">#1A1C1E</color> - <color name="md_theme_dark_onBackground">#E1E2E5</color> - <color name="md_theme_dark_surface">#1A1C1E</color> - <color name="md_theme_dark_onSurface">#E1E2E5</color> - <color name="md_theme_dark_surfaceVariant">#41474D</color> - <color name="md_theme_dark_onSurfaceVariant">#C1C7CE</color> - <color name="md_theme_dark_outline">#8B9197</color> - <color name="md_theme_dark_inverseOnSurface">#1A1C1E</color> - <color name="md_theme_dark_inverseSurface">#E1E2E5</color> - <color name="md_theme_dark_primaryInverse">#006591</color> - <color name="seed">#003E5B</color> - <color name="error">#BA1B1B</color> - <!-- Multiselect color --> </resources> diff --git a/app/src/main/res/values/prefs.xml b/app/src/main/res/values/prefs.xml index a5977d9d..383b5f8b 100644 --- a/app/src/main/res/values/prefs.xml +++ b/app/src/main/res/values/prefs.xml @@ -4,5 +4,5 @@ --> <resources> - <string name="app_theme_def" translatable="false">battery_saver</string> + <string name="app_theme_def" translatable="false">follow_system</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1a858fd..a415e95f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,7 +44,6 @@ <string name="git_commit_gpg_id">Initialize GPG IDs in %1$s.</string> <!-- PGPHandler --> - <string name="clipboard_copied_text">Copied to clipboard</string> <string name="file_toast_text">Please provide a file name</string> <string name="empty_toast_text">You cannot use an empty password or empty extra content</string> @@ -127,6 +126,13 @@ <string name="pref_rebase_on_pull_title">Rebase on pull</string> <string name="pref_rebase_on_pull_summary">When pulling or syncing, create a merge commit with upstream changes</string> <string name="pref_rebase_on_pull_summary_on">When pulling or syncing, rebase commits that are not present in the remote repository</string> + <string name="pref_export_log_title">Export log</string> + <string name="pref_export_log_summary">Save this app\'s logs to a file. They can show entry names, websites, apps and your git server, so read the file before sharing it</string> + <string name="log_exported">Log saved</string> + <string name="log_export_failed">Could not save the log</string> + <string name="pref_push_after_commit_title">Push after each change</string> + <string name="pref_push_after_commit_summary">Push to the remote repository after saving, moving or deleting in the app</string> + <string name="git_push_after_commit_failed">Saved on this device, but not pushed. Sync to try again</string> <string name="pref_disable_sync_on_pull_title">Disable pull to sync action</string> <string name="pref_disable_sync_on_pull_summary">Prevent pulling down on the password list from triggering a Git sync</string> <string name="pref_import_pgp_key_title">Import PGP key</string> @@ -263,7 +269,6 @@ <string name="pref_app_theme_title">App theme</string> <string name="pref_app_theme_value_light">Light</string> <string name="pref_app_theme_value_dark">Dark</string> - <string name="pref_app_theme_value_battery_saver">Set by Battery Saver</string> <string name="pref_app_theme_value_follow_system">System default</string> <string name="connection_mode_ssh_key">SSH key</string> <string name="connection_mode_basic_authentication">Password</string> @@ -415,4 +420,20 @@ <string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string> <string name="git_tool_done">%1$s: done</string> <string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string> + <string name="about">About</string> + <string name="about_summary">An Android client for pass, the standard Unix password manager. A maintained fork of Android Password Store.</string> + <string name="about_version">Version</string> + <string name="about_docs">Documentation</string> + <string name="about_changelog">What\'s new</string> + <string name="about_source">Source code</string> + <string name="about_issues">Report a problem</string> + <string name="about_support">Support on Ko-fi</string> + <string name="about_privacy_title">Privacy</string> + <string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string> + <string name="about_certificate">Signing certificate (SHA-256)</string> + <string name="about_license">License</string> + <string name="about_license_text">GNU General Public License v3.0 only. Copyright the Android Password Store authors and HardenedPass contributors.</string> + <string name="about_libraries">Open source licenses</string> + <string name="about_opens_browser">Opens in the browser</string> + <string name="about_no_browser">No browser found to open this link</string> </resources> diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 0ec74a45..7db01be0 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -6,18 +6,11 @@ <resources> <style name="AppThemeM3" parent="APSThemeM3"> - <item name="fontFamily">@font/manrope</item> <item name="android:windowLightStatusBar">@bool/light_status_bar</item> <item name="android:statusBarColor">?android:colorBackground</item> <item name="android:navigationBarColor">?android:colorBackground</item> </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"> <item name="android:background">@android:color/transparent</item> <item name="android:backgroundDimEnabled">true</item> @@ -25,48 +18,17 @@ <item name="android:statusBarColor">@android:color/transparent</item> <item name="android:windowIsTranslucent">true</item> <item name="android:windowContentOverlay">@null</item> - <item name="android:windowActionBar">false</item> <item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowEnterAnimation">@android:anim/fade_in</item> <item name="android:windowExitAnimation">@android:anim/fade_out</item> - <item name="colorPrimaryDark">@android:color/transparent</item> - <item name="windowNoTitle">true</item> </style> <style name="DialogLikeThemeM3" parent="AppThemeM3"> - <item name="android:windowActionBar">false</item> <item name="android:windowIsFloating">true</item> <item name="android:backgroundDimEnabled">true</item> - <!-- Needs to be set without android: prefix as per https://stackoverflow.com/a/33417456 --> - <item name="windowNoTitle">true</item> </style> - <style name="APSThemeM3" parent="Theme.Material3.DayNight"> - <item name="colorPrimary">@color/md_theme_light_primary</item> - <item name="colorOnPrimary">@color/md_theme_light_onPrimary</item> - <item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item> - <item name="colorOnPrimaryContainer">@color/md_theme_light_onPrimaryContainer</item> - <item name="colorSecondary">@color/md_theme_light_secondary</item> - <item name="colorOnSecondary">@color/md_theme_light_onSecondary</item> - <item name="colorSecondaryContainer">@color/md_theme_light_secondaryContainer</item> - <item name="colorOnSecondaryContainer">@color/md_theme_light_onSecondaryContainer</item> - <item name="colorTertiary">@color/md_theme_light_tertiary</item> - <item name="colorOnTertiary">@color/md_theme_light_onTertiary</item> - <item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item> - <item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item> - <item name="colorError">@color/md_theme_light_error</item> - <item name="colorErrorContainer">@color/md_theme_light_errorContainer</item> - <item name="colorOnError">@color/md_theme_light_onError</item> - <item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item> - <item name="android:colorBackground">@color/md_theme_light_background</item> - <item name="colorOnBackground">@color/md_theme_light_onBackground</item> - <item name="colorSurface">@color/md_theme_light_surface</item> - <item name="colorOnSurface">@color/md_theme_light_onSurface</item> - <item name="colorSurfaceVariant">@color/md_theme_light_surfaceVariant</item> - <item name="colorOnSurfaceVariant">@color/md_theme_light_onSurfaceVariant</item> - <item name="colorOutline">@color/md_theme_light_outline</item> - <item name="colorOnSurfaceInverse">@color/md_theme_light_inverseOnSurface</item> - <item name="colorSurfaceInverse">@color/md_theme_light_inverseSurface</item> - <item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item> + <style name="APSThemeM3" parent="android:Theme.Material.Light.NoActionBar"> + <item name="android:colorBackground">@android:color/system_neutral2_10</item> </style> </resources> diff --git a/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt new file mode 100644 index 00000000..12d1671e --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/prompts/PromptsTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.prompts + +import androidx.compose.material3.SnackbarDuration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class PromptsTest { + + private fun Prompts.question() = current as Question + + @Test + fun answerResumesAsk() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + prompts.answer(prompts.question(), Choice.Other) + assertEquals(Choice.Other, asked.await()) + assertNull(prompts.current) + } + + @Test + fun dismissingAnswersCancel() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + prompts.cancel(prompts.question()) + assertEquals(Choice.Cancel, asked.await()) + } + + @Test + fun secondAnswerIsIgnored() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + val question = prompts.question() + prompts.answer(question, Choice.Confirm) + prompts.answer(question, Choice.Cancel) + assertEquals(Choice.Confirm, asked.await()) + } + + @Test + fun cancelledAskRemovesItsDialog() = runBlocking { + val prompts = Prompts() + val asked = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("t", "m", "ok") } + asked.cancel() + assertNull(prompts.current) + } + + @Test + fun promptsShowOneAtATimeInOrder() = + runBlocking<Unit> { + val prompts = Prompts() + val first = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("1", "m", "ok") } + val second = async(start = CoroutineStart.UNDISPATCHED) { prompts.ask("2", "m", "ok") } + assertEquals("1", prompts.question().title) + prompts.answer(prompts.question(), Choice.Confirm) + assertEquals("2", prompts.question().title) + prompts.answer(prompts.question(), Choice.Confirm) + first.await() + second.await() + } + + @Test + fun cancelAllAnswersAPendingCredentialWithNull() { + val prompts = Prompts() + val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false)) + prompts.askCredential("m", "h", "r", null) { answers[0] = it } + prompts.cancelAll() + assertNull(answers[0]) + assertNull(prompts.current) + } + + @Test + fun askCredentialAfterCloseAnswersNullAtOnce() { + val prompts = Prompts() + prompts.close() + val answers = mutableListOf<CredentialAnswer?>(CredentialAnswer("unset", false)) + prompts.askCredential("m", "h", "r", null) { answers[0] = it } + assertNull(answers[0]) + assertNull(prompts.current) + } + + @Test + fun newSnackbarReplacesTheCurrentOne() { + val prompts = Prompts() + val first = prompts.snackbar("one", SnackbarDuration.Indefinite) + val second = prompts.snackbar("two", SnackbarDuration.Short) + assertSame(second, prompts.snack) + // The finished operation dismisses its own snackbar only. + prompts.dismiss(first) + assertSame(second, prompts.snack) + prompts.dismiss(second) + assertNull(prompts.snack) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt new file mode 100644 index 00000000..e692c229 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AboutTest { + + @Test + fun versionLineWithoutCommit() { + assertEquals("0.3.0 (300)", versionLine("0.3.0", 300, "")) + } + + @Test + fun versionLineWithCommit() { + assertEquals("0.3.0-debug (300) · daa7ee01", versionLine("0.3.0-debug", 300, "daa7ee01")) + } + + @Test + fun changelogUrlPointsAtTheReleaseTag() { + assertEquals( + "https://github.com/danixland/HardenedPass/blob/v0.3.0/CHANGELOG.md", + changelogUrl("0.3.0-debug"), + ) + } + + @Test + fun fingerprintIsColonSeparatedUppercaseSha256() { + assertEquals( + "BA:78:16:BF:8F:01:CF:EA:41:41:40:DE:5D:AE:22:23:" + + "B0:03:61:A3:96:17:7A:9C:B4:10:FF:61:F2:00:15:AD", + certificateFingerprint("abc".encodeToByteArray()), + ) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt index 6b30c248..111e7f51 100644 --- a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt +++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt @@ -9,7 +9,9 @@ import app.passwordstore.util.extensions.getString import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class GitSettingsTest { @@ -24,4 +26,19 @@ class GitSettingsTest { assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME)) assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME)) } + + @Test + fun pushAfterCommitIsOptIn() { + val settings = SPMockBuilder().createSharedPreferences() + val gitSettings = + GitSettings( + settings, + SPMockBuilder().createSharedPreferences(), + SPMockBuilder().createSharedPreferences(), + "/nonexistent", + ) + assertFalse(gitSettings.pushAfterCommit) + gitSettings.pushAfterCommit = true + assertTrue(settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false)) + } } diff --git a/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt b/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt index 0ddb72f8..59c1de05 100644 --- a/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt +++ b/app/src/test/java/app/passwordstore/util/viewmodel/StrictDomainRegexTest.kt @@ -10,13 +10,11 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config private infix fun String.matchedForDomain(domain: String) = SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true @RunWith(RobolectricTestRunner::class) -@Config(sdk = [23]) class StrictDomainRegexTest { @Test |
