diff options
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/list/ListDestination.kt | 352 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/main/MainActivity.kt | 111 |
2 files changed, 450 insertions, 13 deletions
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 |
