aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-02 21:28:50 +0100
committerDanilo M. <danix@danix.xyz>2026-11-02 21:28:50 +0100
commit32ee99b3243da8c55428538b326b159d03f93f6c (patch)
tree3a2bf2c0b7f33bb816eb6f5fe7430a8d08e3376a /app
parent6c2ee8112c2ea0d1eb40d07698813900b78af36b (diff)
downloadHardenedPass-32ee99b3243da8c55428538b326b159d03f93f6c.tar.gz
HardenedPass-32ee99b3243da8c55428538b326b159d03f93f6c.zip
feat(app): show the Compose password list in MainActivity
Folders are back stack entries (ListRoute(path)), so back goes up one level. MainActivity extends BaseGitActivity for pull to sync and the commits after move, rename and delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'app')
-rw-r--r--app/src/main/AndroidManifest.xml6
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt352
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt111
-rw-r--r--app/src/main/res/values/themes_material3.xml6
4 files changed, 460 insertions, 15 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 457647d5..5c54c683 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -66,7 +66,9 @@
<activity
android:name=".ui.main.MainActivity"
- android:exported="false" />
+ android:exported="false"
+ android:theme="@style/NoActionBarThemeM3"
+ android:windowSoftInputMode="adjustResize" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
@@ -98,7 +100,7 @@
android:name=".ui.settings.SettingsActivity"
android:exported="false"
android:label="@string/action_settings"
- android:parentActivityName=".ui.passwords.PasswordStore" />
+ android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.crypto.PasswordCreationActivity"
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
new file mode 100644
index 00000000..3045e43e
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -0,0 +1,352 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.list
+
+import android.app.Activity
+import android.content.Intent
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import app.passwordstore.R
+import app.passwordstore.data.repo.PasswordRepository
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.folderselect.SelectFolderActivity
+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.sharedPrefs
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.PreferenceKeys
+import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
+import java.io.File
+import kotlinx.collections.immutable.toImmutableSet
+import kotlinx.coroutines.launch
+
+private enum class ListDialog {
+ None,
+ Create,
+ NewFolder,
+ Rename,
+ Delete,
+ Clone,
+ NotInitialized,
+}
+
+/**
+ * The list at [path] (relative to the store root): wires [ListScreen] to the [viewModel], the
+ * legacy activities and the store operations. [onOpenFolder] takes a path relative to the root.
+ */
+@Composable
+fun ListDestination(
+ activity: MainActivity,
+ path: String,
+ onOpenFolder: (relativePath: String) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SearchableRepositoryViewModel = hiltViewModel(),
+) {
+ val root = PasswordRepository.getRepositoryDirectory()
+ val dir = File(root, path)
+ val prefs = activity.sharedPrefs
+ var query by rememberSaveable { mutableStateOf("") }
+ var selected by rememberSaveable { mutableStateOf(listOf<String>()) }
+ var dialog by rememberSaveable { mutableStateOf(ListDialog.None) }
+ var deleteCount by rememberSaveable { mutableIntStateOf(0) }
+ var refreshing by remember { mutableStateOf(false) }
+ var resumes by remember { mutableIntStateOf(0) }
+ val searchFocus = remember { FocusRequester() }
+
+ // Declared before the collection below on purpose: effects start in order, so the ViewModel
+ // already points at this folder when the first rows are built (no flash of the store root).
+ LaunchedEffect(path, query) {
+ viewModel.navigateTo(dir, pushPreviousLocation = false)
+ if (query.isNotBlank()) viewModel.search(query.trim())
+ }
+ val rows by viewModel.rows.collectAsStateWithLifecycle(ListRows())
+ val gitOps = remember(resumes) { activity.gitOps() }
+ val canSync = remember(resumes) { !prefs.getBoolean(PreferenceKeys.DISABLE_SYNC_ACTION, false) }
+
+ LaunchedEffect(Unit) {
+ if (prefs.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) searchFocus.requestFocus()
+ }
+ // Every return to this folder (back from a child, or from a legacy activity) re-reads it.
+ LifecycleResumeEffect(Unit) {
+ if (activity.checkStore()) {
+ if (path.isNotEmpty() && !dir.isDirectory) onOpenFolder("") else viewModel.forceRefresh()
+ }
+ resumes++
+ onPauseOrDispose {}
+ }
+ BackHandler(enabled = selected.isNotEmpty()) { selected = emptyList() }
+ BackHandler(enabled = selected.isEmpty() && query.isNotEmpty()) { query = "" }
+
+ val moveLauncher =
+ rememberLauncherForActivityResult(StartActivityForResult()) { result ->
+ val data = result.data
+ val target = data?.getStringExtra("SELECTED_FOLDER_PATH")?.let(::File)
+ val files = data?.getStringArrayExtra("Files")?.map(::File)
+ if (result.resultCode == Activity.RESULT_OK && target?.isDirectory == true && files != null) {
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.moveItems(files, target)
+ viewModel.forceRefresh()
+ }
+ }
+ }
+
+ fun ifInitialized(action: () -> Unit) {
+ if (PasswordRepository.isInitialized) action() else dialog = ListDialog.NotInitialized
+ }
+
+ fun runGit(op: GitOp) {
+ refreshing = true
+ activity.lifecycleScope.launch {
+ activity.runGit(op)
+ refreshing = false
+ viewModel.forceRefresh()
+ }
+ }
+
+ Box(modifier) {
+ ListScreen(
+ path = path,
+ query = query,
+ rows = rows,
+ selected = selected.toImmutableSet(),
+ refreshing = refreshing,
+ canSync = canSync,
+ gitOps = gitOps,
+ onQueryChange = { query = it },
+ onCrumb = onOpenFolder,
+ onOpenFolder = { folder ->
+ activity.recordUse(File(folder))
+ query = ""
+ onOpenFolder(File(folder).relativeTo(root).path)
+ },
+ onOpenEntry = { entry ->
+ activity.recordUse(File(entry))
+ activity.openEntry(File(entry))
+ },
+ onToggle = { p -> selected = if (p in selected) selected - p else selected + p },
+ onClearSelection = { selected = emptyList() },
+ onSync = {
+ when {
+ root.resolve(".git").listFiles().isNullOrEmpty() -> viewModel.forceRefresh()
+ !PasswordRepository.isGitRepo() -> dialog = ListDialog.Clone
+ activity.gitSettings.authMode == AuthMode.None -> runGit(GitOp.PULL)
+ else -> runGit(GitOp.SYNC)
+ }
+ },
+ onGitOp = { op -> ifInitialized { runGit(op) } },
+ onSettings = activity::openSettings,
+ onNew = { dialog = ListDialog.Create },
+ onMove = {
+ moveLauncher.launch(
+ Intent(activity, SelectFolderActivity::class.java)
+ .putExtra("Files", selected.toTypedArray())
+ )
+ },
+ onDelete = {
+ val files = selected.map(::File)
+ val count = countFiles(files)
+ if (count == 0) {
+ files.forEach { it.deleteRecursively() }
+ selected = emptyList()
+ viewModel.forceRefresh()
+ } else {
+ deleteCount = count
+ dialog = ListDialog.Delete
+ }
+ },
+ onRename = { dialog = ListDialog.Rename },
+ onPin = {
+ activity.pinEntry(File(selected.single()))
+ selected = emptyList()
+ },
+ searchFocus = searchFocus,
+ )
+
+ when (dialog) {
+ ListDialog.None -> Unit
+ ListDialog.Create ->
+ CreateSheet(
+ onDismiss = { dialog = ListDialog.None },
+ onNewEntry = {
+ dialog = ListDialog.None
+ ifInitialized { activity.createEntry(dir) }
+ },
+ onNewFolder = {
+ dialog = ListDialog.None
+ ifInitialized { dialog = ListDialog.NewFolder }
+ },
+ )
+ ListDialog.NewFolder ->
+ FolderNameDialog(
+ title = stringResource(R.string.title_create_folder),
+ message = null,
+ confirm = stringResource(R.string.button_create),
+ validate = { folderNameError(it, File(dir, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ val folder = File(dir, name).canonicalFile
+ folder.mkdirs()
+ onOpenFolder(folder.relativeTo(root.canonicalFile).path)
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ ListDialog.Rename -> {
+ val folder = File(selected.single())
+ FolderNameDialog(
+ title = stringResource(R.string.title_rename_folder),
+ message = stringResource(R.string.message_rename_folder, folder.name),
+ confirm = stringResource(R.string.dialog_ok),
+ validate = { folderNameError(it, File(folder.parentFile, it)) },
+ onConfirm = { name ->
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.renameFolder(folder, File(folder.parentFile, name))
+ viewModel.forceRefresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ ListDialog.Delete ->
+ Prompt(
+ text = pluralStringResource(R.plurals.delete_dialog_text, deleteCount, deleteCount),
+ confirm = stringResource(R.string.dialog_yes),
+ onConfirm = {
+ val files = selected.map(::File)
+ dialog = ListDialog.None
+ selected = emptyList()
+ activity.lifecycleScope.launch {
+ activity.deleteItems(files)
+ viewModel.forceRefresh()
+ }
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_no),
+ )
+ ListDialog.Clone ->
+ Prompt(
+ text = stringResource(R.string.clone_git_repo),
+ confirm = stringResource(R.string.clone_button),
+ onConfirm = {
+ dialog = ListDialog.None
+ activity.startActivity(GitServerConfigActivity.createCloneIntent(activity))
+ },
+ onDismiss = { dialog = ListDialog.None },
+ dismiss = stringResource(R.string.dialog_cancel),
+ )
+ ListDialog.NotInitialized ->
+ Prompt(
+ text = stringResource(R.string.creation_dialog_text),
+ confirm = stringResource(R.string.dialog_ok),
+ onConfirm = { dialog = ListDialog.None },
+ onDismiss = { dialog = ListDialog.None },
+ )
+ }
+ }
+}
+
+/** Asks for a folder name; [validate] returns an error string resource or null. */
+@Composable
+private fun FolderNameDialog(
+ title: String,
+ message: String?,
+ confirm: String,
+ validate: (String) -> Int?,
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf("") }
+ var error by remember { mutableStateOf<Int?>(null) }
+ val focus = remember { FocusRequester() }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ val err = error
+ Column {
+ if (message != null) Text(message, Modifier.padding(bottom = SpacingMedium))
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ error = null
+ },
+ modifier = Modifier.focusRequester(focus),
+ label = { Text(stringResource(R.string.crypto_name_hint)) },
+ supportingText =
+ if (err != null) {
+ { Text(stringResource(err)) }
+ } else null,
+ isError = err != null,
+ singleLine = true,
+ )
+ }
+ LaunchedEffect(Unit) { focus.requestFocus() }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val e = validate(name)
+ error = e
+ if (e == null) onConfirm(name)
+ }
+ ) {
+ Text(confirm)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
+ },
+ )
+}
+
+/** A one-message dialog; [dismiss] adds a second button that only closes it. */
+@Composable
+private fun Prompt(
+ text: String,
+ confirm: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ dismiss: String? = null,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
+ dismissButton =
+ if (dismiss != null) {
+ { TextButton(onClick = onDismiss) { Text(dismiss) } }
+ } else null,
+ )
+}
diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
index 48546e00..5877ae07 100644
--- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -6,49 +6,81 @@
package app.passwordstore.ui.main
import android.app.Activity
+import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.fragment.app.FragmentActivity
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
+import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.compose.theme.APSTheme
import app.passwordstore.ui.crypto.BasePGPActivity
import app.passwordstore.ui.crypto.DecryptActivity
-import app.passwordstore.ui.passwords.PasswordStore
+import app.passwordstore.ui.crypto.PasswordCreationActivity
+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.util.extensions.launchActivity
+import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.settings.AuthMode
+import app.passwordstore.util.settings.PreferenceKeys
+import app.passwordstore.util.shortcuts.ShortcutHandler
+import com.github.michaelbull.result.onFailure
import dagger.hilt.android.AndroidEntryPoint
+import java.io.File
+import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.serialization.Serializable
-/** The password list, at the store root. */
-@Serializable data object ListRoute
+/**
+ * The password list of the folder at [path], relative to the store root ("" is the root). The back
+ * stack holds one entry per folder level.
+ */
+@Serializable data class ListRoute(val path: String = "")
/** One entry. [filePath] was checked with [LaunchActivity.isFileInRepo] before routing here. */
@Serializable data class EntryRoute(val filePath: String)
/**
* Single-activity host for the Compose UI. Not exported: it is only started by [LaunchActivity],
- * after its biometric gate and path check. Extends [FragmentActivity] only because BiometricPrompt
- * needs one; it hosts no fragments.
+ * 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.
*/
@AndroidEntryPoint
-class MainActivity : FragmentActivity() {
+class MainActivity : BaseGitActivity() {
+
+ @Inject lateinit var shortcutHandler: ShortcutHandler
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 start: Any = if (filePath != null) EntryRoute(filePath) else ListRoute()
setContent {
APSTheme {
- NavHost(rememberNavController(), startDestination = start) {
- composable<ListRoute> {
- LegacyScreen(
- this@MainActivity,
- Intent(this@MainActivity, PasswordStore::class.java).setAction(Intent.ACTION_VIEW),
+ val nav = rememberNavController()
+ NavHost(nav, startDestination = start) {
+ composable<ListRoute> { entry ->
+ val path = entry.toRoute<ListRoute>().path
+ ListDestination(
+ activity = this@MainActivity,
+ path = path,
+ onOpenFolder = { target ->
+ // Only the folder on top may move the stack: a double tap must not push twice.
+ if (nav.currentBackStackEntry?.id == entry.id) {
+ val (pops, pushes) = folderMoves(path, target)
+ repeat(pops) { nav.popBackStack() }
+ pushes.forEach { nav.navigate(ListRoute(it)) }
+ }
+ },
)
}
composable<EntryRoute> { entry ->
@@ -67,6 +99,59 @@ class MainActivity : FragmentActivity() {
}
}
}
+
+ /** Re-reads the store; starts onboarding and returns false when there is none yet. */
+ fun checkStore(): Boolean {
+ PasswordRepository.initialize()
+ if (sharedPrefs.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) return true
+ launchActivity(OnboardingActivity::class.java)
+ return false
+ }
+
+ /** Git actions offered in the list's menu, chosen as the old list chose its menu. */
+ fun gitOps(): ImmutableList<GitOp> =
+ when {
+ gitSettings.authMode == AuthMode.None -> persistentListOf(GitOp.PULL)
+ PasswordRepository.isGitRepo() -> persistentListOf(GitOp.SYNC, GitOp.PULL, GitOp.PUSH)
+ else -> persistentListOf()
+ }
+
+ /** Runs [op]; failures show the usual git error dialog. */
+ suspend fun runGit(op: GitOp) {
+ launchGitOperation(op).onFailure { promptOnErrorHandler(it) }
+ }
+
+ // ponytail: the list still opens the legacy decrypt screen directly; step 3 navigates to
+ // EntryRoute instead (its trampoline would finish this activity and close the list).
+ fun openEntry(file: File) {
+ val item = entryItem(file)
+ val authIntent = item.createAuthEnabledIntent(this)
+ startActivity(Intent(authIntent).setComponent(ComponentName(this, DecryptActivity::class.java)))
+ shortcutHandler.addDynamicShortcut(item, authIntent)
+ }
+
+ fun pinEntry(file: File) {
+ val item = entryItem(file)
+ shortcutHandler.addPinnedShortcut(item, item.createAuthEnabledIntent(this))
+ }
+
+ fun createEntry(dir: File) {
+ startActivity(
+ Intent(this, PasswordCreationActivity::class.java)
+ .putExtra(BasePGPActivity.EXTRA_FILE_PATH, dir.absolutePath)
+ .putExtra(
+ BasePGPActivity.EXTRA_REPO_PATH,
+ PasswordRepository.getRepositoryDirectory().absolutePath,
+ )
+ )
+ }
+
+ fun openSettings() {
+ launchActivity(SettingsActivity::class.java)
+ }
+
+ private fun entryItem(file: File) =
+ PasswordItem.newPassword(file.name, file, PasswordRepository.getRepositoryDirectory())
}
// ponytail: trampoline to a screen not yet ported; each port replaces one call with the
diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml
index 7e34b472..0ce3796f 100644
--- a/app/src/main/res/values/themes_material3.xml
+++ b/app/src/main/res/values/themes_material3.xml
@@ -14,6 +14,12 @@
<item name="bottomSheetDialogTheme">@style/APSThemeM3.BottomSheetDialog</item>
</style>
+ <!-- AppCompat activities whose whole UI is Compose -->
+ <style name="NoActionBarThemeM3" parent="AppThemeM3">
+ <item name="windowActionBar">false</item>
+ <item name="windowNoTitle">true</item>
+ </style>
+
<style name="NoBackgroundThemeM3" parent="@style/AppThemeM3">
<item name="android:background">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>