aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-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>