diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3181217805..2221e32fc6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -54,6 +54,12 @@
android:name=".ui.crypto.DecryptActivity"
android:exported="true" />
+
+
= emptyList(),
) : Comparable {
+ val physicalName = name.replace("\\.gpg$".toRegex(), "")
+
val fullPathToParent = PasswordRepository.getParentPath(file.absolutePath, rootDir.absolutePath)
+ val physicalLongName =
+ PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, physicalName)
+
val longName = PasswordRepository.getLongName(fullPathToParent, rootDir.absolutePath, toString())
+ val searchableName = buildList {
+ add(physicalLongName)
+ if (mappedName != null) add(longName)
+ addAll(aliases)
+ }
+ .joinToString(" ")
+
+ fun matchesSearch(filter: String): Boolean = searchableName.contains(filter, ignoreCase = true)
+
+ fun matchesStrictDomain(regex: Regex): Boolean {
+ val physicalPath =
+ try {
+ file.relativeTo(rootDir).path
+ } catch (_: IllegalArgumentException) {
+ return false
+ }
+ if (regex.containsMatchIn(physicalPath)) return true
+
+ val logicalTokens = buildList {
+ mappedName?.split(Regex("[\\s/]+"))?.filterTo(this) { it.isNotBlank() }
+ aliases.forEach { alias ->
+ add(alias)
+ if ('@' in alias) add(alias.substringAfterLast('@'))
+ }
+ }
+ return logicalTokens.any { token -> regex.containsMatchIn("$token.gpg") }
+ }
+
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
}
override fun compareTo(other: PasswordItem): Int {
- return (type + name).compareTo(other.type + other.name, ignoreCase = true)
+ return (type + toString()).compareTo(other.type + other.toString(), ignoreCase = true)
}
override fun toString(): String {
- return name.replace("\\.gpg$".toRegex(), "")
+ return mappedName ?: physicalName
}
override fun hashCode(): Int {
@@ -42,7 +77,9 @@ data class PasswordItem(
/** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */
fun createAuthEnabledIntent(context: Context): Intent {
val intent = Intent(context, LaunchActivity::class.java)
- intent.putExtra("NAME", toString()) // this.toString
+ // Intent extras may outlive the current UI process through Android shortcuts. Keep the
+ // persisted identity physical even when a Pass-Secrets label is currently unlocked.
+ intent.putExtra("NAME", physicalName)
intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, file.absolutePath)
intent.putExtra(
BasePGPActivity.EXTRA_REPO_PATH,
@@ -70,13 +107,26 @@ data class PasswordItem(
}
@JvmStatic
- fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
- return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
+ fun newPassword(
+ name: String,
+ file: File,
+ parent: PasswordItem,
+ rootDir: File,
+ mappedName: String? = null,
+ aliases: List = emptyList(),
+ ): PasswordItem {
+ return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir, mappedName, aliases)
}
@JvmStatic
- fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
- return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
+ fun newPassword(
+ name: String,
+ file: File,
+ rootDir: File,
+ mappedName: String? = null,
+ aliases: List = emptyList(),
+ ): PasswordItem {
+ return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir, mappedName, aliases)
}
@JvmStatic
diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt
new file mode 100644
index 0000000000..abdf990bb4
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapStore.kt
@@ -0,0 +1,467 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.passsecrets
+
+import java.io.File
+import java.io.IOException
+import java.security.SecureRandom
+
+/** In-memory resolver and mutation model for Pass-Secrets identity metadata. */
+object PassSecretsMapStore {
+
+ const val MAP_FILE_NAME = ".secrets.gpg"
+ const val MASK_FILE_NAME = ".mask.gpg"
+ private const val GPG_ID_FILE_NAME = ".gpg-id"
+ private const val PENDING_VALUE = "(pendente)"
+ private const val DEFAULT_CODENAME_LENGTH = 5
+ private const val CODENAME_ATTEMPTS = 50
+ private const val CONSONANTS = "bcdfglmnprstvz"
+ private const val VOWELS = "aeiou"
+ private val validMapKey = Regex("[A-Za-z0-9_./-]+")
+ private val random = SecureRandom()
+
+ data class MaskAssociation(val alias: String, val directory: String)
+
+ data class MetadataFiles(val mapFile: File?, val maskFile: File?) {
+
+ val existingFiles: List
+ get() = listOfNotNull(mapFile, maskFile)
+ }
+
+ private data class FileVersion(val lastModified: Long, val length: Long)
+
+ private data class IdentityVersion(val map: FileVersion?, val mask: FileVersion?)
+
+ private data class LoadedMap(val version: FileVersion, val values: Map)
+
+ private data class LoadedMask(
+ val version: FileVersion,
+ val associations: List,
+ )
+
+ private val lock = Any()
+ private val loadedMaps = mutableMapOf()
+ private val loadedMasks = mutableMapOf()
+ private val gatedVersions = mutableMapOf()
+
+ /** Parse the plaintext map format ` = `. */
+ fun parse(plaintext: String): Map {
+ val values = linkedMapOf()
+ plaintext.lineSequence().forEach { rawLine ->
+ val separator = rawLine.indexOf('=')
+ if (separator <= 0) return@forEach
+
+ val key = rawLine.substring(0, separator).trim()
+ val value = rawLine.substring(separator + 1).trim()
+ if (!isValidMapKey(key) || value.isBlank() || value == PENDING_VALUE) return@forEach
+
+ // Pass-Secrets treats a path as a unique key. Last value wins for a malformed duplicate.
+ values[key] = value
+ }
+ return values
+ }
+
+ /** Parse `.mask.gpg`'s many-to-many ` = ` format. */
+ fun parseMask(plaintext: String): List {
+ return plaintext
+ .lineSequence()
+ .mapNotNull { rawLine ->
+ val separator = rawLine.indexOf('=')
+ if (separator <= 0) return@mapNotNull null
+ val alias = rawLine.substring(0, separator).trim()
+ val directory = rawLine.substring(separator + 1).trim()
+ if (alias.isBlank() || !isValidMapKey(directory)) return@mapNotNull null
+ MaskAssociation(alias, directory)
+ }
+ .distinct()
+ .sortedWith(compareBy { it.alias }.thenBy { it.directory })
+ .toList()
+ }
+
+ fun serializeMap(values: Map): String {
+ return values.toSortedMap().entries.joinToString(
+ separator = "\n",
+ postfix = if (values.isEmpty()) "" else "\n",
+ ) { (key, value) ->
+ "$key = $value"
+ }
+ }
+
+ fun serializeMask(associations: List): String {
+ val values =
+ associations
+ .distinct()
+ .sortedWith(compareBy { it.alias }.thenBy { it.directory })
+ return values.joinToString(separator = "\n", postfix = if (values.isEmpty()) "" else "\n") {
+ association ->
+ "${association.alias} = ${association.directory}"
+ }
+ }
+
+ /** Resolve the mapped display name for a physical password file, if its map is unlocked. */
+ fun mappedName(file: File, repositoryRoot: File): String? {
+ if (!isPasswordFile(file)) return null
+ val identity =
+ findNearestIdentity(file.parentFile ?: return null, repositoryRoot) ?: return null
+ val mapFile = File(identity, MAP_FILE_NAME)
+ if (!mapFile.isFile) return null
+ val relativePath = passwordRelativePath(file, identity) ?: return null
+
+ synchronized(lock) {
+ val identityKey = identity.key()
+ val loaded = loadedMaps[identityKey] ?: return null
+ if (loaded.version != mapFile.fileVersion()) {
+ loadedMaps.remove(identityKey)
+ gatedVersions.remove(identityKey)
+ return null
+ }
+ return loaded.values[relativePath]
+ }
+ }
+
+ /** Resolve any `.mask.gpg` aliases applying to this password's physical directory. */
+ fun aliases(file: File, repositoryRoot: File): List {
+ if (!isPasswordFile(file)) return emptyList()
+ val identity =
+ findNearestIdentity(file.parentFile ?: return emptyList(), repositoryRoot)
+ ?: return emptyList()
+ val maskFile = File(identity, MASK_FILE_NAME)
+ if (!maskFile.isFile) return emptyList()
+ val relativeDirectory =
+ try {
+ file.parentFile?.relativeTo(identity)?.invariantSeparatorsPath?.ifBlank { "." }
+ ?: return emptyList()
+ } catch (_: IllegalArgumentException) {
+ return emptyList()
+ }
+
+ synchronized(lock) {
+ val identityKey = identity.key()
+ val loaded = loadedMasks[identityKey] ?: return emptyList()
+ if (loaded.version != maskFile.fileVersion()) {
+ loadedMasks.remove(identityKey)
+ gatedVersions.remove(identityKey)
+ return emptyList()
+ }
+ return loaded.associations
+ .asSequence()
+ .filter { association -> directoryContains(association.directory, relativeDirectory) }
+ .map { it.alias }
+ .distinct()
+ .toList()
+ }
+ }
+
+ /**
+ * Claim this directory's nearest identity metadata for lazy unlock.
+ *
+ * A nested `.gpg-id` is always a hard boundary. Both `.secrets.gpg` and `.mask.gpg` are loaded in
+ * one authentication session when present. The returned path is merely the primary file used to
+ * launch the unlock activity.
+ */
+ fun claimForDirectory(directory: File, repositoryRoot: File): File? {
+ val identity = findNearestIdentity(directory, repositoryRoot) ?: return null
+ val metadata = identity.metadataFiles()
+ val primaryFile = metadata.mapFile ?: metadata.maskFile ?: return null
+
+ synchronized(lock) {
+ val identityKey = identity.key()
+ val version = identity.metadataVersion()
+ if (isCurrent(identityKey, metadata)) return null
+
+ val gatedVersion = gatedVersions[identityKey]
+ if (gatedVersion == version) return null
+ gatedVersions[identityKey] = version
+ return primaryFile
+ }
+ }
+
+ fun metadataFilesForDirectory(directory: File, repositoryRoot: File): MetadataFiles {
+ val identity =
+ findNearestIdentity(directory, repositoryRoot) ?: return MetadataFiles(null, null)
+ return identity.metadataFiles()
+ }
+
+ fun mapFileForDirectory(directory: File, repositoryRoot: File): File? {
+ return metadataFilesForDirectory(directory, repositoryRoot).mapFile
+ }
+
+ fun maskFileForDirectory(directory: File, repositoryRoot: File): File? {
+ return metadataFilesForDirectory(directory, repositoryRoot).maskFile
+ }
+
+ fun identityForDirectory(directory: File, repositoryRoot: File): File? {
+ return findNearestIdentity(directory, repositoryRoot)
+ }
+
+ fun isLoaded(metadataFile: File): Boolean {
+ val identity = metadataFile.parentFile ?: return false
+ synchronized(lock) {
+ return when (metadataFile.name) {
+ MAP_FILE_NAME -> loadedMaps[identity.key()]?.version == metadataFile.fileVersion()
+ MASK_FILE_NAME -> loadedMasks[identity.key()]?.version == metadataFile.fileVersion()
+ else -> false
+ }
+ }
+ }
+
+ /** Store a successfully decrypted `.secrets.gpg`. Plaintext remains process-local. */
+ fun putMap(mapFile: File, values: Map) {
+ val identity = mapFile.parentFile ?: return
+ synchronized(lock) {
+ loadedMaps[identity.key()] = LoadedMap(mapFile.fileVersion(), values.toMap())
+ gatedVersions.remove(identity.key())
+ }
+ }
+
+ /** Store a successfully decrypted `.mask.gpg`. Plaintext remains process-local. */
+ fun putMask(maskFile: File, associations: List) {
+ val identity = maskFile.parentFile ?: return
+ synchronized(lock) {
+ loadedMasks[identity.key()] = LoadedMask(maskFile.fileVersion(), associations.toList())
+ gatedVersions.remove(identity.key())
+ }
+ }
+
+ /** Backwards-compatible shorthand used by older tests/callers for `.secrets.gpg`. */
+ fun put(mapFile: File, values: Map) = putMap(mapFile, values)
+
+ fun mapSnapshot(mapFile: File): Map? {
+ if (mapFile.name != MAP_FILE_NAME || !mapFile.isFile) return null
+ val identity = mapFile.parentFile ?: return null
+ synchronized(lock) {
+ val loaded = loadedMaps[identity.key()] ?: return null
+ if (loaded.version != mapFile.fileVersion()) return null
+ return loaded.values.toMap()
+ }
+ }
+
+ fun maskSnapshot(maskFile: File): List? {
+ if (maskFile.name != MASK_FILE_NAME || !maskFile.isFile) return null
+ val identity = maskFile.parentFile ?: return null
+ synchronized(lock) {
+ val loaded = loadedMasks[identity.key()] ?: return null
+ if (loaded.version != maskFile.fileVersion()) return null
+ return loaded.associations.toList()
+ }
+ }
+
+ /** Suppress repeated automatic prompts after cancellation/failure for this metadata version. */
+ fun skip(metadataFile: File) {
+ val identity = metadataFile.parentFile ?: return
+ synchronized(lock) { gatedVersions[identity.key()] = identity.metadataVersion() }
+ }
+
+ /** Forget all decrypted labels, aliases and prompt gates, e.g. when the screen locks. */
+ fun clear() {
+ synchronized(lock) {
+ loadedMaps.clear()
+ loadedMasks.clear()
+ gatedVersions.clear()
+ }
+ }
+
+ fun isMetadataFile(file: File): Boolean {
+ return file.isFile && (file.name == MAP_FILE_NAME || file.name == MASK_FILE_NAME)
+ }
+
+ fun isProtectedIdentityMarker(file: File): Boolean {
+ if (file.name != GPG_ID_FILE_NAME || !file.isFile) return false
+ val parent = file.parentFile ?: return false
+ return File(parent, MAP_FILE_NAME).isFile || File(parent, MASK_FILE_NAME).isFile
+ }
+
+ fun passwordRelativePath(file: File, identity: File): String? {
+ if (!isPasswordFile(file)) return null
+ return try {
+ file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg")
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+
+ /** Generate a free codename exactly like Pass-Secrets 2.5.x `namegen`. */
+ fun generateCodename(directory: File, length: Int = DEFAULT_CODENAME_LENGTH): String {
+ require(length > 0) { "Codename length must be positive" }
+ repeat(CODENAME_ATTEMPTS) {
+ val startsWithConsonant = random.nextBoolean()
+ val generated =
+ buildString(length) {
+ repeat(length) { index ->
+ val useConsonant = (index % 2 == 0) == startsWithConsonant
+ val alphabet = if (useConsonant) CONSONANTS else VOWELS
+ append(alphabet[random.nextInt(alphabet.length)])
+ }
+ }
+ val codename = generated.replaceFirstChar { it.uppercaseChar() }
+ if (!File(directory, codename).exists() && !File(directory, "$codename.gpg").exists()) {
+ return codename
+ }
+ }
+ error("Unable to generate a free Pass-Secrets codename after $CODENAME_ATTEMPTS attempts")
+ }
+
+ fun mapAfterMove(
+ values: Map,
+ sourceRelativePath: String,
+ destinationRelativePath: String,
+ sourceIsDirectory: Boolean,
+ ): Map {
+ val result = linkedMapOf()
+ values.forEach { (key, value) ->
+ val movedKey =
+ when {
+ !sourceIsDirectory && key == sourceRelativePath -> destinationRelativePath
+ sourceIsDirectory && key.startsWith("$sourceRelativePath/") ->
+ destinationRelativePath + key.removePrefix(sourceRelativePath)
+ else -> key
+ }
+ result[movedKey] = value
+ }
+ return result
+ }
+
+ fun mapAfterDelete(
+ values: Map,
+ relativePath: String,
+ isDirectory: Boolean,
+ ): Map {
+ return values.filterKeys { key ->
+ if (isDirectory) key != relativePath && !key.startsWith("$relativePath/")
+ else key != relativePath
+ }
+ }
+
+ fun maskAfterMove(
+ associations: List,
+ sourceRelativePath: String,
+ destinationRelativePath: String,
+ ): List {
+ return associations
+ .map { association ->
+ val movedDirectory =
+ when {
+ association.directory == sourceRelativePath -> destinationRelativePath
+ association.directory.startsWith("$sourceRelativePath/") ->
+ destinationRelativePath + association.directory.removePrefix(sourceRelativePath)
+ else -> association.directory
+ }
+ association.copy(directory = movedDirectory)
+ }
+ .distinct()
+ }
+
+ fun maskAfterDelete(
+ associations: List,
+ relativePath: String,
+ isDirectory: Boolean,
+ ): List {
+ if (!isDirectory) return associations
+ return associations.filterNot { association ->
+ association.directory == relativePath || association.directory.startsWith("$relativePath/")
+ }
+ }
+
+ /**
+ * Return true when a generic filesystem move would change the `.gpg-id` owning encrypted files.
+ * Such a move requires decrypt/re-encrypt and must not be performed by the bulk move path.
+ */
+ fun moveRequiresReencryption(source: File, destination: File, repositoryRoot: File): Boolean {
+ if (isProtectedIdentityMarker(source)) return true
+ if (source.isFile) {
+ if (!isPasswordFile(source)) return false
+ val sourceIdentity = findNearestIdentity(source.parentFile ?: return false, repositoryRoot)
+ val targetIdentity =
+ findNearestIdentity(destination.parentFile ?: return false, repositoryRoot)
+ return !sameIdentity(sourceIdentity, targetIdentity)
+ }
+
+ if (!source.isDirectory || File(source, GPG_ID_FILE_NAME).isFile) return false
+ val sourceIdentity = findNearestIdentity(source, repositoryRoot)
+ val targetIdentity = findNearestIdentity(destination.parentFile ?: return false, repositoryRoot)
+ if (sameIdentity(sourceIdentity, targetIdentity)) return false
+
+ return source
+ .walkTopDown()
+ .onEnter { directory ->
+ directory == source || !File(directory, GPG_ID_FILE_NAME).isFile
+ }
+ .any(::isPasswordFile)
+ }
+
+ private fun isCurrent(identityKey: String, metadata: MetadataFiles): Boolean {
+ val mapCurrent =
+ metadata.mapFile == null || loadedMaps[identityKey]?.version == metadata.mapFile.fileVersion()
+ val maskCurrent =
+ metadata.maskFile == null ||
+ loadedMasks[identityKey]?.version == metadata.maskFile.fileVersion()
+ return mapCurrent && maskCurrent
+ }
+
+ private fun findNearestIdentity(start: File, repositoryRoot: File): File? {
+ val root: File
+ var current: File
+ try {
+ root = repositoryRoot.canonicalFile
+ current = start.canonicalFile
+ } catch (_: IOException) {
+ return null
+ }
+ if (!isInsideRoot(current, root)) return null
+
+ while (true) {
+ if (File(current, GPG_ID_FILE_NAME).isFile) return current
+ if (current == root) return null
+ current = current.parentFile ?: return null
+ if (!isInsideRoot(current, root)) return null
+ }
+ }
+
+ private fun isInsideRoot(file: File, root: File): Boolean {
+ val rootPath = root.absolutePath.trimEnd(File.separatorChar)
+ val path = file.absolutePath
+ return path == rootPath || path.startsWith("$rootPath${File.separator}")
+ }
+
+ private fun isValidMapKey(key: String): Boolean {
+ return key.isNotBlank() && validMapKey.matches(key) && !key.startsWith('/') && ".." !in key
+ }
+
+ private fun directoryContains(mappedDirectory: String, actualDirectory: String): Boolean {
+ return mappedDirectory == "." ||
+ actualDirectory == mappedDirectory ||
+ actualDirectory.startsWith("$mappedDirectory/")
+ }
+
+ private fun isPasswordFile(file: File): Boolean {
+ return file.isFile && file.extension.equals("gpg", ignoreCase = true) && !isMetadataFile(file)
+ }
+
+ private fun sameIdentity(first: File?, second: File?): Boolean {
+ if (first == null || second == null) return first == null && second == null
+ return first.key() == second.key()
+ }
+
+ private fun File.metadataFiles(): MetadataFiles {
+ val map = File(this, MAP_FILE_NAME).takeIf { it.isFile }
+ val mask = File(this, MASK_FILE_NAME).takeIf { it.isFile }
+ return MetadataFiles(map, mask)
+ }
+
+ private fun File.fileVersion() = FileVersion(lastModified = lastModified(), length = length())
+
+ private fun File.metadataVersion(): IdentityVersion =
+ IdentityVersion(
+ map = File(this, MAP_FILE_NAME).takeIf { it.isFile }?.fileVersion(),
+ mask = File(this, MASK_FILE_NAME).takeIf { it.isFile }?.fileVersion(),
+ )
+
+ private fun File.key(): String =
+ try {
+ canonicalPath
+ } catch (_: IOException) {
+ absolutePath
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt
new file mode 100644
index 0000000000..68f4d2cafc
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMapWriter.kt
@@ -0,0 +1,176 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.passsecrets
+
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.data.crypto.CryptoRepository
+import app.passwordstore.passsecrets.PassSecretsMapStore.MaskAssociation
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.wipe
+import com.github.michaelbull.result.getOrThrow
+import com.github.michaelbull.result.unwrapError
+import dagger.Reusable
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import javax.inject.Inject
+import kotlinx.coroutines.withContext
+
+/** Encrypts and commits Pass-Secrets metadata updates without ever persisting plaintext. */
+@Reusable
+class PassSecretsMapWriter
+@Inject
+constructor(
+ private val repository: CryptoRepository,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ sealed interface Update {
+ val file: File
+
+ data class Secrets(override val file: File, val values: Map) : Update
+
+ data class Mask(override val file: File, val associations: List) : Update
+ }
+
+ private data class StagedUpdate(
+ val update: Update,
+ val temporaryFile: File,
+ val originalCiphertext: ByteArray?,
+ )
+
+ suspend fun persist(updates: List) {
+ if (updates.isEmpty()) return
+ withContext(dispatcherProvider.io()) {
+ require(updates.map { it.file.canonicalPath }.distinct().size == updates.size) {
+ "Pass-Secrets metadata update contains duplicate target files"
+ }
+
+ val staged = mutableListOf()
+ try {
+ updates.forEach { update -> staged += stage(update) }
+ val committed = mutableListOf()
+ try {
+ staged.forEach { stagedUpdate ->
+ replace(stagedUpdate.temporaryFile, stagedUpdate.update.file)
+ committed += stagedUpdate
+ }
+ } catch (error: Throwable) {
+ committed.asReversed().forEach(::restoreBestEffort)
+ throw error
+ }
+
+ updates.forEach { update ->
+ when (update) {
+ is Update.Secrets -> PassSecretsMapStore.putMap(update.file, update.values)
+ is Update.Mask -> PassSecretsMapStore.putMask(update.file, update.associations)
+ }
+ }
+ } finally {
+ staged.forEach { stagedUpdate ->
+ stagedUpdate.temporaryFile.delete()
+ stagedUpdate.originalCiphertext?.wipe()
+ }
+ }
+ }
+ }
+
+ private fun stage(update: Update): StagedUpdate {
+ val target = update.file
+ val identity = requireNotNull(target.parentFile) { "Metadata file has no parent: $target" }
+ val plaintext =
+ when (update) {
+ is Update.Secrets -> PassSecretsMapStore.serializeMap(update.values)
+ is Update.Mask -> PassSecretsMapStore.serializeMask(update.associations)
+ }
+ val plaintextBytes = plaintext.encodeToByteArray()
+ val encryptedBytes =
+ try {
+ encrypt(identity, plaintextBytes)
+ } finally {
+ plaintextBytes.wipe()
+ }
+
+ val temporaryFile = File.createTempFile(".aps-pass-secrets-", ".tmp", identity)
+ try {
+ temporaryFile.writeBytes(encryptedBytes)
+ } finally {
+ encryptedBytes.wipe()
+ }
+ return StagedUpdate(
+ update = update,
+ temporaryFile = temporaryFile,
+ originalCiphertext = target.takeIf { it.isFile }?.readBytes(),
+ )
+ }
+
+ private fun encrypt(identity: File, plaintext: ByteArray): ByteArray {
+ val identifiers = identifiersForIdentity(identity)
+ val output = ByteArrayOutputStream()
+ val (_, result) = repository.encrypt(identifiers, plaintext.inputStream(), output)
+ if (result.isErr) throw result.unwrapError()
+ val encryptedOutput = result.getOrThrow()
+ return encryptedOutput.toByteArray().also { encryptedOutput.wipe() }
+ }
+
+ /** Pass-Secrets intentionally uses the exact identity `.gpg-id`; it never inherits a parent. */
+ private fun identifiersForIdentity(identity: File): List {
+ val gpgId = File(identity, ".gpg-id")
+ require(gpgId.isFile) { "Pass-Secrets identity has no .gpg-id: $identity" }
+
+ val identifiers = mutableListOf()
+ gpgId.readLines().forEach { rawLine ->
+ val line = rawLine.substringBefore('#').substringBefore('!').trim()
+ if (line.isBlank() || line == "gpg-id") return@forEach
+ require(!line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
+ "Short OpenPGP key IDs are not accepted in $gpgId"
+ }
+ val identifier =
+ requireNotNull(PGPIdentifier.fromString(line)) {
+ "Invalid OpenPGP identifier '$line' in $gpgId"
+ }
+ require(repository.hasKey(identifier)) { "OpenPGP key '$identifier' is not imported" }
+ identifiers += identifier
+ }
+ require(identifiers.isNotEmpty()) {
+ "Pass-Secrets identity has no usable recipients: $identity"
+ }
+ return identifiers
+ }
+
+ private fun replace(source: File, target: File) {
+ try {
+ Files.move(
+ source.toPath(),
+ target.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ }
+
+ private fun restoreBestEffort(stagedUpdate: StagedUpdate) {
+ try {
+ val target = stagedUpdate.update.file
+ val previous = stagedUpdate.originalCiphertext
+ if (previous == null) {
+ target.delete()
+ } else {
+ val restore = File.createTempFile(".aps-pass-secrets-restore-", ".tmp", target.parentFile)
+ try {
+ restore.writeBytes(previous)
+ replace(restore, target)
+ } finally {
+ restore.delete()
+ }
+ }
+ } catch (_: IOException) {}
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt
new file mode 100644
index 0000000000..e8ce10db2b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/passsecrets/PassSecretsMutationService.kt
@@ -0,0 +1,474 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.passsecrets
+
+import app.passwordstore.passsecrets.PassSecretsMapWriter.Update
+import app.passwordstore.util.coroutines.DispatcherProvider
+import app.passwordstore.util.extensions.wipe
+import dagger.Reusable
+import java.io.File
+import java.io.IOException
+import java.util.UUID
+import javax.inject.Inject
+import kotlinx.coroutines.withContext
+
+/** Coordinates physical password mutations with their encrypted Pass-Secrets metadata. */
+@Reusable
+class PassSecretsMutationService
+@Inject
+constructor(
+ private val writer: PassSecretsMapWriter,
+ private val dispatcherProvider: DispatcherProvider,
+) {
+
+ class MetadataLockedException(val metadataFile: File) :
+ IllegalStateException("Pass-Secrets metadata must be unlocked first: $metadataFile")
+
+ class ReencryptionRequiredException(source: File, destination: File) :
+ IllegalStateException(
+ "Moving $source to $destination crosses a .gpg-id boundary and requires re-encryption"
+ )
+
+ class ProtectedIdentityMarkerException(file: File) :
+ IllegalStateException("Cannot mutate $file independently from its Pass-Secrets identity")
+
+ data class PasswordWritePlan(
+ val sourceFile: File?,
+ val destinationFile: File,
+ val logicalName: String,
+ val mappedDestination: Boolean,
+ val updates: List,
+ val rollbackUpdates: List,
+ )
+
+ data class FileMovePlan(
+ val moves: List>,
+ val updates: List,
+ val rollbackUpdates: List,
+ )
+
+ data class DeletePlan(
+ val targets: List,
+ val updates: List,
+ val rollbackUpdates: List,
+ )
+
+ private data class StagedMove(val source: File, val destination: File, val backup: File?)
+
+ fun requiredMetadataForPasswordWrite(
+ sourceFile: File?,
+ destinationDirectory: File,
+ repositoryRoot: File,
+ ): List {
+ val sourceMap =
+ sourceFile?.parentFile?.let { PassSecretsMapStore.mapFileForDirectory(it, repositoryRoot) }
+ val targetMap = PassSecretsMapStore.mapFileForDirectory(destinationDirectory, repositoryRoot)
+ return listOfNotNull(sourceMap, targetMap)
+ .distinctBy { it.canonicalPath }
+ .filterNot(PassSecretsMapStore::isLoaded)
+ }
+
+ fun planPasswordWrite(
+ sourceFile: File?,
+ destinationDirectory: File,
+ requestedName: String,
+ repositoryRoot: File,
+ ): PasswordWritePlan {
+ require(destinationDirectory.isDirectory) { "Password destination is not a directory" }
+ require(requestedName.isNotBlank()) { "Password name must not be blank" }
+
+ val sourceMapFile =
+ sourceFile?.parentFile?.let { PassSecretsMapStore.mapFileForDirectory(it, repositoryRoot) }
+ val destinationMapFile =
+ PassSecretsMapStore.mapFileForDirectory(destinationDirectory, repositoryRoot)
+
+ sourceMapFile?.requireLoaded()
+ destinationMapFile?.requireLoaded()
+
+ val sameMap =
+ sourceMapFile != null &&
+ destinationMapFile != null &&
+ sourceMapFile.canonicalPath == destinationMapFile.canonicalPath
+
+ val physicalName =
+ if (destinationMapFile != null) {
+ if (sourceFile != null && sameMap) sourceFile.nameWithoutExtension
+ else PassSecretsMapStore.generateCodename(destinationDirectory)
+ } else {
+ require('/' !in requestedName && '\\' !in requestedName) {
+ "A normal pass filename cannot contain path separators"
+ }
+ requestedName
+ }
+
+ val destinationFile = File(destinationDirectory, "$physicalName.gpg")
+ if (
+ destinationFile.exists() &&
+ (sourceFile == null || destinationFile.canonicalPath != sourceFile.canonicalPath)
+ ) {
+ throw IOException("Password destination already exists: $destinationFile")
+ }
+
+ val oldMaps = linkedMapOf>>()
+ listOfNotNull(sourceMapFile, destinationMapFile).forEach { mapFile ->
+ oldMaps[mapFile.canonicalPath] = mapFile to mapFile.requireMapSnapshot()
+ }
+ val newMaps = oldMaps.mapValuesTo(linkedMapOf()) { (_, pair) -> pair.second }
+
+ if (sourceFile != null && sourceMapFile != null) {
+ val sourceIdentity = requireNotNull(sourceMapFile.parentFile) { "Source map has no parent" }
+ val sourceKey =
+ requireNotNull(PassSecretsMapStore.passwordRelativePath(sourceFile, sourceIdentity)) {
+ "Cannot resolve relative path for $sourceFile"
+ }
+ newMaps[sourceMapFile.canonicalPath] =
+ PassSecretsMapStore.mapAfterDelete(
+ newMaps.getValue(sourceMapFile.canonicalPath),
+ sourceKey,
+ isDirectory = false,
+ )
+ }
+
+ if (destinationMapFile != null) {
+ val destinationIdentity =
+ requireNotNull(destinationMapFile.parentFile) { "Destination map has no parent" }
+ val destinationKey = passwordRelativePath(destinationFile, destinationIdentity)
+ newMaps[destinationMapFile.canonicalPath] =
+ newMaps.getValue(destinationMapFile.canonicalPath) + (destinationKey to requestedName)
+ }
+
+ val updates = mutableListOf()
+ val rollback = mutableListOf()
+ oldMaps.forEach { (key, pair) ->
+ val (file, oldValues) = pair
+ val newValues = newMaps.getValue(key)
+ if (oldValues != newValues) {
+ updates += Update.Secrets(file, newValues)
+ rollback += Update.Secrets(file, oldValues)
+ }
+ }
+
+ return PasswordWritePlan(
+ sourceFile = sourceFile,
+ destinationFile = destinationFile,
+ logicalName = requestedName,
+ mappedDestination = destinationMapFile != null,
+ updates = updates,
+ rollbackUpdates = rollback,
+ )
+ }
+
+ suspend fun commitPasswordWrite(plan: PasswordWritePlan, encryptedBytes: ByteArray) {
+ withContext(dispatcherProvider.io()) {
+ val source = plan.sourceFile
+ val destination = plan.destinationFile
+ val sameFile = source?.canonicalPath == destination.canonicalPath
+ val originalDestination =
+ if (sameFile && destination.isFile) destination.readBytes() else null
+
+ try {
+ destination.parentFile?.mkdirs()
+ destination.writeBytes(encryptedBytes)
+ writer.persist(plan.updates)
+
+ if (!sameFile && source != null && source.exists() && !source.delete()) {
+ try {
+ writer.persist(plan.rollbackUpdates)
+ } finally {
+ destination.delete()
+ }
+ throw IOException("Could not remove the old password after a mapped move: $source")
+ }
+ } catch (error: Throwable) {
+ if (sameFile && originalDestination != null) {
+ destination.writeBytes(originalDestination)
+ } else if (!sameFile) {
+ destination.delete()
+ }
+ throw error
+ } finally {
+ originalDestination?.wipe()
+ }
+ }
+ }
+
+ fun requiredMetadataForMoves(
+ moves: List>,
+ repositoryRoot: File,
+ ): List {
+ val metadata = linkedMapOf()
+ moves.forEach { (source, destination) ->
+ if (PassSecretsMapStore.isProtectedIdentityMarker(source)) {
+ throw ProtectedIdentityMarkerException(source)
+ }
+ if (PassSecretsMapStore.moveRequiresReencryption(source, destination, repositoryRoot)) {
+ throw ReencryptionRequiredException(source, destination)
+ }
+ val sourceParent = source.parentFile ?: return@forEach
+ val sourceIdentity =
+ PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return@forEach
+ val destinationIdentity =
+ PassSecretsMapStore.identityForDirectory(
+ destination.parentFile ?: return@forEach,
+ repositoryRoot,
+ )
+ if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) {
+ // Moving an entire nested identity is safe cryptographically because its .gpg-id moves
+ // with it, but parent Pass-Secrets metadata could reference that directory. Refuse when
+ // such parent metadata exists rather than silently leaving stale associations.
+ val parentMetadata =
+ PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot).existingFiles
+ if (parentMetadata.isNotEmpty()) throw ReencryptionRequiredException(source, destination)
+ return@forEach
+ }
+ PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot)
+ .existingFiles
+ .forEach { file -> metadata[file.canonicalPath] = file }
+ }
+ return metadata.values.filterNot(PassSecretsMapStore::isLoaded)
+ }
+
+ fun requiredMetadataForMove(source: File, destination: File, repositoryRoot: File): List =
+ requiredMetadataForMoves(listOf(source to destination), repositoryRoot)
+
+ fun planMoves(moves: List>, repositoryRoot: File): FileMovePlan {
+ requiredMetadataForMoves(moves, repositoryRoot).firstOrNull()?.let {
+ throw MetadataLockedException(it)
+ }
+ val normalizedMoves = moves.distinctBy { (source, _) -> source.canonicalPath }
+ val original = linkedMapOf()
+ val current = linkedMapOf()
+
+ normalizedMoves.forEach { (source, destination) ->
+ val sourceParent = source.parentFile ?: return@forEach
+ val sourceIdentity =
+ PassSecretsMapStore.identityForDirectory(sourceParent, repositoryRoot) ?: return@forEach
+ val destinationIdentity =
+ PassSecretsMapStore.identityForDirectory(
+ destination.parentFile ?: sourceParent,
+ repositoryRoot,
+ )
+ if (sourceIdentity.canonicalPath != destinationIdentity?.canonicalPath) return@forEach
+
+ val sourceRelative = relativeEntryPath(source, sourceIdentity)
+ val destinationRelative = relativeEntryPath(destination, sourceIdentity)
+ val metadata = PassSecretsMapStore.metadataFilesForDirectory(sourceParent, repositoryRoot)
+
+ metadata.mapFile?.let { mapFile ->
+ val key = mapFile.canonicalPath
+ val old =
+ (original[key] as? Update.Secrets)?.values
+ ?: mapFile.requireMapSnapshot().also {
+ original[key] = Update.Secrets(mapFile, it)
+ }
+ val values = (current[key] as? Update.Secrets)?.values ?: old
+ current[key] =
+ Update.Secrets(
+ mapFile,
+ PassSecretsMapStore.mapAfterMove(
+ values,
+ sourceRelative,
+ destinationRelative,
+ source.isDirectory,
+ ),
+ )
+ }
+ metadata.maskFile?.let { maskFile ->
+ val key = maskFile.canonicalPath
+ val old =
+ (original[key] as? Update.Mask)?.associations
+ ?: maskFile.requireMaskSnapshot().also {
+ original[key] = Update.Mask(maskFile, it)
+ }
+ val values = (current[key] as? Update.Mask)?.associations ?: old
+ current[key] =
+ Update.Mask(
+ maskFile,
+ if (source.isDirectory)
+ PassSecretsMapStore.maskAfterMove(values, sourceRelative, destinationRelative)
+ else values,
+ )
+ }
+ }
+
+ return FileMovePlan(
+ moves = normalizedMoves,
+ updates = current.filter { (key, value) -> value != original[key] }.values.toList(),
+ rollbackUpdates = original.filter { (key, value) -> value != current[key] }.values.toList(),
+ )
+ }
+
+ fun planMove(source: File, destination: File, repositoryRoot: File): FileMovePlan =
+ planMoves(listOf(source to destination), repositoryRoot)
+
+ suspend fun commitMoves(plan: FileMovePlan) {
+ withContext(dispatcherProvider.io()) {
+ val staged = mutableListOf()
+ try {
+ plan.moves.forEach { (source, destination) ->
+ destination.parentFile?.mkdirs()
+ val backup =
+ if (destination.exists()) {
+ File(
+ destination.parentFile,
+ ".aps-move-backup-${UUID.randomUUID()}-${destination.name}",
+ )
+ .also { backupFile ->
+ if (!destination.renameTo(backupFile)) {
+ throw IOException("Could not stage existing destination $destination")
+ }
+ }
+ } else null
+ if (!source.renameTo(destination)) {
+ backup?.renameTo(destination)
+ throw IOException("Could not move $source to $destination")
+ }
+ staged += StagedMove(source, destination, backup)
+ }
+
+ writer.persist(plan.updates)
+ staged.forEach { it.backup?.deleteRecursively() }
+ } catch (error: Throwable) {
+ staged.asReversed().forEach { move ->
+ if (move.destination.exists()) move.destination.renameTo(move.source)
+ move.backup?.takeIf { it.exists() }?.renameTo(move.destination)
+ }
+ throw error
+ }
+ }
+ }
+
+ suspend fun commitMove(plan: FileMovePlan) = commitMoves(plan)
+
+ fun requiredMetadataForDelete(targets: List, repositoryRoot: File): List {
+ val metadata = linkedMapOf()
+ normalizeDeleteTargets(targets).forEach { target ->
+ if (PassSecretsMapStore.isProtectedIdentityMarker(target)) {
+ throw ProtectedIdentityMarkerException(target)
+ }
+ val parent = target.parentFile ?: return@forEach
+ PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot).existingFiles.forEach {
+ file ->
+ metadata[file.canonicalPath] = file
+ }
+ }
+ return metadata.values.filterNot(PassSecretsMapStore::isLoaded)
+ }
+
+ fun planDelete(targets: List, repositoryRoot: File): DeletePlan {
+ val normalizedTargets = normalizeDeleteTargets(targets)
+ requiredMetadataForDelete(normalizedTargets, repositoryRoot).firstOrNull()?.let {
+ throw MetadataLockedException(it)
+ }
+ val original = linkedMapOf()
+ val current = linkedMapOf()
+
+ normalizedTargets.forEach { target ->
+ val parent = target.parentFile ?: return@forEach
+ val identity =
+ PassSecretsMapStore.identityForDirectory(parent, repositoryRoot) ?: return@forEach
+ val relativePath = relativeEntryPath(target, identity)
+ val metadata = PassSecretsMapStore.metadataFilesForDirectory(parent, repositoryRoot)
+
+ metadata.mapFile?.let { mapFile ->
+ val key = mapFile.canonicalPath
+ val old =
+ (original[key] as? Update.Secrets)?.values
+ ?: mapFile.requireMapSnapshot().also {
+ original[key] = Update.Secrets(mapFile, it)
+ }
+ val values = (current[key] as? Update.Secrets)?.values ?: old
+ current[key] =
+ Update.Secrets(
+ mapFile,
+ PassSecretsMapStore.mapAfterDelete(values, relativePath, target.isDirectory),
+ )
+ }
+ metadata.maskFile?.let { maskFile ->
+ val key = maskFile.canonicalPath
+ val old =
+ (original[key] as? Update.Mask)?.associations
+ ?: maskFile.requireMaskSnapshot().also {
+ original[key] = Update.Mask(maskFile, it)
+ }
+ val values = (current[key] as? Update.Mask)?.associations ?: old
+ current[key] =
+ Update.Mask(
+ maskFile,
+ PassSecretsMapStore.maskAfterDelete(values, relativePath, target.isDirectory),
+ )
+ }
+ }
+
+ return DeletePlan(
+ targets = normalizedTargets,
+ updates = current.filter { (key, value) -> value != original[key] }.values.toList(),
+ rollbackUpdates = original.filter { (key, value) -> value != current[key] }.values.toList(),
+ )
+ }
+
+ suspend fun commitDelete(plan: DeletePlan) {
+ withContext(dispatcherProvider.io()) {
+ val staged = mutableListOf>()
+ try {
+ plan.targets.forEach { target ->
+ if (!target.exists()) return@forEach
+ val parent = requireNotNull(target.parentFile) { "Delete target has no parent" }
+ val temporary = File(parent, ".aps-delete-${UUID.randomUUID()}-${target.name}")
+ if (!target.renameTo(temporary)) {
+ throw IOException("Could not stage $target for deletion")
+ }
+ staged += target to temporary
+ }
+ writer.persist(plan.updates)
+ } catch (error: Throwable) {
+ staged.asReversed().forEach { (original, temporary) ->
+ if (temporary.exists()) temporary.renameTo(original)
+ }
+ throw error
+ }
+
+ // At this point the original paths are gone and encrypted metadata has committed. Cleanup is
+ // best effort: a failed unlink leaves only a hidden, unreferenced ciphertext tombstone rather
+ // than making the logical deletion inconsistent.
+ staged.forEach { (_, temporary) -> temporary.deleteRecursively() }
+ }
+ }
+
+ private fun normalizeDeleteTargets(targets: List): List {
+ val unique = targets.distinctBy { it.canonicalPath }
+ return unique.filter { candidate ->
+ unique.none { other ->
+ other != candidate &&
+ other.isDirectory &&
+ candidate.canonicalPath.startsWith(
+ other.canonicalPath.trimEnd(File.separatorChar) + File.separator
+ )
+ }
+ }
+ }
+
+ private fun File.requireLoaded() {
+ if (!PassSecretsMapStore.isLoaded(this)) throw MetadataLockedException(this)
+ }
+
+ private fun File.requireMapSnapshot(): Map {
+ return PassSecretsMapStore.mapSnapshot(this) ?: throw MetadataLockedException(this)
+ }
+
+ private fun File.requireMaskSnapshot(): List {
+ return PassSecretsMapStore.maskSnapshot(this) ?: throw MetadataLockedException(this)
+ }
+
+ private fun relativeEntryPath(file: File, identity: File): String {
+ val relative = file.relativeTo(identity).invariantSeparatorsPath
+ return if (file.isFile || file.name.endsWith(".gpg")) relative.removeSuffix(".gpg")
+ else relative
+ }
+
+ private fun passwordRelativePath(file: File, identity: File): String {
+ return file.relativeTo(identity).invariantSeparatorsPath.removeSuffix(".gpg")
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
index 48e0854c98..9970f227ff 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt
@@ -19,12 +19,14 @@ import app.passwordstore.crypto.errors.NoDecryptionKeyAvailableException
import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.data.password.FieldItem
import app.passwordstore.databinding.DecryptLayoutBinding
+import app.passwordstore.passsecrets.PassSecretsMapStore
import app.passwordstore.ui.adapters.FieldItemAdapter
import app.passwordstore.util.crypto.AESEncryption
import app.passwordstore.util.extensions.enableEdgeToEdgeView
import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.snackbar
import app.passwordstore.util.extensions.toCharArray
+import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.extensions.viewBinding
import app.passwordstore.util.extensions.wipe
import app.passwordstore.util.settings.PreferenceKeys
@@ -47,6 +49,9 @@ class DecryptActivity : BasePGPActivity() {
private var itemsAdapter: FieldItemAdapter? = null
private val binding by viewBinding(DecryptLayoutBinding::inflate)
+ private val passSecretsName by unsafeLazy {
+ PassSecretsMapStore.mappedName(File(fullPath), File(repoPath))
+ }
// temporarily AES-encrypted password entry
private var encryptedEntryChars: CharArray? = null // AES encrypted password entry
@@ -56,14 +61,15 @@ class DecryptActivity : BasePGPActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- title = name
+ val displayName = passSecretsName ?: name
+ title = displayName
with(binding) {
enableEdgeToEdgeView(root)
setContentView(root)
passwordCategory.text = relativeParentPath
- passwordFile.text = name
+ passwordFile.text = displayName
passwordFile.setOnLongClickListener {
- copyTextToClipboard(name.toCharArray(), isSensitive = false)
+ copyTextToClipboard(displayName.toCharArray(), isSensitive = false)
true
}
fab.setOnClickListener { copyPassword() }
@@ -188,7 +194,8 @@ class DecryptActivity : BasePGPActivity() {
intent.action = Intent.ACTION_VIEW
intent.putExtra(EXTRA_FILE_PATH, Paths.get(fullPath).parent.pathString)
intent.putExtra(EXTRA_REPO_PATH, repoPath)
- intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
+ intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, passSecretsName ?: name)
+ intent.putExtra(PasswordCreationActivity.EXTRA_PHYSICAL_FILE_NAME, name)
intent.putExtra(PasswordCreationActivity.EXTRA_ENTRY, encrypted)
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
startActivity(intent)
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt
new file mode 100644
index 0000000000..f76e36f96b
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/crypto/PassSecretsMapUnlockActivity.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.ui.crypto
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.core.content.edit
+import app.passwordstore.crypto.PGPIdentifier
+import app.passwordstore.crypto.errors.IncorrectPassphraseException
+import app.passwordstore.passsecrets.PassSecretsMapStore
+import app.passwordstore.util.extensions.wipe
+import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.getError
+import com.github.michaelbull.result.getOrThrow
+import dagger.hilt.android.AndroidEntryPoint
+import java.io.ByteArrayOutputStream
+import java.io.File
+import kotlinx.coroutines.withContext
+import logcat.asLog
+import logcat.logcat
+
+/** Authenticates once and loads all Pass-Secrets metadata for an identity into process memory. */
+@AndroidEntryPoint
+class PassSecretsMapUnlockActivity : BasePGPActivity() {
+
+ private var metadataLoaded = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.hide()
+ requireKeysExist {
+ requireDecryptionKeysExist(relativeParentPath) { ids -> getPersistentAndDecrypt(ids) }
+ }
+ }
+
+ override suspend fun decryptWithPassphrase(
+ passphrases: Map,
+ identifiers: List,
+ onSuccess: suspend (String) -> Unit,
+ ) {
+ val primaryFile = File(fullPath)
+ val primaryResults = decryptFile(primaryFile, passphrases, identifiers)
+ val lastResult = primaryResults.lastOrNull()
+
+ if (lastResult != null && lastResult.second.isOk) {
+ loadMetadata(primaryFile, lastResult.second.getOrThrow())
+ metadataLoaded = true
+
+ // `.secrets.gpg` and `.mask.gpg` belong to the same exact identity. Reuse the successful
+ // authentication to load the sibling too, avoiding a second passphrase/biometric prompt.
+ val identity = primaryFile.parentFile
+ if (identity != null) {
+ listOf(PassSecretsMapStore.MAP_FILE_NAME, PassSecretsMapStore.MASK_FILE_NAME)
+ .map { File(identity, it) }
+ .filter { it.isFile && it != primaryFile }
+ .forEach { sibling ->
+ val siblingResults = decryptFile(sibling, passphrases, identifiers)
+ val siblingResult = siblingResults.lastOrNull()
+ if (siblingResult != null && siblingResult.second.isOk) {
+ loadMetadata(sibling, siblingResult.second.getOrThrow())
+ } else {
+ siblingResults.forEach { result ->
+ result.second.getError()?.let { error -> logcat { error.asLog() } }
+ }
+ // Keep the good primary metadata but do not repeatedly prompt just because an
+ // optional sibling is corrupt or was encrypted inconsistently.
+ PassSecretsMapStore.skip(primaryFile)
+ }
+ }
+ }
+
+ onSuccess(lastResult.first)
+ setResult(RESULT_OK)
+ finish()
+ } else {
+ passphrases.values.forEach { it?.wipe() }
+ val incorrectPassphrase =
+ primaryResults
+ .filter { result ->
+ if (result.second.getError() is IncorrectPassphraseException) {
+ persistentPassphrases.edit { remove(result.first) }
+ cachedPassphrases[result.first]?.wipe()
+ cachedPassphrases.remove(result.first)
+ true
+ } else {
+ result.second.getError()?.let { error -> logcat { error.asLog() } }
+ false
+ }
+ }
+ .any()
+
+ if (incorrectPassphrase) decrypt(identifiers, isError = true) else finish()
+ }
+
+ if (!settings.getBoolean(PreferenceKeys.CACHE_PASSPHRASE, false)) {
+ cachedPassphrases.values.forEach { it.wipe() }
+ cachedPassphrases.clear()
+ }
+ }
+
+ private suspend fun decryptFile(
+ file: File,
+ passphrases: Map,
+ identifiers: List,
+ ) =
+ withContext(dispatcherProvider.io()) {
+ val message = file.readBytes().inputStream()
+ repository.decrypt(passphrases, identifiers, message, ByteArrayOutputStream())
+ }
+
+ private fun loadMetadata(file: File, decryptedOutput: ByteArrayOutputStream) {
+ val decryptedBytes = decryptedOutput.toByteArray()
+ decryptedOutput.wipe()
+ try {
+ when (file.name) {
+ PassSecretsMapStore.MAP_FILE_NAME ->
+ PassSecretsMapStore.putMap(
+ file,
+ PassSecretsMapStore.parse(decryptedBytes.decodeToString()),
+ )
+ PassSecretsMapStore.MASK_FILE_NAME ->
+ PassSecretsMapStore.putMask(
+ file,
+ PassSecretsMapStore.parseMask(decryptedBytes.decodeToString()),
+ )
+ else -> error("Unsupported Pass-Secrets metadata file: $file")
+ }
+ } finally {
+ decryptedBytes.wipe()
+ }
+ }
+
+ override fun onDestroy() {
+ if (!metadataLoaded) PassSecretsMapStore.skip(File(fullPath))
+ super.onDestroy()
+ }
+
+ companion object {
+
+ fun newIntent(context: Context, metadataFile: File, repositoryRoot: File): Intent {
+ return Intent(context, PassSecretsMapUnlockActivity::class.java).apply {
+ putExtra(EXTRA_FILE_PATH, metadataFile.absolutePath)
+ putExtra(EXTRA_REPO_PATH, repositoryRoot.absolutePath)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
index eed4e0d722..9b77b3426c 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt
@@ -32,6 +32,8 @@ import app.passwordstore.data.passfile.splitToCharArrayListAt
import app.passwordstore.data.passfile.trimEnd
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.databinding.PasswordCreationActivityBinding
+import app.passwordstore.passsecrets.PassSecretsMapStore
+import app.passwordstore.passsecrets.PassSecretsMutationService
import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment
import app.passwordstore.ui.dialogs.OtpImportDialogFragment
import app.passwordstore.ui.dialogs.PasswordGeneratorDialogFragment
@@ -68,15 +70,12 @@ import com.google.zxing.qrcode.QRCodeReader
import dagger.hilt.android.AndroidEntryPoint
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
+import java.io.File
import java.io.IOException
import java.nio.CharBuffer
-import java.nio.file.Paths
import javax.inject.Inject
-import kotlin.io.path.absolutePathString
import kotlin.io.path.createDirectories
import kotlin.io.path.exists
-import kotlin.io.path.pathString
-import kotlin.io.path.writeBytes
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
@@ -88,14 +87,30 @@ class PasswordCreationActivity : BasePGPActivity() {
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+ @Inject lateinit var passSecretsMutationService: PassSecretsMutationService
private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) }
+ private val suggestedPhysicalName by unsafeLazy {
+ intent.getStringExtra(EXTRA_PHYSICAL_FILE_NAME) ?: suggestedName
+ }
private val suggestedEntryChars by unsafeLazy { intent.getCharArrayExtra(EXTRA_ENTRY) }
private val shouldGeneratePassword by unsafeLazy {
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
}
private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) }
private var copy: Boolean = false
+ private var pendingSave = false
+
+ private val passSecretsUnlockAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (!pendingSave) return@registerForActivityResult
+ if (result.resultCode == RESULT_OK) {
+ updateMappedSuggestedNameAfterUnlock()
+ continueSave()
+ } else {
+ pendingSave = false
+ }
+ }
private val otpImportAction =
registerForActivityResult(StartActivityForResult()) { result ->
@@ -128,7 +143,6 @@ class PasswordCreationActivity : BasePGPActivity() {
@Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri)
}
val intArray = IntArray(bitmap.width * bitmap.height)
- // copy pixel data from the Bitmap into the 'intArray' array
bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
@@ -260,9 +274,6 @@ class PasswordCreationActivity : BasePGPActivity() {
} else if (suggestedName != null) username.requestFocus()
}
- // Allow the user to quickly switch between storing the username as the filename or
- // in the encrypted extras. This only makes sense if the directory structure is
- // FileBased.
if (
suggestedName == null &&
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
@@ -272,15 +283,11 @@ class PasswordCreationActivity : BasePGPActivity() {
visibility = View.VISIBLE
setOnClickListener {
if (isChecked) {
- // User wants to enable username encryption, so we use the filename
- // as username and insert it into the username input field.
val login = filename.text.toString()
filename.text?.clear()
username.setText(login)
usernameInputLayout.apply { visibility = View.VISIBLE }
} else {
- // User wants to disable username encryption, so we take the username
- // from the username text field and insert it into the filename input field.
val login = username.text.toString()
username.text?.clear()
filename.setText(login)
@@ -325,23 +332,62 @@ class PasswordCreationActivity : BasePGPActivity() {
setResult(RESULT_CANCELED)
onBackPressedDispatcher.onBackPressed()
}
- R.id.save_password -> {
- copy = false
- requireKeysExist {
- requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) }
- }
- }
- R.id.save_and_copy_password -> {
- copy = true
- requireKeysExist {
- requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) }
- }
- }
+ R.id.save_password -> beginSave(copyPassword = false)
+ R.id.save_and_copy_password -> beginSave(copyPassword = true)
else -> return super.onOptionsItemSelected(item)
}
return true
}
+ private fun beginSave(copyPassword: Boolean) {
+ copy = copyPassword
+ pendingSave = true
+ continueSave()
+ }
+
+ private fun continueSave() {
+ if (!pendingSave) return
+ val repositoryRoot = File(repoPath)
+ val destinationDirectory = selectedDirectory()
+ val sourceFile = sourcePasswordFile()
+ val requiredMetadata =
+ passSecretsMutationService.requiredMetadataForPasswordWrite(
+ sourceFile,
+ destinationDirectory,
+ repositoryRoot,
+ )
+ val nextMetadata = requiredMetadata.firstOrNull()
+ if (nextMetadata != null) {
+ passSecretsUnlockAction.launch(
+ PassSecretsMapUnlockActivity.newIntent(this, nextMetadata, repositoryRoot)
+ )
+ return
+ }
+
+ pendingSave = false
+ requireKeysExist {
+ requireEncryptionKeysExist(binding.directory.text.toString()) { ids -> encrypt(ids) }
+ }
+ }
+
+ private fun updateMappedSuggestedNameAfterUnlock() {
+ if (!editing) return
+ val source = sourcePasswordFile() ?: return
+ val mappedName = PassSecretsMapStore.mappedName(source, File(repoPath)) ?: return
+ if (binding.filename.text?.toString() == suggestedName) binding.filename.setText(mappedName)
+ }
+
+ private fun sourcePasswordFile(): File? {
+ if (!editing) return null
+ val physicalName = suggestedPhysicalName ?: return null
+ return File(fullPath.trimEnd('/'), "$physicalName.gpg")
+ }
+
+ private fun selectedDirectory(): File {
+ val relative = binding.directory.text.toString().trim().trim('/')
+ return File(repoPath, relative)
+ }
+
private fun generatePassword() {
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
requestKey,
@@ -367,14 +413,13 @@ class PasswordCreationActivity : BasePGPActivity() {
isEnabled = hasUsernameInFileName xor usernameIsEncrypted
isChecked = usernameIsEncrypted
}
- // Use PasswordEntry to parse extras for OTP
val entry = passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".toCharArray())
val hasTotp = entry.hasTotp()
entry.clear()
otpImportButton.isVisible = !hasTotp
}
- /** Encrypts the password and the extra content */
+ /** Encrypts the password and extra content, then atomically updates Pass-Secrets metadata. */
private fun encrypt(identifiers: List) {
with(binding) {
val editName = filename.text.toString().trim()
@@ -382,11 +427,14 @@ class PasswordCreationActivity : BasePGPActivity() {
val editPass = password.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf()
var editExtra =
extraContent.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf()
+ val destinationDirectory = selectedDirectory()
+ val mappedDestination =
+ PassSecretsMapStore.mapFileForDirectory(destinationDirectory, File(repoPath)) != null
if (editName.isEmpty()) {
snackbar(message = resources.getString(R.string.file_toast_text))
return@with
- } else if (editName.contains('/')) {
+ } else if (!mappedDestination && (editName.contains('/') || editName.contains('\\'))) {
snackbar(message = resources.getString(R.string.invalid_filename_text))
return@with
}
@@ -404,15 +452,14 @@ class PasswordCreationActivity : BasePGPActivity() {
return@with
}
- // fix extra content formatting
if (!editExtra.isEmpty()) {
editExtra = editExtra.let {
- val extraLines = it.splitToCharArrayListAt('\n').map { it.trimEnd() }
- it?.wipe()
- val editExtra = extraLines.joinToCharArray('\n')?.trimEnd()
- val editExtraPlusLineFeed = editExtra?.let { it + '\n' }
- editExtra?.wipe()
- editExtraPlusLineFeed ?: charArrayOf()
+ val extraLines = it.splitToCharArrayListAt('\n').map { line -> line.trimEnd() }
+ it.wipe()
+ val trimmed = extraLines.joinToCharArray('\n')?.trimEnd()
+ val withLineFeed = trimmed?.let { value -> value + '\n' }
+ trimmed?.wipe()
+ withLineFeed ?: charArrayOf()
}
}
@@ -421,25 +468,27 @@ class PasswordCreationActivity : BasePGPActivity() {
clearTimer = copyPasswordToClipboard(editPass.copyOf(editPass.size))
}
- // pass enters the key ID into `.gpg-id`.
- val gpgIdentifiers = getPGPIdentifiers(directory.text.toString())
- if (gpgIdentifiers.isNullOrEmpty()) return@with
-
- val path = run { // password item's full file path string
- val editRelativePath = directory.text.toString().trim()
- val passwordDirectory = Paths.get(repoPath, editRelativePath.trim('/'))
- passwordDirectory.createDirectories() // ensure destination dir exists
- if (!passwordDirectory.exists()) { // should not happen
- snackbar(message = "Failed to create directory ${editRelativePath.trimEnd('/')}")
- return
- }
-
- "${passwordDirectory.pathString}/$editName.gpg"
+ destinationDirectory.toPath().createDirectories()
+ if (!destinationDirectory.toPath().exists()) {
+ snackbar(message = "Failed to create directory ${destinationDirectory.path}")
+ return@with
}
lifecycleScope.launch(dispatcherProvider.main()) {
runCatching {
- val contentChars = (editPass + editUsername + '\n' + editExtra)
+ val plan =
+ passSecretsMutationService.planPasswordWrite(
+ sourcePasswordFile(),
+ destinationDirectory,
+ editName,
+ File(repoPath),
+ )
+ if (!plan.destinationFile.isInsideRepository()) {
+ snackbar(message = getString(R.string.message_error_destination_outside_repo))
+ return@runCatching
+ }
+
+ val contentChars = editPass + editUsername + '\n' + editExtra
val contentBytes = contentChars.toByteArray()
contentChars.wipe()
@@ -456,7 +505,6 @@ class PasswordCreationActivity : BasePGPActivity() {
if (result.isErr) throw result.unwrapError()
if (succeededUserEmails.isNullOrEmpty()) throw UnusableKeyException
- var unknownKeyCount = 0
val failedUserEmails =
identifiers
.map { id ->
@@ -469,51 +517,32 @@ class PasswordCreationActivity : BasePGPActivity() {
}
}
.distinct()
- .filter { it !in succeededUserEmails ?: emptyList() }
-
- val passwordFile = Paths.get(path)
- // If we're not editing, this file should not already exist!
- // Additionally, if we were editing and the incoming and outgoing
- // file paths differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if (
- (!editing ||
- (editing &&
- "${fullPath.trimEnd('/')}/$suggestedName.gpg" !=
- passwordFile.absolutePathString())) && passwordFile.exists()
- ) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@runCatching
+ .filter { it !in succeededUserEmails }
+
+ val encryptedOutput = result.getOrThrow()
+ val encryptedBytes = encryptedOutput.toByteArray()
+ encryptedOutput.wipe()
+ try {
+ passSecretsMutationService.commitPasswordWrite(plan, encryptedBytes)
+ } finally {
+ encryptedBytes.wipe()
}
- if (!passwordFile.toFile().isInsideRepository()) {
- snackbar(message = getString(R.string.message_error_destination_outside_repo))
- return@runCatching
- }
-
- withContext(dispatcherProvider.io()) {
- passwordFile.writeBytes(result.getOrThrow().toByteArray())
- }
-
- // create/update timestamp on the current password file
val preference = getSharedPreferences("recent_password_history", 0)
preference.edit {
- suggestedName?.let { oldFile ->
- val oldFilePathHash = "${fullPath.trimEnd('/')}/$oldFile.gpg".base64()
- remove(oldFilePathHash)
- }
+ sourcePasswordFile()?.let { oldFile -> remove(oldFile.absolutePath.base64()) }
putString(
- passwordFile.absolutePathString().base64(),
+ plan.destinationFile.absolutePath.base64(),
System.currentTimeMillis().toString(),
)
}
val returnIntent = Intent()
- returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
+ returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, plan.destinationFile.absolutePath)
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
returnIntent.putExtra(
RETURN_EXTRA_LONG_NAME,
- PasswordRepository.getLongName(fullPath, repoPath, editName),
+ PasswordRepository.getLongName(destinationDirectory.absolutePath, repoPath, editName),
)
if (shouldGeneratePassword) {
@@ -527,57 +556,60 @@ class PasswordCreationActivity : BasePGPActivity() {
val username =
entry.username?.let { it.copyOf(it.size) }
- ?: directoryStructure.getUsernameFor(passwordFile.toFile())
+ ?: directoryStructure.getUsernameFor(plan.destinationFile)
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
entry.clear()
}
- editPass?.wipe()
- editUsername?.wipe()
- editExtra?.wipe()
+ editPass.wipe()
+ editUsername.wipe()
+ editExtra.wipe()
val commitMessageRes =
if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
+ val physicalLongName =
+ PasswordRepository.getLongName(
+ destinationDirectory.absolutePath,
+ repoPath,
+ plan.destinationFile.nameWithoutExtension,
+ )
lifecycleScope.launch {
- commitChange(
- resources.getString(
- commitMessageRes,
- PasswordRepository.getLongName(fullPath, repoPath, editName),
+ commitChange(resources.getString(commitMessageRes, physicalLongName)).onOk {
+ setResult(RESULT_OK, returnIntent)
+ val dialog =
+ MaterialAlertDialogBuilder(this@PasswordCreationActivity)
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
+ var messageText =
+ getString(
+ R.string.password_creation_file_encryption_succeeded_ids_message,
+ succeededUserEmails.joinToString(),
)
- )
- .onOk {
- setResult(RESULT_OK, returnIntent)
- val dialog =
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
- var messageText =
+ if (failedUserEmails.isNotEmpty()) {
+ dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title)
+ messageText +=
getString(
- R.string.password_creation_file_encryption_succeeded_ids_message,
- succeededUserEmails.joinToString(),
+ R.string.password_creation_file_encryption_failed_ids_message,
+ failedUserEmails.joinToString(),
)
- if (!failedUserEmails.isEmpty()) {
- dialog.setTitle(R.string.password_creation_file_encryption_partial_success_title)
- messageText +=
- getString(
- R.string.password_creation_file_encryption_failed_ids_message,
- failedUserEmails.joinToString(),
- )
- } else {
- val title =
- if (editing)
- getString(R.string.password_creation_edit_file_encryption_success_title)
- else getString(R.string.password_creation_new_file_encryption_success_title)
- dialog.setTitle(title)
- }
- dialog.setMessage(messageText)
- dialog.show()
+ } else {
+ val title =
+ if (editing)
+ getString(R.string.password_creation_edit_file_encryption_success_title)
+ else getString(R.string.password_creation_new_file_encryption_success_title)
+ dialog.setTitle(title)
}
+ dialog.setMessage(messageText)
+ dialog.show()
+ }
}
}
.onErr { e ->
logcat(ERROR) { e.asLog() }
+ editPass.wipe()
+ editUsername.wipe()
+ editExtra.wipe()
setResult(RESULT_CANCELED)
val errMessage =
when (e) {
@@ -593,7 +625,7 @@ class PasswordCreationActivity : BasePGPActivity() {
.setTitle(getString(R.string.error))
.setMessage(errMessage)
.setCancelable(false)
- .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
+ .setPositiveButton(android.R.string.ok, null)
.show()
}
}
@@ -613,6 +645,7 @@ class PasswordCreationActivity : BasePGPActivity() {
const val RETURN_EXTRA_USERNAME = "USERNAME"
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
const val EXTRA_FILE_NAME = "EXTRA_FILENAME"
+ const val EXTRA_PHYSICAL_FILE_NAME = "EXTRA_PHYSICAL_FILENAME"
const val EXTRA_ENTRY = "EXTRA_ENTRY"
const val EXTRA_GENERATE_PASSWORD = "EXTRA_GENERATE_PASSWORD"
const val EXTRA_EDITING = "EXTRA_EDITING"
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
index 8f5fb6dbda..64201c0be0 100644
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
+++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt
@@ -4,6 +4,7 @@
*/
package app.passwordstore.ui.passwords
+import android.app.Activity
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
@@ -30,7 +31,9 @@ import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.databinding.PasswordRecyclerViewBinding
import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.passsecrets.PassSecretsMapStore
import app.passwordstore.ui.adapters.PasswordItemRecyclerAdapter
+import app.passwordstore.ui.crypto.PassSecretsMapUnlockActivity
import app.passwordstore.ui.dialogs.BasicBottomSheet
import app.passwordstore.ui.dialogs.ItemCreationBottomSheet
import app.passwordstore.ui.git.base.BaseGitActivity
@@ -81,6 +84,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
binding.swipeRefresher.isRefreshing = false
requireStore().refreshPasswordList()
}
+ private val passSecretsUnlockResult =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == Activity.RESULT_OK) requireStore().refreshPasswordList()
+ }
val currentDir: File
get() = model.currentDir.value
@@ -203,6 +210,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
model.navigateTo(File(path), pushPreviousLocation = false)
lifecycleScope.launch {
model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
+ maybeUnlockPassSecretsMap()
// Only run animations when the new list is filtered, i.e., the user submitted a search,
// and not on folder navigation since the latter leads to too many removal animations.
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
@@ -235,6 +243,14 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
updateFabSync()
}
+ private fun maybeUnlockPassSecretsMap() {
+ val repositoryRoot = PasswordRepository.getRepositoryDirectory()
+ val mapFile = PassSecretsMapStore.claimForDirectory(currentDir, repositoryRoot) ?: return
+ passSecretsUnlockResult.launch(
+ PassSecretsMapUnlockActivity.newIntent(requireContext(), mapFile, repositoryRoot)
+ )
+ }
+
private var fabVisible = true
private val actionModeCallback =
diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
index 067d7a581f..10a5236f41 100644
--- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
+++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt
@@ -27,8 +27,10 @@ import app.passwordstore.R
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.databinding.ActivityPwdstoreBinding
+import app.passwordstore.passsecrets.PassSecretsMutationService
import app.passwordstore.ui.crypto.BasePGPActivity
import app.passwordstore.ui.crypto.DecryptActivity
+import app.passwordstore.ui.crypto.PassSecretsMapUnlockActivity
import app.passwordstore.ui.crypto.PasswordCreationActivity
import app.passwordstore.ui.dialogs.FolderCreationDialogFragment
import app.passwordstore.ui.folderselect.SelectFolderActivity
@@ -39,7 +41,6 @@ import app.passwordstore.ui.settings.SettingsActivity
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.extensions.base64
import app.passwordstore.util.extensions.commitChange
-import app.passwordstore.util.extensions.contains
import app.passwordstore.util.extensions.enableEdgeToEdgeView
import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.isInsideRepository
@@ -75,11 +76,20 @@ const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
class PasswordStore : BaseGitActivity() {
@Inject lateinit var shortcutHandler: ShortcutHandler
+ @Inject lateinit var passSecretsMutationService: PassSecretsMutationService
private lateinit var searchItem: MenuItem
private val settings by lazy { sharedPrefs }
private val binding by viewBinding(ActivityPwdstoreBinding::inflate)
private val model: SearchableRepositoryViewModel by viewModels()
+ private var pendingPassSecretsOperation: (() -> Unit)? = null
+
+ private val passSecretsUnlockAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ val operation = pendingPassSecretsOperation
+ pendingPassSecretsOperation = null
+ if (result.resultCode == RESULT_OK) operation?.invoke()
+ }
private val gpgKeySelectAction =
registerForActivityResult(StartActivityForResult()) { result ->
@@ -99,9 +109,7 @@ class PasswordStore : BaseGitActivity() {
private val listRefreshAction =
registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- refreshPasswordList()
- }
+ if (result.resultCode == RESULT_OK) refreshPasswordList()
}
private val passwordMoveAction =
@@ -117,97 +125,61 @@ class PasswordStore : BaseGitActivity() {
"'SELECTED_FOLDER_PATH' intent extra must be set"
}
)
- val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
if (!target.isDirectory) {
logcat(ERROR) { "Tried moving passwords to a non-existing folder." }
return@registerForActivityResult
}
- logcat { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
- logcat { filesToMove.joinToString(", ") }
+ val moves =
+ filesToMove
+ .map { File(it) }
+ .filter { source -> source.exists() }
+ .map { source -> source to File(target, source.name) }
+ .filter { (source, destination) -> source.canonicalPath != destination.canonicalPath }
+ if (moves.isEmpty()) {
+ getPasswordFragment()?.dismissActionMode()
+ return@registerForActivityResult
+ }
- lifecycleScope.launch(dispatcherProvider.io()) {
- for (file in filesToMove) {
- val source = File(file)
- if (!source.exists()) {
- logcat(ERROR) { "Tried moving something that appears non-existent." }
- continue
- }
- val destinationFile = File(target.absolutePath + "/" + source.name)
- val basename = source.nameWithoutExtension
- val sourceLongName =
- PasswordRepository.getLongName(
- requireNotNull(source.parent) { "$file has no parent" },
- repositoryPath,
- basename,
+ logcat { "Moving passwords to ${target.absolutePath}" }
+ logcat { moves.joinToString(", ") { (source, _) -> source.absolutePath } }
+
+ val conflict = moves.firstOrNull { (source, destination) ->
+ destination.exists() && source.canonicalPath != destination.canonicalPath
+ }
+ if (conflict != null) {
+ val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
+ val (source, destination) = conflict
+ val sourceLongName =
+ PasswordRepository.getLongName(
+ requireNotNull(source.parent) { "Move source has no parent" },
+ repositoryPath,
+ source.nameWithoutExtension,
+ )
+ val destinationLongName =
+ PasswordRepository.getLongName(
+ requireNotNull(destination.parent) { "Move destination has no parent" },
+ repositoryPath,
+ destination.nameWithoutExtension,
+ )
+ MaterialAlertDialogBuilder(this)
+ .setTitle(resources.getString(R.string.password_exists_title))
+ .setMessage(
+ resources.getString(
+ R.string.password_exists_message,
+ destinationLongName,
+ sourceLongName,
)
- val destinationLongName =
- PasswordRepository.getLongName(target.absolutePath, repositoryPath, basename)
- if (destinationFile.exists()) {
- logcat(ERROR) { "Trying to move a file that already exists." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(resources.getString(R.string.password_exists_title))
- .setMessage(
- resources.getString(
- R.string.password_exists_message,
- destinationLongName,
- sourceLongName,
- )
- )
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .show()
- }
- } else {
- launch(dispatcherProvider.io()) { moveFile(source, destinationFile) }
- }
- }
- when (filesToMove.size) {
- 1 -> {
- val source = File(filesToMove[0])
- val basename = source.nameWithoutExtension
- val sourceLongName =
- PasswordRepository.getLongName(
- requireNotNull(source.parent) { "$basename has no parent" },
- repositoryPath,
- basename,
- )
- val destinationLongName =
- PasswordRepository.getLongName(target.absolutePath, repositoryPath, basename)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- sourceLongName,
- destinationLongName,
- )
- )
- updateFabSync()
- }
- }
- else -> {
- val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
- val relativePath =
- PasswordRepository.getRelativePath("${target.absolutePath}/", repoPath)
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(R.string.git_commit_move_multiple_text, relativePath)
- )
- updateFabSync()
- }
- }
- }
+ )
+ .setPositiveButton(R.string.dialog_ok) { _, _ -> performPasswordMoves(moves, target) }
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
+ } else {
+ performPasswordMoves(moves, target)
}
- getPasswordFragment()?.dismissActionMode()
- getPasswordFragment()?.scrollToOnNextRefresh(File(target, File(filesToMove[0]).name))
- refreshPasswordList(target)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- // open search view on search key, or Ctr+F
if (
(keyCode == KeyEvent.KEYCODE_SEARCH ||
keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded
@@ -216,7 +188,6 @@ class PasswordStore : BaseGitActivity() {
return true
}
- // open search view on any printable character and query for it
val c = event.unicodeChar.toChar()
val printable = isPrintable(c)
if (printable && !searchItem.isActionViewExpanded) {
@@ -237,9 +208,7 @@ class PasswordStore : BaseGitActivity() {
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
- if (getPasswordFragment()?.onBackPressedInActivity() != true) {
- finishAndRemoveTask()
- }
+ if (getPasswordFragment()?.onBackPressedInActivity() != true) finishAndRemoveTask()
}
},
)
@@ -264,9 +233,7 @@ class PasswordStore : BaseGitActivity() {
checkLocalRepository()
refreshPasswordList()
if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
- if (!searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- }
+ if (!searchItem.isActionViewExpanded) searchItem.expandActionView()
}
}
@@ -282,8 +249,6 @@ class PasswordStore : BaseGitActivity() {
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
- // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
- // we can get by without any noticeable difference in performance.
invalidateOptionsMenu()
searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
@@ -300,8 +265,6 @@ class PasswordStore : BaseGitActivity() {
if (settings.getString(PreferenceKeys.SEARCH_FILTER_MODE, "exact") == "fuzzy")
FilterMode.Fuzzy
else FilterMode.Exact
- // List the contents of the current directory if the user enters a blank
- // search term.
if (filter.isEmpty())
model.navigateTo(newDirectory = model.currentDir.value, pushPreviousLocation = false)
else model.search(filter, filterMode = filterMode)
@@ -310,8 +273,6 @@ class PasswordStore : BaseGitActivity() {
}
)
- // When using the support library, the setOnActionExpandListener() method is
- // static and accepts the MenuItem object as an argument
searchItem.setOnActionExpandListener(
object : OnActionExpandListener {
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
@@ -319,9 +280,7 @@ class PasswordStore : BaseGitActivity() {
return true
}
- override fun onMenuItemActionExpand(item: MenuItem): Boolean {
- return true
- }
+ override fun onMenuItemActionExpand(item: MenuItem): Boolean = true
}
)
if (
@@ -345,30 +304,16 @@ class PasswordStore : BaseGitActivity() {
.onErr { e -> e.printStackTrace() }
}
R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
+ if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.PUSH)
}
R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
+ if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.PULL)
}
R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
+ if (!PasswordRepository.isInitialized) initBefore.show() else runGitOperation(GitOp.SYNC)
}
R.id.refresh -> refreshPasswordList()
- android.R.id.home -> {
- onBackPressedDispatcher.onBackPressed()
- }
+ android.R.id.home -> onBackPressedDispatcher.onBackPressed()
else -> return super.onOptionsItemSelected(item)
}
return true
@@ -394,7 +339,6 @@ class PasswordStore : BaseGitActivity() {
private fun checkLocalRepository(localDir: File?) {
if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
- // do not push the fragment if we already have it
if (
getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)
) {
@@ -402,11 +346,7 @@ class PasswordStore : BaseGitActivity() {
val args = Bundle()
args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
- // if the activity was started from the autofill settings, the
- // intent is to match a clicked pwd with app. pass this to fragment
- if (intent.getBooleanExtra("matchWith", false)) {
- args.putBoolean("matchWith", true)
- }
+ if (intent.getBooleanExtra("matchWith", false)) args.putBoolean("matchWith", true)
supportActionBar?.apply {
show()
setDisplayHomeAsUpEnabled(false)
@@ -427,8 +367,6 @@ class PasswordStore : BaseGitActivity() {
Intent(authDecryptIntent).setComponent(ComponentName(this, DecryptActivity::class.java))
startActivity(decryptIntent)
-
- // Adds shortcut
shortcutHandler.addDynamicShortcut(item, authDecryptIntent)
}
@@ -464,55 +402,124 @@ class PasswordStore : BaseGitActivity() {
fun deletePasswords(selectedItems: List) {
var size = 0
- selectedItems.forEach {
- if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size
+ selectedItems.forEach { item ->
+ if (item.file.isFile) size++ else size += item.file.listFilesRecursively().size
}
- if (size == 0) { // delete empty directory trees without confirmation
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
+ if (size == 0) {
+ performDelete(selectedItems)
return
}
MaterialAlertDialogBuilder(this)
.setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
.setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
- val filesToDelete = arrayListOf()
- selectedItems.forEach { item ->
- if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
- else filesToDelete.add(item.file)
- }
- // remove to-be-deleted files from history
- val preference = getSharedPreferences("recent_password_history", 0)
- preference.edit {
- filesToDelete.forEach { file ->
- remove(file.absolutePath.base64())
- }
- }
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
- val fmt =
- selectedItems.joinToString(separator = ", ") { item ->
- item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
- }
- lifecycleScope.launch {
- commitChange(resources.getString(R.string.git_commit_remove_text, fmt))
- updateFabSync()
- }
+ performDelete(selectedItems)
}
.setNegativeButton(resources.getString(R.string.dialog_no), null)
.show()
}
+ private fun performDelete(selectedItems: List) {
+ val targets = selectedItems.map { it.file }
+ val repositoryRoot = PasswordRepository.getRepositoryDirectory()
+ withUnlockedPassSecretsMetadata(
+ required = {
+ passSecretsMutationService.requiredMetadataForDelete(targets, repositoryRoot)
+ },
+ action = {
+ lifecycleScope.launch {
+ try {
+ val filesToDelete = targets.flatMap { target ->
+ if (target.isDirectory) target.listFilesRecursively() else listOf(target)
+ }
+ val plan = passSecretsMutationService.planDelete(targets, repositoryRoot)
+ withContext(dispatcherProvider.io()) { passSecretsMutationService.commitDelete(plan) }
+
+ val preference = getSharedPreferences("recent_password_history", 0)
+ preference.edit {
+ filesToDelete.forEach { file -> remove(file.absolutePath.base64()) }
+ }
+ AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
+
+ val fmt =
+ targets.joinToString(separator = ", ") { target ->
+ target.toRelativeString(repositoryRoot)
+ }
+ commitChange(resources.getString(R.string.git_commit_remove_text, fmt))
+ updateFabSync()
+ getPasswordFragment()?.dismissActionMode()
+ refreshPasswordList()
+ } catch (error: Throwable) {
+ showPassSecretsMutationError(error)
+ }
+ }
+ },
+ )
+ }
+
fun movePasswords(values: List) {
val intent = Intent(this, SelectFolderActivity::class.java)
val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
intent.putExtra("Files", fileLocations)
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
val relPath = PasswordRepository.getRelativePath(currentDir.absolutePath, repoPath)
- if (!relPath.isEmpty()) intent.putExtra(PasswordStore.REQUEST_ARG_PATH, relPath)
+ if (!relPath.isEmpty()) intent.putExtra(REQUEST_ARG_PATH, relPath)
passwordMoveAction.launch(intent)
}
+ private fun performPasswordMoves(moves: List>, target: File) {
+ val repositoryRoot = PasswordRepository.getRepositoryDirectory()
+ withUnlockedPassSecretsMetadata(
+ required = { passSecretsMutationService.requiredMetadataForMoves(moves, repositoryRoot) },
+ action = {
+ lifecycleScope.launch {
+ try {
+ val sourceDestinationMap = buildSourceDestinationMap(moves)
+ val plan = passSecretsMutationService.planMoves(moves, repositoryRoot)
+ withContext(dispatcherProvider.io()) { passSecretsMutationService.commitMoves(plan) }
+ updateHistoryAfterMove(sourceDestinationMap)
+ AutofillMatcher.updateMatches(applicationContext, sourceDestinationMap)
+
+ val repositoryPath = repositoryRoot.absolutePath
+ if (moves.size == 1) {
+ val (source, destination) = moves.single()
+ val sourceLongName =
+ PasswordRepository.getLongName(
+ requireNotNull(source.parent) { "Move source has no parent" },
+ repositoryPath,
+ source.nameWithoutExtension,
+ )
+ val destinationLongName =
+ PasswordRepository.getLongName(
+ requireNotNull(destination.parent) { "Move destination has no parent" },
+ repositoryPath,
+ destination.nameWithoutExtension,
+ )
+ commitChange(
+ resources.getString(
+ R.string.git_commit_move_text,
+ sourceLongName,
+ destinationLongName,
+ )
+ )
+ } else {
+ val relativePath =
+ PasswordRepository.getRelativePath("${target.absolutePath}/", repositoryPath)
+ commitChange(
+ resources.getString(R.string.git_commit_move_multiple_text, relativePath)
+ )
+ }
+ updateFabSync()
+ getPasswordFragment()?.dismissActionMode()
+ getPasswordFragment()?.scrollToOnNextRefresh(moves.first().second)
+ refreshPasswordList(target)
+ } catch (error: Throwable) {
+ showPassSecretsMutationError(error)
+ }
+ }
+ },
+ )
+ }
+
enum class CategoryRenameError(val resource: Int) {
None(0),
EmptyField(R.string.message_category_error_empty_field),
@@ -520,18 +527,6 @@ class PasswordStore : BaseGitActivity() {
DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
}
- /**
- * Prompt the user with a new category name to assign, if the new category forms/leads a path
- * (i.e. contains "/"), intermediate directories will be created and new category will be placed
- * inside.
- *
- * @param oldCategory The category to change its name
- * @param error Determines whether to show an error to the user in the alert dialog, this error
- * may be due to the new category the user entered already exists or the field was empty or the
- * destination path is outside the repository
- * @see [CategoryRenameError]
- * @see [isInsideRepository]
- */
private fun renameCategory(
oldCategory: PasswordItem,
error: CategoryRenameError = CategoryRenameError.None,
@@ -540,9 +535,7 @@ class PasswordStore : BaseGitActivity() {
val newCategoryEditText = view.findViewById(R.id.folder_name_text)
val folderNameViewContainer = view.findViewById(R.id.folder_name_container)
- if (error != CategoryRenameError.None) {
- folderNameViewContainer.error = getString(error.resource)
- }
+ if (error != CategoryRenameError.None) folderNameViewContainer.error = getString(error.resource)
val dialog =
MaterialAlertDialogBuilder(this)
@@ -557,34 +550,7 @@ class PasswordStore : BaseGitActivity() {
newCategoryEditText.text.isNullOrBlank() ->
renameCategory(oldCategory, CategoryRenameError.EmptyField)
newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
- else ->
- lifecycleScope.launch(dispatcherProvider.io()) {
- moveFile(oldCategory.file, newCategory)
-
- // associate the new category with the last category's timestamp in
- // history
- val preference = getSharedPreferences("recent_password_history", 0)
- val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
- if (timestamp != null) {
- preference.edit {
- remove(oldCategory.file.absolutePath.base64())
- putString(newCategory.absolutePath.base64(), timestamp)
- }
- }
-
- withContext(dispatcherProvider.main()) {
- commitChange(
- resources.getString(
- R.string.git_commit_move_text,
- oldCategory.name,
- newCategory.name,
- )
- )
- updateFabSync()
- }
-
- refreshPasswordList()
- }
+ else -> performCategoryRename(oldCategory, newCategory)
}
}
.setNegativeButton(R.string.dialog_cancel, null)
@@ -594,9 +560,118 @@ class PasswordStore : BaseGitActivity() {
dialog.show()
}
+ private fun performCategoryRename(oldCategory: PasswordItem, newCategory: File) {
+ val repositoryRoot = PasswordRepository.getRepositoryDirectory()
+ withUnlockedPassSecretsMetadata(
+ required = {
+ passSecretsMutationService.requiredMetadataForMove(
+ oldCategory.file,
+ newCategory,
+ repositoryRoot,
+ )
+ },
+ action = {
+ lifecycleScope.launch {
+ try {
+ val sourceDestinationMap =
+ buildSourceDestinationMap(listOf(oldCategory.file to newCategory))
+ val categoryPreference = getSharedPreferences("recent_password_history", 0)
+ val categoryTimestamp =
+ categoryPreference.getString(oldCategory.file.absolutePath.base64())
+ val plan =
+ passSecretsMutationService.planMove(oldCategory.file, newCategory, repositoryRoot)
+ withContext(dispatcherProvider.io()) { passSecretsMutationService.commitMove(plan) }
+ updateHistoryAfterMove(sourceDestinationMap)
+ if (categoryTimestamp != null) {
+ categoryPreference.edit {
+ remove(oldCategory.file.absolutePath.base64())
+ putString(newCategory.absolutePath.base64(), categoryTimestamp)
+ }
+ }
+ AutofillMatcher.updateMatches(applicationContext, sourceDestinationMap)
+
+ commitChange(
+ resources.getString(
+ R.string.git_commit_move_text,
+ oldCategory.name,
+ newCategory.name,
+ )
+ )
+ updateFabSync()
+ refreshPasswordList(newCategory)
+ } catch (error: Throwable) {
+ showPassSecretsMutationError(error)
+ }
+ }
+ },
+ )
+ }
+
fun renameCategory(categories: List) {
- for (oldCategory in categories) {
- renameCategory(oldCategory)
+ for (oldCategory in categories) renameCategory(oldCategory)
+ }
+
+ private fun withUnlockedPassSecretsMetadata(required: () -> List, action: () -> Unit) {
+ try {
+ val nextMetadata = required().firstOrNull()
+ if (nextMetadata == null) {
+ pendingPassSecretsOperation = null
+ action()
+ return
+ }
+ pendingPassSecretsOperation = { withUnlockedPassSecretsMetadata(required, action) }
+ passSecretsUnlockAction.launch(
+ PassSecretsMapUnlockActivity.newIntent(
+ this,
+ nextMetadata,
+ PasswordRepository.getRepositoryDirectory(),
+ )
+ )
+ } catch (error: Throwable) {
+ pendingPassSecretsOperation = null
+ showPassSecretsMutationError(error)
+ }
+ }
+
+ private fun showPassSecretsMutationError(error: Throwable) {
+ val message =
+ when (error) {
+ is PassSecretsMutationService.ReencryptionRequiredException ->
+ getString(R.string.pass_secrets_reencryption_required)
+ is PassSecretsMutationService.ProtectedIdentityMarkerException ->
+ getString(R.string.pass_secrets_identity_marker_protected)
+ else -> getString(R.string.pass_secrets_mutation_error, error.message ?: error.toString())
+ }
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.error)
+ .setMessage(message)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+
+ private fun buildSourceDestinationMap(moves: List>): Map {
+ return buildMap {
+ moves.forEach { (source, destination) ->
+ if (source.isDirectory) {
+ source.listFilesRecursively().forEach { child ->
+ put(child, destination.resolve(child.relativeTo(source)))
+ }
+ } else {
+ put(source, destination)
+ }
+ }
+ }
+ }
+
+ private fun updateHistoryAfterMove(sourceDestinationMap: Map) {
+ val preference = getSharedPreferences("recent_password_history", 0)
+ preference.edit {
+ sourceDestinationMap.forEach { (source, destination) ->
+ val sourceHash = source.absolutePath.base64()
+ val timestamp = preference.getString(sourceHash)
+ remove(sourceHash)
+ if (timestamp != null) putString(destination.absolutePath.base64(), timestamp)
+ }
}
}
@@ -604,12 +679,6 @@ class PasswordStore : BaseGitActivity() {
runOnUiThread { getPasswordFragment()?.updateFabSync() }
}
- /**
- * Refreshes the password list by re-executing the last navigation or search action, preserving
- * the navigation stack and scroll position. If the current directory no longer exists, navigation
- * is reset to the repository root. If the optional [target] argument is provided, it will be
- * entered if it is a directory or scrolled into view if it is a file.
- */
fun refreshPasswordList(target: File? = null) {
val relativeTargetPath = target?.let {
require(it.isInsideRepository()) { "Trying to access target outside the repository" }
@@ -622,7 +691,7 @@ class PasswordStore : BaseGitActivity() {
relativeTargetPath.trim('/').split('/').forEach { item ->
val file = File(model.currentDir.value, item)
if (file.isDirectory) {
- if (file.equals(model.currentDir.value)) model.forceRefresh()
+ if (file == model.currentDir.value) model.forceRefresh()
else model.navigateTo(file, pushPreviousLocation = true)
} else getPasswordFragment()?.scrollToOnNextRefresh(file)
}
@@ -638,48 +707,10 @@ class PasswordStore : BaseGitActivity() {
private val currentDir: File
get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
- private suspend fun moveFile(source: File, destinationFile: File) {
- val sourceDestinationMap =
- if (source.isDirectory) {
- destinationFile.mkdirs()
- // Recursively list all files (not directories) below `source`, then
- // obtain the corresponding target file by resolving the relative path
- // starting at the destination folder.
- source.listFilesRecursively().associateWith {
- destinationFile.resolve(it.relativeTo(source))
- }
- } else {
- mapOf(source to destinationFile)
- }
- if (!source.renameTo(destinationFile)) {
- logcat(ERROR) { "Something went wrong while moving $source to $destinationFile." }
- withContext(dispatcherProvider.main()) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(R.string.password_move_error_title)
- .setMessage(getString(R.string.password_move_error_message, source, destinationFile))
- .setCancelable(true)
- .setPositiveButton(android.R.string.ok, null)
- .show()
- }
- } else {
- // update timestamp cache with the new file locations
- val preference = getSharedPreferences("recent_password_history", 0)
- preference.edit {
- sourceDestinationMap.forEach { (src, dest) ->
- val srcPathHash = src.absolutePath.base64()
- val timestamp = preference.getString(srcPathHash)
- remove(srcPathHash)
- putString(dest.absolutePath.base64(), timestamp)
- }
- }
- AutofillMatcher.updateMatches(this, sourceDestinationMap)
- }
- }
-
fun matchPasswordWithApp(item: PasswordItem) {
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
val path =
- PasswordRepository.getRelativePath(item.file.absolutePath, repoPath + "/").replace(".gpg", "")
+ PasswordRepository.getRelativePath(item.file.absolutePath, "$repoPath/").replace(".gpg", "")
val data = Intent()
data.putExtra("path", path)
setResult(RESULT_OK, data)
diff --git a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
index 57dd87bee1..642d04ce24 100644
--- a/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
+++ b/app/src/main/java/app/passwordstore/util/shortcuts/ShortcutHandler.kt
@@ -71,9 +71,11 @@ class ShortcutHandler @Inject constructor(@ApplicationContext val context: Conte
/** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */
private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo {
- return ShortcutInfo.Builder(context, item.longName)
- .setShortLabel(item.toString())
- .setLongLabel("/${item.longName}")
+ // Android persists launcher shortcuts outside this process. Pass-Secrets labels therefore must
+ // not be used here: only the obfuscated physical path is safe to persist.
+ return ShortcutInfo.Builder(context, item.physicalLongName)
+ .setShortLabel(item.physicalName)
+ .setLongLabel("/${item.physicalLongName}")
.setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
.setIntent(intent)
.build()
diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
index 0aba857572..3e8b2e5761 100644
--- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -4,6 +4,7 @@
*/
package app.passwordstore.util.viewmodel
+import android.annotation.SuppressLint
import android.app.Application
import android.content.SharedPreferences
import android.os.Parcelable
@@ -24,6 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.injection.prefs.SettingsPreferences
+import app.passwordstore.passsecrets.PassSecretsMapStore
import app.passwordstore.util.autofill.AutofillPreferences
import app.passwordstore.util.checkMainThread
import app.passwordstore.util.coroutines.DispatcherProvider
@@ -55,17 +57,24 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import me.zhanghai.android.fastscroll.PopupTextProvider
-private fun File.toPasswordItem() =
- if (isFile) {
- if (name == ".gpg-id")
- PasswordItem.newGpgIdItem(name, this, PasswordRepository.getRepositoryDirectory())
+private fun File.toPasswordItem(): PasswordItem {
+ val root = PasswordRepository.getRepositoryDirectory()
+ return if (isFile) {
+ if (name == ".gpg-id") PasswordItem.newGpgIdItem(name, this, root)
else if (extension == "gpg")
- PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
- else PasswordItem.newOtherItem(name, this, PasswordRepository.getRepositoryDirectory())
- } else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
+ PasswordItem.newPassword(
+ name,
+ this,
+ root,
+ PassSecretsMapStore.mappedName(this, root),
+ PassSecretsMapStore.aliases(this, root),
+ )
+ else PasswordItem.newOtherItem(name, this, root)
+ } else PasswordItem.newCategory(name, this, root)
+}
private fun PasswordItem.fuzzyMatch(filter: String): Int {
- val (_, score) = Fuzzy.fuzzyMatch(filter, longName)
+ val (_, score) = Fuzzy.fuzzyMatch(filter, searchableName)
return score
}
@@ -243,13 +252,8 @@ constructor(
}
FilterMode.Exact -> {
prefilteredResultFlow
- .filter { absoluteFile ->
- absoluteFile
- .relativeTo(root)
- .path
- .contains(searchAction.filter, ignoreCase = true)
- }
.map { it.toPasswordItem() }
+ .filter { item -> item.matchesSearch(searchAction.filter) }
.flowOn(dispatcherProvider.io())
.toList()
.sortedWith(itemComparator)
@@ -261,10 +265,8 @@ constructor(
val regex = generateStrictDomainRegex(searchAction.filter)
if (regex != null) {
prefilteredResultFlow
- .filter { absoluteFile ->
- regex.containsMatchIn(absoluteFile.relativeTo(root).path)
- }
.map { it.toPasswordItem() }
+ .filter { item -> item.matchesStrictDomain(regex) }
.flowOn(dispatcherProvider.io())
.toList()
.sortedWith(itemComparator)
@@ -277,10 +279,11 @@ constructor(
}
.flowOn(dispatcherProvider.io())
- private fun shouldTake(file: File) =
- with(file) {
+ private fun shouldTake(file: File): Boolean {
+ if (PassSecretsMapStore.isMetadataFile(file)) return false
+ return with(file) {
if (showHiddenContents) {
- return !file.name.startsWith(".git")
+ return@with !file.name.startsWith(".git")
}
if (isDirectory) {
!isHidden
@@ -288,6 +291,7 @@ constructor(
!isHidden && file.extension == "gpg"
}
}
+ }
private fun listFiles(dir: File): Flow {
return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow()
@@ -413,7 +417,11 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback()
override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
oldItem.file.absolutePath == newItem.file.absolutePath
- override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
+ @SuppressLint("DiffUtilEquals")
+ override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
+ oldItem.file == newItem.file &&
+ oldItem.mappedName == newItem.mappedName &&
+ oldItem.aliases == newItem.aliases
}
open class SearchableRepositoryAdapter(
@@ -515,6 +523,7 @@ open class SearchableRepositoryAdapter(
}
final override fun getPopupText(view: View, position: Int): String {
- return getItem(position).name[0].toString().uppercase(Locale.getDefault())
+ return getItem(position).toString().firstOrNull()?.toString()?.uppercase(Locale.getDefault())
+ ?: ""
}
}
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 574956d336..ee460b7528 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -29,8 +29,6 @@
Elimina
La contrasenya ja existeix.
Aquesta acció sobreescriurà %1$s amb %2$s.
- Error al moure contrasenyes
- No s\'ha pogut moure %1$s a %2$s
Afegeix contrasenya generada per %1$s usant Android Password Store
Edita contrasenya per %1$s usant Android Password Store.
Suprimeix %1$s del magatzem.
@@ -252,7 +250,6 @@
L\'emplenament automàtic distingirà subdominis d\'aquests dominis
company.com\npersonal.com
No s\'ha pogut escriute el fitxer de contrasenya al magatzem, prova un altre cop.
- El fitxer ja existeix, usa un nom diferent
Afegeix OTP
S\'ha importat al configuració OTP correctament
No s\'ha pogut importar la configuració OTP
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 1c74a595e9..6908e15e42 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -34,8 +34,6 @@
Fehler
Passwort existiert bereits!
Dies wird %1$s mit %2$s überschreiben.
- Fehler beim Verschieben von Passwörtern
- Verschieben von %1$s nach %2$s fehlgeschlagen.
Füge erstelltes Passwort für %1$s mittels Android Password Store hinzu.
@@ -333,7 +331,6 @@
Fehler beim Speichern der Passwortdatei im Password Store, bitte versuchen Sie es erneut.
Verschlüsseln fehlgeschlagen. Überprüfen Sie Ablaufdatum und Widerrufstatus der PGP-Schlüssel mithilfe eines externen Programms.
Keine passenden PGP-Schlüssel gefunden.
- Datei existiert bereits, bitte verwenden Sie einen anderen Dateinamen.
OTP hinzufügen
TOTP-Konfiguration erfolgreich importiert
Import der TOTP-Konfiguration fehlgeschlagen
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 649ddd267d..abdcd125a5 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -35,8 +35,6 @@
Eliminar
¡La contraseña ya existe!
Esto sobrescribirá %1$s con %2$s.
- Error al mover contraseñas
- No se pudo mover %1$s a %2$s
Agregue la contraseña generada para %1$s usando Android Password Store.
@@ -295,7 +293,6 @@
No se pudo escribir el archivo de contraseña en el almacén, inténtelo de nuevo.
- El archivo ya existe, por favor use un nombre diferente
Agregar TOTP
Configuración de TOTP importada correctamente
No se pudo importar la configuración de TOTP
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 6c7e315917..fedee3d4ef 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -28,8 +28,6 @@
Supprimer
Le mot de passe existe!
Cela écrasera %1$s avec %2$s.
- Erreur lors du déplacement des mots de passe
- Impossible de déplacer %1$s vers %2$s
Ajout par Android Password Store d\'un mot de passe pour %1$s.
Modification par Android Password Store du mot de passe pour %1$s.
@@ -246,7 +244,6 @@
societe.com\npersonnel.com
Impossible d\'écrire le fichier de mot de passe dans le magasin, veuillez réessayer.
- Le fichier existe déjà, veuillez utiliser un autre nom
Ajouter OTP
Configuration TOTP importée avec succès
Impossible d\'importer la configuration TOTP
diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml
index b7fb810a3f..8abfe32de4 100644
--- a/app/src/main/res/values-gl/strings.xml
+++ b/app/src/main/res/values-gl/strings.xml
@@ -28,8 +28,6 @@
Eliminar
O contrasinal xa existe!
Vas sobrescribir %1$s con %2$s.
- Erro ao mover os contrasinais
- Non se mudou %1$s por %2$s
Engadir o contrasinal creado para %1$s usando Android Password Store.
Editar contrasinal para %1$s usando Android Password Store.
@@ -254,7 +252,6 @@ a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid
empresa.com\npersoal.com
Fallo ó escribir o ficheiro de contrasinal no almacén, inténtao outra vez.
- Xa existe o ficheiro, usa un nome diferente
Engade OTP
Importouse correctamente a configuración TOTP
Fallou a importación da configuración TOTP
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index ef1c2a9736..48d8ddddc9 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -28,8 +28,6 @@
Elimina
La password esiste già!
Questo sovrascriverà %1$s con %2$s.
- Errore spostando le password
- Impossibile spostare %1$s a %2$s
Aggiungi password generata per %1$s usando Android Password Store.
Modifica password per %1$s usando Android Password Store.
@@ -241,7 +239,6 @@
company.com\npersonal.com
Impossibile scrivere il file delle password al negozio, sei pregato di riprovare.
- Il file esiste già, sei pregato di usare un nome differente
Aggiungi OTP
Configurazione TOTP importata correttamente
Impossibile importare la configurazione di TOTP
diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml
index 85b2ae89a1..7733b121ff 100644
--- a/app/src/main/res/values-ko-rKR/strings.xml
+++ b/app/src/main/res/values-ko-rKR/strings.xml
@@ -26,8 +26,6 @@
삭제
이미 추가된 비밀번호입니다!
%1$s 내용이 %2$s 로 덮어씌워집니다.
- 비밀번호를 옮기는 중 문제가 발생하였습니다.
- %1$s 를 %2$s 로 옮기는데 실패하였습니다.
Android Password Store 를 이용해 %1$s 의 비밀번호를 생성하였습니다.
Android Password Store 를 이용해 %1$s 의 비밀번호를 변경하였습니다.
@@ -253,7 +251,6 @@ username@example.com:…
company.com\npersonal.com
스토어에 비밀번호 파일을 작성하지 못했습니다. 다시 시도해 주세요.
- 파일이 이미 존재합니다. 다른 이름을 사용하세요
OTP 추가
TOTP 구성을 성공적으로 가져왔습니다
TOTP 구성을 가져오지 못했습니다.
diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml
index 0610c2f92a..85cf3d1b88 100644
--- a/app/src/main/res/values-pl-rPL/strings.xml
+++ b/app/src/main/res/values-pl-rPL/strings.xml
@@ -27,8 +27,6 @@
Usuń
Hasło już istnieje!
%1$s zostanie nadpisane przez %2$s.
- Błąd podczas przenoszenia haseł
- Nie udało się przenieść %1$s do %2$s
Dodaj wygenerowane hasło dla %1$s przy użyciu Android Password Store.
Edytuj hasło dla %1$s za pomocą Android Password Store.
@@ -248,7 +246,6 @@
Autouzupełnianie rozróżni subdomeny tych domen
Nie udało się zapisać pliku z hasłem do repozytorium, spróbuj ponownie.
- Plik o tej nazwie już istnieje, użyj innej nazwy
Dodaj OTP
Pomyślnie zaimportowano konfigurację TOTP
Znaleziono plik .gpg-id, ale zawiera on nieprawidłowy lub nieznany identyfikator klucza.
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index fb7441153d..c2da2db0f3 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -28,8 +28,6 @@
Excluir
Senha já existe!
Isso irá sobrescrever %1$s com %2$s.
- Erro ao mover senhas
- Falha ao mover %1$s para %2$s
Adicionar senha gerada para %1$s usando o Android Password Store.
Editar a senha para %1$s usando o Android Password Store.
@@ -252,7 +250,6 @@
company.com\npersonal.com
Falha ao armazenar o arquivo de senha. Por favor, tente novamente.
- O arquivo já existe, por favor use um nome diferente
Adicionar OTP
Configuração TOTP importada com sucesso
Falha ao importar a configuração TOTP
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index daeaca0b2a..41f45bf3c3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -32,8 +32,6 @@
Удалить
Пароль уже существует!
Это перезапишет %1$s на %2$s
- Ошибка при перемещении паролей
- Не удалось переместить %1$s в %2$s
Добавлен пароль %1$s из хранилища.
Отредактирован %1$s из хранилища.
@@ -250,7 +248,6 @@
company.com\npersonal.com
Не удалось записать файл пароля в хранилище, пожалуйста, повторите попытку.
- Файл с таким названием уже существует! Используйте другое имя
Добавить OTP
Конфигурация TOTP успешно импортирована
Не удалось импортировать конфигурацию TOTP
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index be653d69a7..1bf6b3e2e0 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -34,8 +34,6 @@
Sil
Şifre zaten mevcut!
Bu işlem, %1$s üzerine %2$s yazacaktır.
- Şifreleri taşırken hata oluştu
- %1$s, %2$s konumuna taşınamadı
%1$s için oluşturulan şifre Android Password Store kullanılarak eklendi.
@@ -290,7 +288,6 @@
Şifre dosyası depoya yazılamadı, lütfen tekrar deneyin.
- Dosya zaten mevcut, lütfen farklı bir ad kullanın
OTP ekle
TOTP yapılandırması başarıyla içe aktarıldı
TOTP yapılandırması içe aktarılamadı
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 0a7e470805..3d929f49b3 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -27,8 +27,6 @@
删除
密码已经存在!
这将用%2$s覆盖%1$s
- 移动密码时出错
- 未能将%1$s移动到%2$s
使用Android密码仓库为%1$s生成密码
使用Android密码仓库编辑%1$s的密码。
@@ -256,7 +254,6 @@
personal.com
将密码文件写入存储失败,请重试
- 文件已存在,请使用其他名称
添加 OTP
导入TOTP配置成功
导入TOTP配置失败
diff --git a/app/src/main/res/values/pass_secrets_strings.xml b/app/src/main/res/values/pass_secrets_strings.xml
new file mode 100644
index 0000000000..375dde4587
--- /dev/null
+++ b/app/src/main/res/values/pass_secrets_strings.xml
@@ -0,0 +1,6 @@
+
+
+ This move crosses a Pass-Secrets encryption identity. Move a single password by editing it and selecting the destination so it can be re-encrypted safely.
+ The .gpg-id file belongs to a Pass-Secrets identity and cannot be moved or deleted independently.
+ Could not update Pass-Secrets metadata: %1$s
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 36a1ab5d42..d616321c7f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -35,8 +35,6 @@
Error
Password already exists!
This will overwrite %1$s with %2$s.
- Error while moving passwords
- Failed to move %1$s to %2$s
Add generated password for %1$s using Android Password Store.
@@ -347,7 +345,6 @@
Failed to write password file to the store, please try again.
Encryption failure. Check expiration date and revocation status of the PGP key(s) using an external utility.
No matching PGP keys found.
- File already exists, please use a different name.
Add OTP
Successfully imported TOTP configuration
Failed to import TOTP configuration
diff --git a/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt
new file mode 100644
index 0000000000..3f5318d1d5
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/passsecrets/PassSecretsMapStoreTest.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.passsecrets
+
+import app.passwordstore.data.password.PasswordItem
+import java.io.File
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+
+class PassSecretsMapStoreTest {
+
+ @get:Rule val tempFolder = TemporaryFolder()
+
+ private lateinit var root: File
+
+ @BeforeTest
+ fun setup() {
+ PassSecretsMapStore.clear()
+ root = tempFolder.newFolder("store")
+ }
+
+ @AfterTest
+ fun tearDown() {
+ PassSecretsMapStore.clear()
+ tempFolder.root.deleteRecursively()
+ }
+
+ @Test
+ fun `parser handles whitespace and equals signs in descriptions`() {
+ val parsed =
+ PassSecretsMapStore.parse(
+ """
+ Abcde/FgXyz = GitHub personal
+ Qwert/Asdfg=service = production
+ """
+ .trimIndent()
+ )
+
+ assertEquals("GitHub personal", parsed["Abcde/FgXyz"])
+ assertEquals("service = production", parsed["Qwert/Asdfg"])
+ }
+
+ @Test
+ fun `parser ignores malformed unsafe empty and pending entries`() {
+ val parsed =
+ PassSecretsMapStore.parse(
+ """
+ malformed line
+ /absolute/path = nope
+ foo/../bar = nope
+ foo\\bar = nope
+ foo bar/baz = nope
+ valid/path =
+ pending/path = (pendente)
+ good/path = Good value
+ """
+ .trimIndent()
+ )
+
+ assertEquals(mapOf("good/path" to "Good value"), parsed)
+ }
+
+ @Test
+ fun `parser uses last value for duplicate keys`() {
+ val parsed = PassSecretsMapStore.parse("Foo/Bar = first\nFoo/Bar = second")
+ assertEquals("second", parsed["Foo/Bar"])
+ }
+
+ @Test
+ fun `plain store without identity or map never activates`() {
+ val directory = File(root, "Plain").apply { mkdirs() }
+ val password = password(directory, "example.com")
+
+ assertNull(PassSecretsMapStore.claimForDirectory(directory, root))
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `root identity is detected automatically`() {
+ File(root, ".gpg-id").writeText("0123456789ABCDEF\n")
+ val mapFile = File(root, PassSecretsMapStore.MAP_FILE_NAME).apply { writeText("ciphertext") }
+ val password = password(root, "Abcde")
+
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(root, root)?.absolutePath,
+ )
+ PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "GitHub personal"))
+ assertEquals("GitHub personal", PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `identity without secrets map behaves like a normal store`() {
+ val identity = identity(root, "Work", withMap = false)
+ val password = password(identity, "Abcde")
+
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `mapped name resolves relative to nearest identity`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Abcde/FgXyz")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.put(mapFile, mapOf("Abcde/FgXyz" to "GitHub work"))
+
+ assertEquals("GitHub work", PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `unmapped physical entry falls back cleanly`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Abcde")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.put(mapFile, mapOf("Different" to "Orphan mapping"))
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ val item = PasswordItem.newPassword(password.name, password, root)
+ assertEquals("Abcde", item.toString())
+ assertTrue(item.matchesSearch("Abcde"))
+ }
+
+ @Test
+ fun `orphan map entry cannot resolve an unrelated physical file`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Existing")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.put(mapFile, mapOf("Missing" to "Bank"))
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `nested identity with own map overrides parent identity`() {
+ val parent = identity(root, "Parent")
+ val child = identity(parent, "Nested")
+ val password = password(child, "Abcde")
+ PassSecretsMapStore.put(
+ File(parent, PassSecretsMapStore.MAP_FILE_NAME),
+ mapOf("Nested/Abcde" to "Wrong parent mapping"),
+ )
+ PassSecretsMapStore.put(
+ File(child, PassSecretsMapStore.MAP_FILE_NAME),
+ mapOf("Abcde" to "Correct child mapping"),
+ )
+
+ assertEquals("Correct child mapping", PassSecretsMapStore.mappedName(password, root))
+ }
+
+ @Test
+ fun `nested identity without map never inherits parent mapping`() {
+ val parent = identity(root, "Parent")
+ val child = identity(parent, "Nested", withMap = false)
+ val password = password(child, "Abcde")
+ PassSecretsMapStore.put(
+ File(parent, PassSecretsMapStore.MAP_FILE_NAME),
+ mapOf("Nested/Abcde" to "Must not leak across boundary"),
+ )
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ assertNull(PassSecretsMapStore.claimForDirectory(child, root))
+ }
+
+ @Test
+ fun `claim is automatic once per map until resolved`() {
+ val identity = identity(root, "Work")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath,
+ )
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+
+ PassSecretsMapStore.put(mapFile, emptyMap())
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+ }
+
+ @Test
+ fun `skipped map does not immediately prompt again`() {
+ val identity = identity(root, "Work")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath,
+ )
+ PassSecretsMapStore.skip(mapFile)
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+ }
+
+ @Test
+ fun `changing map invalidates loaded labels and permits a new unlock`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Abcde")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "Old name"))
+ assertEquals("Old name", PassSecretsMapStore.mappedName(password, root))
+
+ mapFile.appendText("changed")
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath,
+ )
+ }
+
+ @Test
+ fun `changing skipped map permits retry`() {
+ val identity = identity(root, "Work")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.claimForDirectory(identity, root)
+ PassSecretsMapStore.skip(mapFile)
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+
+ mapFile.appendText("changed")
+
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath,
+ )
+ }
+
+ @Test
+ fun `clear removes both decrypted labels and prompt suppression`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Abcde")
+ val mapFile = File(identity, PassSecretsMapStore.MAP_FILE_NAME)
+ PassSecretsMapStore.put(mapFile, mapOf("Abcde" to "GitHub"))
+ assertEquals("GitHub", PassSecretsMapStore.mappedName(password, root))
+
+ PassSecretsMapStore.clear()
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ assertEquals(
+ mapFile.absolutePath,
+ PassSecretsMapStore.claimForDirectory(identity, root)?.absolutePath,
+ )
+ }
+
+ @Test
+ fun `metadata files are always recognized separately from passwords`() {
+ val identity = identity(root, "Work")
+ assertTrue(PassSecretsMapStore.isMetadataFile(File(identity, ".secrets.gpg")))
+ File(identity, ".mask.gpg").writeText("ciphertext")
+ assertTrue(PassSecretsMapStore.isMetadataFile(File(identity, ".mask.gpg")))
+ assertFalse(PassSecretsMapStore.isMetadataFile(password(identity, "Abcde")))
+ }
+
+ @Test
+ fun `resolver ignores files outside repository root`() {
+ val outside = tempFolder.newFolder("outside")
+ val identity = identity(outside, "Work")
+ val password = password(identity, "Abcde")
+ PassSecretsMapStore.put(
+ File(identity, PassSecretsMapStore.MAP_FILE_NAME),
+ mapOf("Abcde" to "Outside"),
+ )
+
+ assertNull(PassSecretsMapStore.mappedName(password, root))
+ assertNull(PassSecretsMapStore.claimForDirectory(identity, root))
+ }
+
+ @Test
+ fun `password item displays mapped name but remains searchable by physical codename`() {
+ val identity = identity(root, "Work")
+ val password = password(identity, "Abcde/FgXyz")
+ val item =
+ PasswordItem.newPassword(
+ password.name,
+ password,
+ root,
+ mappedName = "github.com / personal",
+ )
+
+ assertEquals("github.com / personal", item.toString())
+ assertTrue(item.matchesSearch("github.com"))
+ assertTrue(item.matchesSearch("FgXyz"))
+ assertTrue(item.matchesSearch("abcde"))
+ assertFalse(item.matchesSearch("gitlab"))
+ }
+
+ @Test
+ fun `mapped duplicate labels do not collapse physical identity`() {
+ val identity = identity(root, "Work")
+ val first = password(identity, "Abcde")
+ val second = password(identity, "FgXyz")
+ val firstItem = PasswordItem.newPassword(first.name, first, root, mappedName = "GitHub")
+ val secondItem = PasswordItem.newPassword(second.name, second, root, mappedName = "GitHub")
+
+ assertEquals(firstItem.toString(), secondItem.toString())
+ assertNotEquals(firstItem.file.absolutePath, secondItem.file.absolutePath)
+ assertFalse(firstItem == secondItem)
+ }
+
+ @Test
+ fun `strict domain matching checks mapped description tokens and physical path`() {
+ val identity = identity(root, "Work")
+ val mapped = password(identity, "Abcde")
+ val physical = password(identity, "gitlab.com")
+ val regex = Regex("(?:^|/)(?:(?:[^/@]+\\.)?github\\.com)(?:\\.gpg|/)")
+ val gitlabRegex = Regex("(?:^|/)(?:(?:[^/@]+\\.)?gitlab\\.com)(?:\\.gpg|/)")
+ val mappedItem = PasswordItem.newPassword(mapped.name, mapped, root, "github.com / personal")
+ val physicalItem = PasswordItem.newPassword(physical.name, physical, root)
+
+ assertTrue(mappedItem.matchesStrictDomain(regex))
+ assertTrue(physicalItem.matchesStrictDomain(gitlabRegex))
+ assertFalse(mappedItem.matchesStrictDomain(gitlabRegex))
+ }
+
+ private fun identity(parent: File, name: String, withMap: Boolean = true): File {
+ val dir = File(parent, name).apply { mkdirs() }
+ File(dir, ".gpg-id").writeText("0123456789ABCDEF\n")
+ if (withMap) File(dir, PassSecretsMapStore.MAP_FILE_NAME).writeText("ciphertext")
+ return dir
+ }
+
+ private fun password(identity: File, relativePath: String): File {
+ val file = File(identity, "$relativePath.gpg")
+ file.parentFile?.mkdirs()
+ file.writeText("encrypted")
+ return file
+ }
+}