From 76dbef8d6d88454a8510d15c2271aa97c7c6d464 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:06:18 +0100 Subject: feat(app): run setup in MainActivity The welcome screen starts the app without a store, the list sends a vanished store back to it, and its clone prompt opens the git server screen. A clone asks for the store's PGP key when none matches it. Settings, the entry view, autofill decryption and git operations open the setup screens through MainActivity.setupIntent. The No SSH key dialog's Cancel and the unusable screen lock prompt no longer finish the calling activity, which is now the app's main screen. KeysMode is a route argument, so it is kept from minification (lint MissingKeepAnnotation). Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 7 +- .../app/passwordstore/ui/entry/EntryDestination.kt | 4 +- .../app/passwordstore/ui/list/ListDestination.kt | 10 ++- .../java/app/passwordstore/ui/main/MainActivity.kt | 58 ++++++++++-- .../app/passwordstore/ui/settings/PGPSettings.kt | 6 +- .../ui/settings/RepositorySettings.kt | 15 ++-- .../app/passwordstore/ui/setup/PgpKeysScreen.kt | 4 +- .../java/app/passwordstore/ui/setup/SetupGraph.kt | 100 +++++++++++++++++++++ .../util/git/operation/GitOperation.kt | 30 +++---- 9 files changed, 185 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt (limited to 'app/src/main/java') diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index ccd3bf08..6831761b 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -23,7 +23,8 @@ import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.pgp.PGPKeyImportActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.clipboard import app.passwordstore.util.extensions.getString @@ -83,7 +84,9 @@ open class BasePGPActivity : AppCompatActivity() { .setMessage(resources.getString(R.string.no_keys_imported_dialog_message)) .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ -> onKeyImport = onKeysExist - keyImportAction.launch(Intent(this@BasePGPActivity, PGPKeyImportActivity::class.java)) + keyImportAction.launch( + MainActivity.setupIntent(this@BasePGPActivity, SetupStart.PgpImport) + ) } .show() } diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index 1b3a75d2..637c9b56 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -49,7 +49,7 @@ import app.passwordstore.ui.crypto.copyTextToClipboard import app.passwordstore.ui.crypto.getPGPIdentifiers import app.passwordstore.ui.main.LaunchActivity import app.passwordstore.ui.main.MainActivity -import app.passwordstore.ui.pgp.PGPKeyImportActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.extensions.sharedPrefs @@ -151,7 +151,7 @@ fun EntryDestination( TextButton( onClick = { viewModel.dismissPrompt() - keyImport.launch(Intent(activity, PGPKeyImportActivity::class.java)) + keyImport.launch(MainActivity.setupIntent(activity, SetupStart.PgpImport)) } ) { Text(stringResource(R.string.button_label_import)) 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 eff3cf02..a256fb1b 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -35,7 +35,6 @@ import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.SpacingMedium import app.passwordstore.ui.git.base.BaseGitActivity.GitOp -import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.main.MainActivity import app.passwordstore.util.extensions.isInsideRepository import app.passwordstore.util.extensions.sharedPrefs @@ -60,7 +59,8 @@ private enum class ListDialog { /** * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the * folder picker, the legacy activities and the store operations. [onOpenFolder] takes a path - * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder. + * relative to the root, [onOpenEntry] an entry file; [onNewEntry] opens the editor in this folder, + * [onClone] the git server screen for a clone; [onNoStore] runs when the store is gone. */ @Composable fun ListDestination( @@ -69,6 +69,8 @@ fun ListDestination( onOpenFolder: (relativePath: String) -> Unit, onOpenEntry: (File) -> Unit, onNewEntry: () -> Unit, + onClone: () -> Unit, + onNoStore: () -> Unit, modifier: Modifier = Modifier, viewModel: SearchableRepositoryViewModel = hiltViewModel(), ) { @@ -98,7 +100,7 @@ fun ListDestination( // Every return to this folder (back from a child, or from a legacy activity) re-reads it. LifecycleResumeEffect(Unit) { - if (activity.checkStore()) refresh() + if (activity.checkStore()) refresh() else onNoStore() resumes++ onPauseOrDispose {} } @@ -270,7 +272,7 @@ fun ListDestination( confirm = stringResource(R.string.clone_button), onConfirm = { dialog = ListDialog.None - activity.startActivity(GitServerConfigActivity.createCloneIntent(activity)) + onClone() }, onDismiss = { dialog = ListDialog.None }, dismiss = stringResource(R.string.dialog_cancel), 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 58ef4381..c8b1861a 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -5,6 +5,8 @@ package app.passwordstore.ui.main +import android.content.Context +import android.content.Intent import android.os.Bundle import android.view.WindowManager import androidx.activity.compose.setContent @@ -28,8 +30,11 @@ 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.onboarding.activity.OnboardingActivity import app.passwordstore.ui.settings.SettingsActivity +import app.passwordstore.ui.setup.GitServerRoute +import app.passwordstore.ui.setup.SetupStart +import app.passwordstore.ui.setup.WelcomeRoute +import app.passwordstore.ui.setup.setupGraph import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.AuthMode @@ -64,9 +69,9 @@ 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. Extends [BaseGitActivity] (an AppCompatActivity, so also - * the FragmentActivity BiometricPrompt needs) because the git operations require one; it hosts no - * fragments. + * after its biometric gate and path check, and by the app itself through [setupIntent]. Extends + * [BaseGitActivity] (an AppCompatActivity, so also the FragmentActivity BiometricPrompt needs) + * because the git operations require one; it hosts no fragments. */ @AndroidEntryPoint class MainActivity : BaseGitActivity() { @@ -79,7 +84,18 @@ class MainActivity : BaseGitActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) - val start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute() + val setup = + intent.getStringExtra(EXTRA_SETUP)?.let { name -> + SetupStart.entries.firstOrNull { it.name == name } + } + // A restored back stack wins over this choice, so a recreation stays where it was. + val start: Any = + when { + filePath != null -> EntryRoute(filePath) + setup != null -> setup.route + checkStore() -> ListRoute() + else -> WelcomeRoute + } setContent { APSTheme { val nav = rememberNavController() @@ -106,6 +122,15 @@ class MainActivity : BaseGitActivity() { onNewEntry = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(EditRoute(path)) }, + onClone = { + if (nav.currentBackStackEntry?.id == entry.id) + nav.navigate(GitServerRoute(clone = true)) + }, + onNoStore = { + // The store is gone (deleted from Settings): setup starts over. + if (nav.currentBackStackEntry?.id == entry.id) + nav.navigate(WelcomeRoute) { popUpTo(nav.graph.id) { inclusive = true } } + }, ) } composable { entry -> @@ -168,6 +193,7 @@ class MainActivity : BaseGitActivity() { }, ) } + setupGraph(this@MainActivity, nav) } } } @@ -183,12 +209,10 @@ class MainActivity : BaseGitActivity() { if (--secureHolds == 0) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } - /** Re-reads the store; starts onboarding and returns false when there is none yet. */ + /** Re-reads the store; false when there is none yet and setup has to run. */ fun checkStore(): Boolean { PasswordRepository.initialize() - if (sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) return true - launchActivity(OnboardingActivity::class.java) - return false + return sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } /** Git actions offered in the list's menu, chosen as the old list chose its menu. */ @@ -221,6 +245,22 @@ class MainActivity : BaseGitActivity() { private fun entryItem(file: File) = PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory()) + + companion object { + + private const val EXTRA_SETUP = "SETUP" + + /** + * Opens one setup screen in an instance of its own, for callers outside the graph (legacy + * Settings, autofill, git operations) and for those waiting for a result (the entry view's + * missing key). Leaving that screen finishes the instance; the PGP keys screen sets RESULT_OK + * once a key was imported. + */ + // ponytail: a second instance instead of in-graph navigation, until steps 6 and 7 move + // Settings and autofill into the graph. + 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/settings/PGPSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt index d2e63779..46e62faa 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/PGPSettings.kt @@ -10,9 +10,9 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.data.crypto.PGPPassphraseCache -import app.passwordstore.ui.pgp.PGPKeyListActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator -import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.features.Feature import app.passwordstore.util.settings.PreferenceKeys @@ -33,7 +33,7 @@ class PGPSettings( titleRes = R.string.pref_pgp_key_manager_title persistent = false onClick { - activity.launchActivity(PGPKeyListActivity::class.java) + activity.startActivity(MainActivity.setupIntent(activity, SetupStart.PgpKeys)) false } } diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt index fac95949..4b60aae8 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -5,7 +5,6 @@ package app.passwordstore.ui.settings -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ShortcutManager import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult @@ -16,11 +15,9 @@ import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.GitPreferences import app.passwordstore.ui.git.config.GitConfigActivity -import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.ui.main.MainActivity import app.passwordstore.ui.proxy.ProxySelectorActivity -import app.passwordstore.ui.sshkeygen.ShowSshKeyFragment -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.extensions.getString import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.extensions.sharedPrefs @@ -73,7 +70,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi titleRes = R.string.pref_edit_git_server_settings visible = PasswordRepository.isGitRepo() onClick { - activity.launchActivity(GitServerConfigActivity::class.java) + activity.startActivity(MainActivity.setupIntent(activity, SetupStart.GitServer)) true } } @@ -97,14 +94,14 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi titleRes = R.string.pref_import_ssh_key_title visible = PasswordRepository.isGitRepo() onClick { - activity.launchActivity(SshKeyImportActivity::class.java) + activity.startActivity(MainActivity.setupIntent(activity, SetupStart.Ssh)) true } } pref(PreferenceKeys.SSH_KEYGEN) { titleRes = R.string.pref_ssh_keygen_title onClick { - generateSshKey.launch(Intent(activity, SshKeyGenActivity::class.java)) + generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.SshGenerate)) true } } @@ -113,7 +110,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi titleRes = R.string.pref_ssh_see_key_title visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey onClick { - ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key") + activity.startActivity(MainActivity.setupIntent(activity, SetupStart.Ssh)) true } } 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 2f060f56..1f0c9599 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -8,6 +8,7 @@ package app.passwordstore.ui.setup import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.annotation.Keep import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -61,7 +62,8 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -/** What the PGP keys screen is for. */ +/** What the PGP keys screen is for. A route argument: kept from minification. */ +@Keep @Serializable enum class KeysMode { /** Import and delete keys (Settings). */ diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt new file mode 100644 index 00000000..ffa47511 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupGraph.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import android.app.Activity +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import app.passwordstore.ui.main.ListRoute +import app.passwordstore.ui.main.MainActivity +import kotlinx.serialization.Serializable + +/** The first screen without a store. */ +@Serializable data object WelcomeRoute + +/** Git server settings; with [clone], saving them clones the store. */ +@Serializable data class GitServerRoute(val clone: Boolean) + +/** The SSH key: state, public key, import. */ +@Serializable data object SshKeyRoute + +/** SSH key generation. */ +@Serializable data object SshKeyGenRoute + +/** The PGP keys, for [mode]. */ +@Serializable data class PgpKeysRoute(val mode: KeysMode = KeysMode.Manage) + +/** The setup screens [MainActivity.setupIntent] opens on their own. */ +enum class SetupStart(val route: Any) { + GitServer(GitServerRoute(clone = false)), + Ssh(SshKeyRoute), + SshGenerate(SshKeyGenRoute), + PgpKeys(PgpKeysRoute()), + PgpImport(PgpKeysRoute(KeysMode.Need)), +} + +/** + * The setup destinations. In an instance started for one of them ([MainActivity.setupIntent]), + * leaving its first screen finishes the activity. + */ +fun NavGraphBuilder.setupGraph(activity: MainActivity, nav: NavHostController) { + fun onTop(entry: NavBackStackEntry) = nav.currentBackStackEntry?.id == entry.id + + fun close(entry: NavBackStackEntry) { + if (!onTop(entry)) return + if (nav.previousBackStackEntry == null) activity.finish() else nav.popBackStack() + } + + // Setup is complete: the new store's list replaces the whole stack. An instance started for + // this screen alone just finishes. + fun complete(entry: NavBackStackEntry) { + if (!onTop(entry)) return + if (nav.previousBackStackEntry == null) activity.finish() + else nav.navigate(ListRoute()) { popUpTo(nav.graph.id) { inclusive = true } } + } + + composable { entry -> + WelcomeScreen( + onClone = { if (onTop(entry)) nav.navigate(GitServerRoute(clone = true)) }, + onCreate = { if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Choose)) }, + onSettings = activity::openSettings, + ) + } + composable { entry -> + GitServerDestination( + activity = activity, + clone = entry.toRoute().clone, + onClose = { close(entry) }, + onSshKey = { if (onTop(entry)) nav.navigate(SshKeyRoute) }, + onCloned = { keyNeeded -> + if (!keyNeeded) complete(entry) + else if (onTop(entry)) nav.navigate(PgpKeysRoute(KeysMode.Need)) + }, + ) + } + composable { entry -> + SshKeyDestination( + activity = activity, + onClose = { close(entry) }, + onGenerate = { if (onTop(entry)) nav.navigate(SshKeyGenRoute) }, + ) + } + composable { entry -> + SshKeyGenDestination(activity = activity, onClose = { close(entry) }) + } + composable { entry -> + PgpKeysDestination( + activity = activity, + mode = entry.toRoute().mode, + onClose = { close(entry) }, + onDone = { complete(entry) }, + onImported = { activity.setResult(Activity.RESULT_OK) }, + ) + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index a3edac7d..2d8b46ca 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -9,8 +9,8 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.sshkeygen.SshKeyGenActivity -import app.passwordstore.ui.sshkeygen.SshKeyImportActivity +import app.passwordstore.ui.main.MainActivity +import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledBySystem import app.passwordstore.util.auth.BiometricAuthenticator.Result.CanceledByUser @@ -18,7 +18,6 @@ import app.passwordstore.util.auth.BiometricAuthenticator.Result.Failure import app.passwordstore.util.auth.BiometricAuthenticator.Result.Retry import app.passwordstore.util.auth.BiometricAuthenticator.Result.Success import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.launchActivity import app.passwordstore.util.git.GitCommandExecutor import app.passwordstore.util.git.sshj.SshAuthMethod import app.passwordstore.util.git.sshj.SshKey @@ -27,8 +26,6 @@ import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -37,9 +34,6 @@ import dagger.hilt.components.SingletonComponent import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.withContext -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.userauth.password.PasswordFinder @@ -106,12 +100,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { } private fun getSshKey(generateKey: Boolean) { - runCatching { - callingActivity.launchActivity( - if (generateKey) SshKeyGenActivity::class.java else SshKeyImportActivity::class.java - ) - } - .onFailure { e -> logcat(ERROR) { e.asLog() } } + callingActivity.startActivity( + MainActivity.setupIntent( + callingActivity, + if (generateKey) SetupStart.SshGenerate else SetupStart.Ssh, + ) + ) } private fun registerAuthProviders( @@ -152,10 +146,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { ) { _, _ -> getSshKey(true) } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - } + // Cancel only closes the dialog: the calling activity is the app's own screen now. + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel), null) .show() } @@ -197,7 +189,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) { Toast.LENGTH_LONG, ) .show() - callingActivity.finish() + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) } } } else { -- cgit v1.2.3