refactor: migrate to NIO

This commit is contained in:
Harsh Shandilya 2024-05-31 17:30:59 +05:30
parent 7010ee85b4
commit 36b45f90f9
34 changed files with 402 additions and 337 deletions

View file

@ -9,19 +9,25 @@ import android.content.Intent
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.crypto.BasePGPActivity
import app.passwordstore.ui.main.LaunchActivity
import java.io.File
import java.nio.file.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.pathString
import kotlin.io.path.relativeTo
data class PasswordItem(
val name: String,
val parent: PasswordItem? = null,
val type: Char,
val file: File,
val rootDir: File,
val file: Path,
val rootDir: Path,
) : Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
val name = file.nameWithoutExtension
val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
val fullPathToParent = file.relativeTo(rootDir).parent.pathString
val longName =
BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePathString(), toString())
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
@ -32,7 +38,7 @@ data class PasswordItem(
}
override fun toString(): String {
return name.replace("\\.gpg$".toRegex(), "")
return name
}
override fun hashCode(): Int {
@ -43,8 +49,8 @@ data class PasswordItem(
fun createAuthEnabledIntent(context: Context): Intent {
val intent = Intent(context, LaunchActivity::class.java)
intent.putExtra("NAME", toString())
intent.putExtra("FILE_PATH", file.absolutePath)
intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
intent.putExtra("FILE_PATH", file.absolutePathString())
intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePathString())
intent.action = LaunchActivity.ACTION_DECRYPT_PASS
return intent
}
@ -55,23 +61,23 @@ data class PasswordItem(
const val TYPE_PASSWORD = 'p'
@JvmStatic
fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
fun newCategory(path: Path, parent: PasswordItem, rootDir: Path): PasswordItem {
return PasswordItem(parent, TYPE_CATEGORY, path, rootDir)
}
@JvmStatic
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
fun newCategory(path: Path, rootDir: Path): PasswordItem {
return PasswordItem(null, TYPE_CATEGORY, path, rootDir)
}
@JvmStatic
fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
fun newPassword(path: Path, parent: PasswordItem, rootDir: Path): PasswordItem {
return PasswordItem(parent, TYPE_PASSWORD, path, rootDir)
}
@JvmStatic
fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
fun newPassword(path: Path, rootDir: Path): PasswordItem {
return PasswordItem(null, TYPE_PASSWORD, path, rootDir)
}
}
}

View file

@ -6,15 +6,19 @@ package app.passwordstore.data.repo
import androidx.core.content.edit
import app.passwordstore.Application
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.settings.PasswordSortOrder
import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import java.io.File
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.PathWalkOption
import kotlin.io.path.deleteRecursively
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.walk
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.lib.Constants
import org.eclipse.jgit.lib.Repository
@ -23,12 +27,13 @@ import org.eclipse.jgit.transport.RefSpec
import org.eclipse.jgit.transport.RemoteConfig
import org.eclipse.jgit.transport.URIish
@OptIn(ExperimentalPathApi::class)
object PasswordRepository {
var repository: Repository? = null
private val settings by unsafeLazy { Application.instance.sharedPrefs }
private val filesDir
get() = Application.instance.filesDir
get() = Application.instance.filesDir.toPath()
val isInitialized: Boolean
get() = repository != null
@ -41,19 +46,20 @@ object PasswordRepository {
* Takes in a [repositoryDir] to initialize a Git repository with, and assigns it to [repository]
* as static state.
*/
private fun initializeRepository(repositoryDir: File) {
private fun initializeRepository(repositoryDir: Path) {
val builder = FileRepositoryBuilder()
repository =
runCatching { builder.setGitDir(repositoryDir).build() }
runCatching { builder.setGitDir(repositoryDir.toFile()).build() }
.getOrElse { e ->
e.printStackTrace()
null
}
}
fun createRepository(repositoryDir: File) {
repositoryDir.delete()
repository = Git.init().setDirectory(repositoryDir).call().repository
@OptIn(ExperimentalPathApi::class)
fun createRepository(repositoryDir: Path) {
repositoryDir.deleteRecursively()
repository = Git.init().setDirectory(repositoryDir.toFile()).call().repository
}
// TODO add multiple remotes support for pull/push
@ -106,8 +112,8 @@ object PasswordRepository {
repository = null
}
fun getRepositoryDirectory(): File {
return File(filesDir.toString(), "/store")
fun getRepositoryDirectory(): Path {
return filesDir.resolve("store")
}
fun initialize(): Repository? {
@ -116,8 +122,8 @@ object PasswordRepository {
settings.edit {
if (
!dir.exists() ||
!dir.isDirectory ||
requireNotNull(dir.listFiles()) { "Failed to list files in ${dir.path}" }.isEmpty()
!dir.isDirectory() ||
dir.walk(PathWalkOption.INCLUDE_DIRECTORIES).toList().isEmpty()
) {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
@ -141,53 +147,4 @@ object PasswordRepository {
null
}
}
/**
* Gets the .gpg files in a directory
*
* @param path the directory path
* @return the list of gpg files in that directory
*/
private fun getFilesList(path: File): ArrayList<File> {
if (!path.exists()) return ArrayList()
val files =
(path.listFiles { file -> file.isDirectory || file.extension == "gpg" } ?: emptyArray())
.toList()
val items = ArrayList<File>()
items.addAll(files)
return items
}
/**
* Gets the passwords (PasswordItem) in a directory
*
* @param path the directory path
* @return a list of password items
*/
fun getPasswords(
path: File,
rootDir: File,
sortOrder: PasswordSortOrder,
): ArrayList<PasswordItem> {
// We need to recover the passwords then parse the files
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
val passwordList = ArrayList<PasswordItem>()
val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
if (passList.size == 0) return passwordList
if (!showHidden) {
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
}
passList.forEach { file ->
passwordList.add(
if (file.isFile) {
PasswordItem.newPassword(file.name, file, rootDir)
} else {
PasswordItem.newCategory(file.name, file, rootDir)
}
)
}
passwordList.sortWith(sortOrder.comparator)
return passwordList
}
}

View file

@ -18,6 +18,9 @@ import app.passwordstore.data.password.PasswordItem
import app.passwordstore.util.coroutines.DispatcherProvider
import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter
import app.passwordstore.util.viewmodel.stableId
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
@ -71,7 +74,10 @@ open class PasswordItemRecyclerAdapter(
folderIndicator.visibility = View.VISIBLE
val count =
withContext(dispatcherProvider.io()) {
item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
item.file
.listDirectoryEntries()
.filter { it.isDirectory() || it.extension == "gpg" }
.size
}
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
childCount.text = "$count"

View file

@ -39,8 +39,11 @@ import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching
import dagger.hilt.android.AndroidEntryPoint
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.absolutePathString
import kotlin.io.path.readBytes
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
@ -59,12 +62,14 @@ class AutofillDecryptActivity : BasePGPActivity() {
override fun onStart() {
super.onStart()
val filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
Paths.get(
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
)
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
@ -93,14 +98,17 @@ class AutofillDecryptActivity : BasePGPActivity() {
}
private fun decrypt(
filePath: String,
filePath: Path,
clientState: Bundle,
action: AutofillAction,
authResult: BiometricResult,
) {
val gpgIdentifiers =
getPGPIdentifiers(
getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString())
getParentPath(
filePath.absolutePathString(),
PasswordRepository.getRepositoryDirectory().toString(),
)
) ?: return
lifecycleScope.launch(dispatcherProvider.main()) {
when (authResult) {
@ -126,13 +134,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
gpgIdentifiers.first(),
)
if (cachedPassphrase != null) {
decryptWithPassphrase(
File(filePath),
gpgIdentifiers,
clientState,
action,
cachedPassphrase,
)
decryptWithPassphrase(filePath, gpgIdentifiers, clientState, action, cachedPassphrase)
} else {
askPassphrase(filePath, gpgIdentifiers, clientState, action)
}
@ -142,13 +144,13 @@ class AutofillDecryptActivity : BasePGPActivity() {
}
private suspend fun askPassphrase(
filePath: String,
filePath: Path,
identifiers: List<PGPIdentifier>,
clientState: Bundle,
action: AutofillAction,
) {
if (!repository.isPasswordProtected(identifiers)) {
decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "")
decryptWithPassphrase(filePath, identifiers, clientState, action, password = "")
return
}
val dialog =
@ -162,14 +164,14 @@ class AutofillDecryptActivity : BasePGPActivity() {
val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!!
clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY)
lifecycleScope.launch(dispatcherProvider.main()) {
decryptWithPassphrase(File(filePath), identifiers, clientState, action, value)
decryptWithPassphrase(filePath, identifiers, clientState, action, value)
}
}
}
}
private suspend fun decryptWithPassphrase(
filePath: File,
filePath: Path,
identifiers: List<PGPIdentifier>,
clientState: Bundle,
action: AutofillAction,
@ -199,7 +201,7 @@ class AutofillDecryptActivity : BasePGPActivity() {
}
private suspend fun decryptCredential(
file: File,
file: Path,
password: String,
identifiers: List<PGPIdentifier>,
): Credentials? {
@ -240,19 +242,19 @@ class AutofillDecryptActivity : BasePGPActivity() {
private var decryptFileRequestCode = 1
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
fun makeDecryptFileIntent(file: Path, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, AutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
putExtra(EXTRA_FILE_PATH, file.absolutePathString())
}
}
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
fun makeDecryptFileIntentSender(file: Path, context: Context): IntentSender {
val intent =
Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
putExtra(EXTRA_FILE_PATH, file.absolutePathString())
}
return PendingIntent.getActivity(
context,

View file

@ -38,7 +38,7 @@ import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.flow.collect
import kotlin.io.path.relativeTo
import kotlinx.coroutines.launch
import logcat.LogPriority.ERROR
import logcat.logcat

View file

@ -23,7 +23,8 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.Credentials
import com.github.androidpasswordstore.autofillparser.FormOrigin
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
import logcat.LogPriority.ERROR
import logcat.logcat
@ -109,8 +110,9 @@ class AutofillSaveActivity : AppCompatActivity() {
Intent(this, PasswordCreationActivity::class.java).apply {
putExtras(
bundleOf(
"REPO_PATH" to repo.absolutePath,
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
"REPO_PATH" to repo.absolutePathString(),
"FILE_PATH" to
repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePathString(),
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to
@ -122,7 +124,7 @@ class AutofillSaveActivity : AppCompatActivity() {
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
val createdPath = data.getStringExtra("CREATED_FILE")!!
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, Paths.get(createdPath)) }
val password = data.getStringExtra("PASSWORD")
val resultIntent =
if (password != null) {

View file

@ -34,8 +34,15 @@ import app.passwordstore.util.settings.PreferenceKeys
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.absolutePathString
import kotlin.io.path.createFile
import kotlin.io.path.exists
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.readLines
import kotlin.io.path.readText
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -54,7 +61,7 @@ open class BasePGPActivity : AppCompatActivity() {
*
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
*/
val name: String by unsafeLazy { File(fullPath).nameWithoutExtension }
val name: String by unsafeLazy { Paths.get(fullPath).nameWithoutExtension }
/** Action to invoke if [keyImportAction] succeeds. */
private var onKeyImport: (() -> Unit)? = null
@ -155,8 +162,8 @@ open class BasePGPActivity : AppCompatActivity() {
fun getPGPIdentifiers(subDir: String): List<PGPIdentifier>? {
val repoRoot = PasswordRepository.getRepositoryDirectory()
val gpgIdentifierFile =
File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot)
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
repoRoot.resolve(subDir).findTillRoot(".gpg-id", repoRoot)
?: repoRoot.resolve(".gpg-id").createFile()
val gpgIdentifiers =
gpgIdentifierFile
.readLines()
@ -185,15 +192,13 @@ open class BasePGPActivity : AppCompatActivity() {
return gpgIdentifiers
}
@Suppress("ReturnCount")
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
val gpgFile = File(this, fileName)
private fun Path.findTillRoot(fileName: String, rootPath: Path): Path? {
val gpgFile = this.resolve(fileName)
if (gpgFile.exists()) return gpgFile
if (this.absolutePath == rootPath.absolutePath) {
if (this.absolutePathString() == rootPath.absolutePathString()) {
return null
}
val parent = parentFile
return if (parent != null && parent.exists()) {
parent.findTillRoot(fileName, rootPath)
} else {

View file

@ -33,8 +33,9 @@ import app.passwordstore.util.settings.Constants
import app.passwordstore.util.settings.PreferenceKeys
import dagger.hilt.android.AndroidEntryPoint
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.readBytes
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
@ -233,7 +234,8 @@ class DecryptActivity : BasePGPActivity() {
authResult: BiometricResult,
onSuccess: suspend () -> Unit = {},
) {
val message = withContext(dispatcherProvider.io()) { File(fullPath).readBytes().inputStream() }
val message =
withContext(dispatcherProvider.io()) { Paths.get(fullPath).readBytes().inputStream() }
val outputStream = ByteArrayOutputStream()
val result = repository.decrypt(passphrase, identifiers, message, outputStream)
if (result.isOk) {

View file

@ -387,7 +387,7 @@ class PasswordCreationActivity : BasePGPActivity() {
return@runCatching
}
if (!passwordFile.toFile().isInsideRepository()) {
if (!passwordFile.isInsideRepository()) {
snackbar(message = getString(R.string.message_error_destination_outside_repo))
return@runCatching
}
@ -414,8 +414,7 @@ class PasswordCreationActivity : BasePGPActivity() {
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
val entry = passwordEntryFactory.create(content.encodeToByteArray())
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
val username =
entry.username ?: directoryStructure.getUsernameFor(passwordFile.toFile())
val username = entry.username ?: directoryStructure.getUsernameFor(passwordFile)
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
}

View file

@ -15,11 +15,15 @@ import app.passwordstore.ui.passwords.PasswordStore
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.createDirectories
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
class FolderCreationDialogFragment : DialogFragment() {
private lateinit var newFolder: File
private lateinit var newFolder: Path
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
@ -41,15 +45,15 @@ class FolderCreationDialogFragment : DialogFragment() {
val dialog = requireDialog()
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
newFolder = File("$currentDir/${folderNameView.text}")
newFolder = Paths.get("$currentDir/${folderNameView.text}")
folderNameViewContainer.error =
when {
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
newFolder.isRegularFile() -> getString(R.string.folder_creation_err_file_exists)
newFolder.isDirectory() -> getString(R.string.folder_creation_err_folder_exists)
else -> null
}
if (folderNameViewContainer.error != null) return
newFolder.mkdirs()
newFolder.createDirectories()
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
// TODO(msfjarvis): Restore this functionality
/*

View file

@ -15,6 +15,7 @@ import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.passwords.PASSWORD_FRAGMENT_TAG
import app.passwordstore.ui.passwords.PasswordStore
import dagger.hilt.android.AndroidEntryPoint
import kotlin.io.path.absolutePathString
@AndroidEntryPoint
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
@ -28,7 +29,7 @@ class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
val args = Bundle()
args.putString(
PasswordStore.REQUEST_ARG_PATH,
PasswordRepository.getRepositoryDirectory().absolutePath,
PasswordRepository.getRepositoryDirectory().absolutePathString(),
)
passwordList.arguments = args
@ -60,7 +61,7 @@ class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
}
private fun selectFolder() {
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePathString())
setResult(RESULT_OK, intent)
finish()
}

View file

@ -25,7 +25,8 @@ import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import javax.inject.Inject
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder
@ -60,7 +61,11 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
"Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
}
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.navigateTo(
Paths.get(path),
listMode = ListMode.DirectoriesOnly,
pushPreviousLocation = false,
)
lifecycleScope.launch {
model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
recyclerAdapter.submitList(result.passwordItems)
@ -88,7 +93,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
}
}
val currentDir: File
val currentDir: Path
get() = model.currentDir.value
interface OnFragmentInteractionListener {

View file

@ -30,6 +30,12 @@ import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteRecursively
import kotlin.io.path.exists
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
@ -220,17 +226,16 @@ class GitServerConfigActivity : BaseGitActivity() {
}
/** Clones the repository, the directory exists, deletes it */
@OptIn(ExperimentalPathApi::class)
private fun cloneRepository() {
val localDir =
requireNotNull(PasswordRepository.getRepositoryDirectory()) {
"Repository directory must be set before cloning"
}
val localDirFiles = localDir.listFiles() ?: emptyArray()
val localDirFiles = if (localDir.exists()) localDir.listDirectoryEntries() else listOf()
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
if (
localDir.exists() &&
localDirFiles.isNotEmpty() &&
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_delete_title)
@ -246,7 +251,7 @@ class GitServerConfigActivity : BaseGitActivity() {
)
withContext(dispatcherProvider.io()) {
localDir.deleteRecursively()
localDir.mkdirs()
localDir.createDirectories()
}
snackbar.dismiss()
launchGitOperation(GitOp.CLONE)

View file

@ -23,6 +23,10 @@ import app.passwordstore.util.extensions.viewBinding
import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import java.nio.file.LinkOption
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.notExists
import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
@ -55,7 +59,9 @@ class CloneFragment : Fragment(R.layout.fragment_clone) {
private fun createRepository() {
val localDir = PasswordRepository.getRepositoryDirectory()
runCatching {
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
if (localDir.notExists(LinkOption.NOFOLLOW_LINKS)) {
localDir.createDirectories()
}
PasswordRepository.createRepository(localDir)
if (!PasswordRepository.isInitialized) {
PasswordRepository.initialize()
@ -64,7 +70,7 @@ class CloneFragment : Fragment(R.layout.fragment_clone) {
}
.onFailure { e ->
logcat(ERROR) { e.asLog() }
if (!localDir.delete()) {
if (!localDir.deleteIfExists()) {
logcat { "Failed to delete local repository: $localDir" }
}
finish()

View file

@ -26,8 +26,8 @@ import app.passwordstore.util.extensions.viewBinding
import app.passwordstore.util.settings.PreferenceKeys
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import javax.inject.Inject
import kotlin.io.path.writeText
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -46,7 +46,7 @@ class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
?: return@registerForActivityResult
lifecycleScope.launch {
withContext(dispatcherProvider.io()) {
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
val gpgIdentifierFile = PasswordRepository.getRepositoryDirectory().resolve(".gpg-id")
gpgIdentifierFile.writeText(selectedKey)
}
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }

View file

@ -48,8 +48,13 @@ import com.github.michaelbull.result.fold
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.runCatching
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder
@ -66,7 +71,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
private var recyclerViewStateToRestore: Parcelable? = null
private var actionMode: ActionMode? = null
private var scrollTarget: File? = null
private var scrollTarget: Path? = null
private val model: SearchableRepositoryViewModel by activityViewModels()
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
@ -76,7 +81,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
requireStore().refreshPasswordList()
}
val currentDir: File
val currentDir: Path
get() = model.currentDir.value
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -97,9 +102,9 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
}
private fun initializePasswordList() {
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
val gitDir = PasswordRepository.getRepositoryDirectory().resolve(".git")
val hasGitDir =
gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
gitDir.exists() && gitDir.isDirectory() && gitDir.listDirectoryEntries().isNotEmpty()
binding.swipeRefresher.setOnRefreshListener {
if (!hasGitDir) {
requireStore().refreshPasswordList()
@ -179,7 +184,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) {
"Cannot navigate if ${PasswordStore.REQUEST_ARG_PATH} is not provided"
}
model.navigateTo(File(path), pushPreviousLocation = false)
model.navigateTo(Paths.get(path), pushPreviousLocation = false)
lifecycleScope.launch {
model.searchResult.flowWithLifecycle(lifecycle).collect { result ->
// Only run animations when the new list is filtered, i.e., the user submitted a search,
@ -317,7 +322,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
val preferences =
context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
preferences.edit {
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
putString(
item.file.absolutePathString().base64(),
System.currentTimeMillis().toString(),
)
}
}
@ -368,7 +376,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
}
}
fun navigateTo(file: File) {
fun navigateTo(file: Path) {
requireStore().clearSearch()
model.navigateTo(
file,
@ -377,7 +385,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
fun scrollToOnNextRefresh(file: File) {
fun scrollToOnNextRefresh(file: Path) {
scrollTarget = file
}

View file

@ -35,13 +35,12 @@ import app.passwordstore.ui.git.base.BaseGitActivity
import app.passwordstore.ui.onboarding.activity.OnboardingActivity
import app.passwordstore.ui.settings.SettingsActivity
import app.passwordstore.util.autofill.AutofillMatcher
import app.passwordstore.util.extensions.asLog
import app.passwordstore.util.extensions.base64
import app.passwordstore.util.extensions.commitChange
import app.passwordstore.util.extensions.contains
import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.isInsideRepository
import app.passwordstore.util.extensions.launchActivity
import app.passwordstore.util.extensions.listFilesRecursively
import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.settings.AuthMode
import app.passwordstore.util.settings.PreferenceKeys
@ -49,13 +48,29 @@ import app.passwordstore.util.shortcuts.ShortcutHandler
import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel
import com.github.michaelbull.result.fold
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.lang.Character.UnicodeBlock
import java.nio.file.Path
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.absolute
import kotlin.io.path.absolutePathString
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteRecursively
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.moveTo
import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.pathString
import kotlin.io.path.relativeTo
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
@ -88,13 +103,13 @@ class PasswordStore : BaseGitActivity() {
"'Files' intent extra must be set"
}
val target =
File(
Paths.get(
requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")) {
"'SELECTED_FOLDER_PATH' intent extra must be set"
}
)
val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
if (!target.isDirectory) {
val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePathString()
if (!target.isDirectory()) {
logcat(ERROR) { "Tried moving passwords to a non-existing folder." }
return@registerForActivityResult
}
@ -104,20 +119,21 @@ class PasswordStore : BaseGitActivity() {
lifecycleScope.launch(dispatcherProvider.io()) {
for (file in filesToMove) {
val source = File(file)
val source = Paths.get(file)
if (!source.exists()) {
logcat(ERROR) { "Tried moving something that appears non-existent." }
continue
}
val destinationFile = File(target.absolutePath + "/" + source.name)
val destinationFile = Paths.get(target.absolutePathString(), source.name)
val basename = source.nameWithoutExtension
val sourceLongName =
getLongName(
requireNotNull(source.parent) { "$file has no parent" },
requireNotNull(source.parent) { "$file has no parent" }.absolutePathString(),
repositoryPath,
basename,
)
val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
val destinationLongName =
getLongName(target.absolutePathString(), repositoryPath, basename)
if (destinationFile.exists()) {
logcat(ERROR) { "Trying to move a file that already exists." }
withContext(dispatcherProvider.main()) {
@ -142,15 +158,16 @@ class PasswordStore : BaseGitActivity() {
}
when (filesToMove.size) {
1 -> {
val source = File(filesToMove[0])
val source = Paths.get(filesToMove[0])
val basename = source.nameWithoutExtension
val sourceLongName =
getLongName(
requireNotNull(source.parent) { "$basename has no parent" },
requireNotNull(source.parent) { "$basename has no parent" }.pathString,
repositoryPath,
basename,
)
val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
val destinationLongName =
getLongName(target.absolutePathString(), repositoryPath, basename)
withContext(dispatcherProvider.main()) {
commitChange(
resources.getString(
@ -162,8 +179,8 @@ class PasswordStore : BaseGitActivity() {
}
}
else -> {
val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
val repoDir = PasswordRepository.getRepositoryDirectory().absolutePathString()
val relativePath = getRelativePath("${target.absolutePathString()}/", repoDir)
withContext(dispatcherProvider.main()) {
commitChange(
resources.getString(R.string.git_commit_move_multiple_text, relativePath)
@ -203,7 +220,7 @@ class PasswordStore : BaseGitActivity() {
lifecycleScope.launch {
model.currentDir.flowWithLifecycle(lifecycle).collect { dir ->
val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
val basePath = PasswordRepository.getRepositoryDirectory().absolute()
supportActionBar?.apply {
if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
}
@ -348,7 +365,7 @@ class PasswordStore : BaseGitActivity() {
checkLocalRepository(PasswordRepository.getRepositoryDirectory())
}
private fun checkLocalRepository(localDir: File?) {
private fun checkLocalRepository(localDir: Path?) {
if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
// do not push the fragment if we already have it
if (
@ -356,7 +373,10 @@ class PasswordStore : BaseGitActivity() {
) {
settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
val args = Bundle()
args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
args.putString(
REQUEST_ARG_PATH,
PasswordRepository.getRepositoryDirectory().absolutePathString(),
)
// if the activity was started from the autofill settings, the
// intent is to match a clicked pwd with app. pass this to fragment
@ -406,25 +426,27 @@ class PasswordStore : BaseGitActivity() {
fun createPassword() {
if (!validateState()) return
val currentDir = currentDir
logcat(INFO) { "Adding file to : ${currentDir.absolutePath}" }
logcat(INFO) { "Adding file to : ${currentDir.absolutePathString()}" }
val intent = Intent(this, PasswordCreationActivity::class.java)
intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePath)
intent.putExtra(BasePGPActivity.EXTRA_FILE_PATH, currentDir.absolutePathString())
intent.putExtra(
BasePGPActivity.EXTRA_REPO_PATH,
PasswordRepository.getRepositoryDirectory().absolutePath,
PasswordRepository.getRepositoryDirectory().absolutePathString(),
)
listRefreshAction.launch(intent)
}
fun createFolder() {
if (!validateState()) return
FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
FolderCreationDialogFragment.newInstance(currentDir.pathString)
.show(supportFragmentManager, null)
}
@OptIn(ExperimentalPathApi::class)
fun deletePasswords(selectedItems: List<PasswordItem>) {
var size = 0
selectedItems.forEach {
if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size
if (it.file.isRegularFile()) size++ else size += it.file.listDirectoryEntries().size
}
if (size == 0) {
selectedItems.map { item -> item.file.deleteRecursively() }
@ -434,9 +456,9 @@ class PasswordStore : BaseGitActivity() {
MaterialAlertDialogBuilder(this)
.setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
.setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
val filesToDelete = arrayListOf<File>()
val filesToDelete = arrayListOf<Path>()
selectedItems.forEach { item ->
if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
if (item.file.isDirectory()) filesToDelete.addAll(item.file.listDirectoryEntries())
else filesToDelete.add(item.file)
}
selectedItems.map { item -> item.file.deleteRecursively() }
@ -444,7 +466,7 @@ class PasswordStore : BaseGitActivity() {
AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
val fmt =
selectedItems.joinToString(separator = ", ") { item ->
item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
item.file.relativeTo(PasswordRepository.getRepositoryDirectory()).absolutePathString()
}
lifecycleScope.launch {
commitChange(resources.getString(R.string.git_commit_remove_text, fmt))
@ -456,7 +478,7 @@ class PasswordStore : BaseGitActivity() {
fun movePasswords(values: List<PasswordItem>) {
val intent = Intent(this, SelectFolderActivity::class.java)
val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
val fileLocations = values.map { it.file.absolutePathString() }.toTypedArray()
intent.putExtra("Files", fileLocations)
passwordMoveAction.launch(intent)
}
@ -497,7 +519,7 @@ class PasswordStore : BaseGitActivity() {
.setView(view)
.setMessage(getString(R.string.message_rename_folder, oldCategory.name))
.setPositiveButton(R.string.dialog_ok) { _, _ ->
val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
val newCategory = Paths.get("${oldCategory.file.parent}/${newCategoryEditText.text}")
when {
newCategoryEditText.text.isNullOrBlank() ->
renameCategory(oldCategory, CategoryRenameError.EmptyField)
@ -512,11 +534,11 @@ class PasswordStore : BaseGitActivity() {
// history
val preference =
getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
val timestamp = preference.getString(oldCategory.file.absolutePathString().base64())
if (timestamp != null) {
preference.edit {
remove(oldCategory.file.absolutePath.base64())
putString(newCategory.absolutePath.base64(), timestamp)
remove(oldCategory.file.absolutePathString().base64())
putString(newCategory.absolutePathString().base64(), timestamp)
}
}
@ -552,14 +574,14 @@ class PasswordStore : BaseGitActivity() {
* entered if it is a directory or scrolled into view if it is a file (both inside the current
* directory).
*/
fun refreshPasswordList(target: File? = null) {
fun refreshPasswordList(target: Path? = null) {
val plist = getPasswordFragment()
if (target?.isDirectory == true && model.currentDir.value.contains(target)) {
if (target?.isDirectory() == true && model.currentDir.value.contains(target)) {
plist?.navigateTo(target)
} else if (target?.isFile == true && model.currentDir.value.contains(target)) {
} else if (target?.isRegularFile() == true && model.currentDir.value.contains(target)) {
// Creating new passwords is handled by an activity, so we will refresh in onStart.
plist?.scrollToOnNextRefresh(target)
} else if (model.currentDir.value.isDirectory) {
} else if (model.currentDir.value.isDirectory()) {
model.forceRefresh()
} else {
model.reset()
@ -567,40 +589,41 @@ class PasswordStore : BaseGitActivity() {
}
}
private val currentDir: File
private val currentDir: Path
get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
private suspend fun moveFile(source: File, destinationFile: File) {
private suspend fun moveFile(source: Path, destinationFile: Path) {
val sourceDestinationMap =
if (source.isDirectory) {
destinationFile.mkdirs()
if (source.isDirectory()) {
destinationFile.createDirectories()
// 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 {
source.listDirectoryEntries().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()
runCatching { source.moveTo(destinationFile) }
.onFailure {
logcat(ERROR) { it.asLog("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 {
AutofillMatcher.updateMatches(this, sourceDestinationMap)
}
.onSuccess { AutofillMatcher.updateMatches(this, sourceDestinationMap) }
}
fun matchPasswordWithApp(item: PasswordItem) {
val path =
item.file.absolutePath
item.file
.absolutePathString()
.replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
.replace(".gpg", "")
val data = Intent()

View file

@ -41,6 +41,9 @@ import de.Maxr1998.modernpreferences.PreferenceScreen
import de.Maxr1998.modernpreferences.helpers.onClick
import de.Maxr1998.modernpreferences.helpers.pref
import de.Maxr1998.modernpreferences.helpers.switch
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteRecursively
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
@ -58,6 +61,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
private var showSshKeyPref: Preference? = null
@OptIn(ExperimentalPathApi::class)
override fun provideSettings(builder: PreferenceScreen.Builder) {
val encryptedPreferences = hiltEntryPoint.encryptedPreferences()
val gitSettings = hiltEntryPoint.gitSettings()
@ -164,7 +168,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
PasswordRepository.closeRepository()
PasswordRepository.getRepositoryDirectory().let { dir ->
dir.deleteRecursively()
dir.mkdirs()
dir.createDirectories()
}
}
.onFailure { it.message?.let { message -> activity.snackbar(message = message) } }

View file

@ -21,7 +21,7 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.FillableForm
import com.github.androidpasswordstore.autofillparser.fillWith
import com.github.michaelbull.result.fold
import java.io.File
import java.nio.file.Path
import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
@ -58,7 +58,7 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) :
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
private fun makeMatchDataset(context: Context, file: Path): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
@ -135,7 +135,7 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) :
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
private fun makeFillResponse(context: Context, matchedFiles: List<Path>): FillResponse? {
var datasetCount = 0
return FillResponse.Builder().run {
for (file in matchedFiles) {

View file

@ -26,7 +26,7 @@ import com.github.androidpasswordstore.autofillparser.AutofillAction
import com.github.androidpasswordstore.autofillparser.FillableForm
import com.github.androidpasswordstore.autofillparser.fillWith
import com.github.michaelbull.result.fold
import java.io.File
import java.nio.file.Path
import logcat.LogPriority.ERROR
import logcat.asLog
import logcat.logcat
@ -123,7 +123,7 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) :
private fun makeMatchDataset(
context: Context,
file: File,
file: Path,
imeSpec: InlinePresentationSpec?,
): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
@ -199,7 +199,7 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) :
private fun makeFillResponse(
context: Context,
inlineSuggestionsRequest: InlineSuggestionsRequest?,
matchedFiles: List<File>,
matchedFiles: List<Path>,
): FillResponse? {
var datasetCount = 0
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()

View file

@ -14,7 +14,11 @@ import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.absolute
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import logcat.LogPriority.ERROR
import logcat.LogPriority.WARN
import logcat.logcat
@ -103,19 +107,22 @@ class AutofillMatcher {
fun getMatchesFor(
context: Context,
formOrigin: FormOrigin,
): Result<List<File>, AutofillPublisherChangedException> {
): Result<List<Path>, AutofillPublisherChangedException> {
if (hasFormOriginHashChanged(context, formOrigin)) {
return Err(AutofillPublisherChangedException(formOrigin))
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles =
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { Paths.get(it) }
return Ok(
matchedFiles
.filter { it.exists() }
.also { validFiles ->
matchPreferences.edit {
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
putStringSet(
matchesKey(formOrigin),
validFiles.map { it.absolutePathString() }.toSet(),
)
}
}
)
@ -135,7 +142,7 @@ class AutofillMatcher {
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
* may crash when too many datasets are offered.
*/
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: Path) {
if (!file.exists()) return
if (hasFormOriginHashChanged(context, formOrigin)) {
// This should never happen since we already verified the publisher in
@ -145,8 +152,8 @@ class AutofillMatcher {
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles =
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet()).orEmpty().map(Paths::get)
val newFiles = setOf(file.absolute()).union(matchedFiles)
if (newFiles.size > MAX_NUM_MATCHES) {
Toast.makeText(
context,
@ -157,7 +164,7 @@ class AutofillMatcher {
return
}
matchPreferences.edit {
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
putStringSet(matchesKey(formOrigin), newFiles.map(Path::absolutePathString).toSet())
}
storeFormOriginHash(context, formOrigin)
logcat { "Stored match for $formOrigin" }
@ -169,12 +176,14 @@ class AutofillMatcher {
*/
fun updateMatches(
context: Context,
moveFromTo: Map<File, File> = emptyMap(),
delete: Collection<File> = emptyList(),
moveFromTo: Map<Path, Path> = emptyMap(),
delete: Collection<Path> = emptyList(),
) {
val deletePathList = delete.map { it.absolutePath }
val deletePathList = delete.map { it.absolutePathString() }
val oldNewPathMap =
moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
moveFromTo
.mapValues { it.value.absolutePathString() }
.mapKeys { it.key.absolutePathString() }
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
for ((key, value) in prefs.all) {
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
@ -190,8 +199,8 @@ class AutofillMatcher {
val newMatches =
oldMatches
.asSequence()
.minus(deletePathList)
.minus(oldNewPathMap.values)
.minus(deletePathList.toSet())
.minus(oldNewPathMap.values.toSet())
.map { match ->
val newPath = oldNewPathMap[match] ?: return@map match
logcat { "Updating match for $key: $match --> $newPath" }

View file

@ -11,8 +11,11 @@ import app.passwordstore.util.extensions.sharedPrefs
import app.passwordstore.util.services.getDefaultUsername
import app.passwordstore.util.settings.PreferenceKeys
import com.github.androidpasswordstore.autofillparser.Credentials
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.io.path.name
import kotlin.io.path.nameWithoutExtension
import kotlin.io.path.pathString
enum class DirectoryStructure(val value: String) {
EncryptedUsername("encrypted_username"),
@ -29,11 +32,11 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getUsernameFor(file: File): String? =
fun getUsernameFor(file: Path): String? =
when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parent?.name ?: file.nameWithoutExtension
}
/**
@ -50,11 +53,11 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
* - Temporary PIN.gpg --> null (DirectoryBased)
*/
fun getIdentifierFor(file: File): String? =
fun getIdentifierFor(file: Path): String? =
when (this) {
EncryptedUsername -> file.nameWithoutExtension
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent
FileBased -> file.parent?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parent?.parent?.pathString
}
/**
@ -69,11 +72,11 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
*/
fun getPathToIdentifierFor(file: File): String? =
fun getPathToIdentifierFor(file: Path): String? =
when (this) {
EncryptedUsername -> file.parent
FileBased -> file.parentFile?.parent
DirectoryBased -> file.parentFile?.parentFile?.parent
EncryptedUsername -> file.parent.pathString
FileBased -> file.parent?.parent?.pathString
DirectoryBased -> file.parent?.parent?.parent?.pathString
}
/**
@ -90,12 +93,12 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getAccountPartFor(file: File): String? =
fun getAccountPartFor(file: Path): String? =
when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
FileBased -> file.nameWithoutExtension.takeIf { file.parent != null }
DirectoryBased ->
file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
file.parent?.let { parent -> "${parent.name}/${file.nameWithoutExtension}" }
?: file.nameWithoutExtension
}
@ -132,13 +135,13 @@ object AutofillPreferences {
fun credentialsFromStoreEntry(
context: Context,
file: File,
path: Path,
entry: PasswordEntry,
directoryStructure: DirectoryStructure,
): Credentials {
// Always give priority to a username stored in the encrypted extras
val username =
entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
entry.username ?: directoryStructure.getUsernameFor(path) ?: context.getDefaultUsername()
val totp = if (entry.hasTotp()) entry.currentOtp else null
return Credentials(username, entry.password, totp)
}

View file

@ -21,7 +21,8 @@ import androidx.autofill.inline.v1.InlineSuggestionUi
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.passwords.PasswordStore
import java.io.File
import java.nio.file.Path
import kotlin.io.path.relativeTo
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
@ -77,7 +78,7 @@ fun makeInlinePresentation(
return InlinePresentation(slice, imeSpec, false)
}
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
fun makeFillMatchMetadata(context: Context, file: Path): DatasetMetadata {
val directoryStructure = AutofillPreferences.directoryStructure(context)
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
val title =

View file

@ -5,10 +5,9 @@
package app.passwordstore.util.extensions
import app.passwordstore.data.repo.PasswordRepository
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.nio.file.Path
import java.time.Instant
import kotlin.io.path.absolutePathString
import logcat.asLog
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
@ -18,30 +17,15 @@ infix fun Int.hasFlag(flag: Int): Boolean {
return this and flag == flag
}
/** Checks whether this [File] is a directory that contains [other]. */
fun File.contains(other: File): Boolean {
if (!isDirectory) return false
if (!other.exists()) return false
val relativePath =
runCatching { other.relativeTo(this) }
.getOrElse {
return false
}
// Direct containment is equivalent to the relative path being equal to the filename.
return relativePath.path == other.name
}
/**
* Checks if this [File] is in the password repository directory as given by
* Checks if this [Path] is in the password repository directory as given by
* [PasswordRepository.getRepositoryDirectory]
*/
fun File.isInsideRepository(): Boolean {
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
fun Path.isInsideRepository(): Boolean {
return absolutePathString()
.contains(PasswordRepository.getRepositoryDirectory().absolutePathString())
}
/** Recursively lists the files in this [File], skipping any directories it encounters. */
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
/**
* Unique SHA-1 hash of this commit as hexadecimal string.
*

View file

@ -65,7 +65,7 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
/** Whether the operation requires authentication or not. */
open val requiresAuth: Boolean = true
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key").toPath()
private var sshSessionFactory: SshjSessionFactory? = null
private val hiltEntryPoint =
EntryPointAccessors.fromApplication<GitOperationEntryPoint>(callingActivity)

View file

@ -25,7 +25,6 @@ import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.io.IOException
import java.security.KeyFactory
import java.security.KeyPairGenerator
@ -34,6 +33,12 @@ import java.security.PrivateKey
import java.security.PublicKey
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import kotlin.io.path.absolutePathString
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@ -113,10 +118,10 @@ object SshKey {
get() = Application.instance.applicationContext
private val privateKeyFile
get() = File(context.filesDir, ".ssh_key")
get() = context.filesDir.toPath().resolve(".ssh_key")
private val publicKeyFile
get() = File(context.filesDir, ".ssh_key.pub")
get() = context.filesDir.toPath().resolve(".ssh_key.pub")
private var type: Type?
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
@ -178,11 +183,11 @@ object SshKey {
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
clear()
}
if (privateKeyFile.isFile) {
privateKeyFile.delete()
if (privateKeyFile.isRegularFile()) {
privateKeyFile.deleteIfExists()
}
if (publicKeyFile.isFile) {
publicKeyFile.delete()
if (publicKeyFile.isRegularFile()) {
publicKeyFile.deleteIfExists()
}
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
type = null
@ -247,7 +252,7 @@ object SshKey {
withContext(Dispatchers.IO) {
EncryptedFile.Builder(
context,
privateKeyFile,
privateKeyFile.toFile(),
getOrCreateWrappingMasterKey(requireAuthentication),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB,
)
@ -304,7 +309,7 @@ object SshKey {
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
when (type) {
Type.LegacyGenerated,
Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
Type.Imported -> client.loadKeys(privateKeyFile.absolutePathString(), passphraseFinder)
Type.KeystoreNative -> KeystoreNativeKeyProvider
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
null -> null

View file

@ -11,15 +11,18 @@ import app.passwordstore.util.git.operation.CredentialFinder
import app.passwordstore.util.settings.AuthMode
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.nio.file.Path
import java.security.PublicKey
import java.util.Collections
import java.util.concurrent.TimeUnit
import kotlin.coroutines.Continuation
import kotlin.coroutines.suspendCoroutine
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.coroutines.runBlocking
import logcat.LogPriority.WARN
import logcat.logcat
@ -69,7 +72,7 @@ abstract class InteractivePasswordFinder(private val dispatcherProvider: Dispatc
class SshjSessionFactory(
private val authMethod: SshAuthMethod,
private val hostKeyFile: File,
private val hostKeyFile: Path,
private val dispatcherProvider: DispatcherProvider,
) : SshSessionFactory() {
@ -93,7 +96,7 @@ class SshjSessionFactory(
}
}
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
private fun makeTofuHostKeyVerifier(hostKeyFile: Path): HostKeyVerifier {
if (!hostKeyFile.exists()) {
return object : HostKeyVerifier {
override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean {
@ -125,7 +128,7 @@ private class SshjSession(
uri: URIish,
private val username: String,
private val authMethod: SshAuthMethod,
private val hostKeyFile: File,
private val hostKeyFile: Path,
private val dispatcherProvider: DispatcherProvider,
) : RemoteSession {

View file

@ -19,6 +19,7 @@ import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlin.io.path.pathString
import logcat.logcat
class PasswordExportService : Service() {
@ -61,9 +62,9 @@ class PasswordExportService : Service() {
requireNotNull(PasswordRepository.getRepositoryDirectory()) {
"Password directory must be set to export them"
}
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory.toFile())
logcat { "Copying ${repositoryDirectory.path} to $targetDirectory" }
logcat { "Copying ${repositoryDirectory.pathString} to $targetDirectory" }
val dateString = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
val passDir = targetDirectory.createDirectory("password_store_$dateString")

View file

@ -14,9 +14,11 @@ import app.passwordstore.injection.prefs.SettingsPreferences
import app.passwordstore.util.extensions.getString
import com.github.michaelbull.result.getOrElse
import com.github.michaelbull.result.runCatching
import java.io.File
import java.nio.file.Paths
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import org.eclipse.jgit.transport.URIish
enum class Protocol(val pref: String) {
@ -174,9 +176,9 @@ constructor(
/** Deletes a previously saved SSH host key */
fun clearSavedHostKey() {
File(hostKeyPath).delete()
Paths.get(hostKeyPath).deleteIfExists()
}
/** Returns true if a host key was previously saved */
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
fun hasSavedHostKey(): Boolean = Paths.get(hostKeyPath).exists()
}

View file

@ -12,8 +12,9 @@ import app.passwordstore.util.extensions.getString
import app.passwordstore.util.git.sshj.SshKey
import com.github.michaelbull.result.get
import com.github.michaelbull.result.runCatching
import java.io.File
import java.net.URI
import java.nio.file.Paths
import kotlin.io.path.exists
import logcat.LogPriority.ERROR
import logcat.LogPriority.INFO
import logcat.logcat
@ -108,7 +109,7 @@ private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
}
private fun migrateToSshKey(filesDirPath: String, sharedPrefs: SharedPreferences) {
val privateKeyFile = File(filesDirPath, ".ssh_key")
val privateKeyFile = Paths.get(filesDirPath, ".ssh_key")
if (
sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
!SshKey.exists &&

View file

@ -11,6 +11,7 @@ import app.passwordstore.Application
import app.passwordstore.data.password.PasswordItem
import app.passwordstore.util.extensions.base64
import app.passwordstore.util.extensions.getString
import kotlin.io.path.absolutePathString
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
FOLDER_FIRST(
@ -27,8 +28,8 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory =
Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
val timeP1 = recentHistory.getString(p1.file.absolutePathString().base64())
val timeP2 = recentHistory.getString(p2.file.absolutePathString().base64())
when {
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
timeP1 != null && timeP2 == null -> return@Comparator -1

View file

@ -32,10 +32,21 @@ import app.passwordstore.util.settings.PasswordSortOrder
import app.passwordstore.util.settings.PreferenceKeys
import com.github.androidpasswordstore.sublimefuzzy.Fuzzy
import dagger.hilt.android.lifecycle.HiltViewModel
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.text.Collator
import java.util.Locale
import javax.inject.Inject
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
import kotlin.io.path.isHidden
import kotlin.io.path.isRegularFile
import kotlin.io.path.name
import kotlin.io.path.pathString
import kotlin.io.path.relativeTo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -43,7 +54,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@ -54,9 +64,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem() =
if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
private fun Path.toPasswordItem() =
if (isRegularFile()) PasswordItem.newPassword(this, PasswordRepository.getRepositoryDirectory())
else PasswordItem.newCategory(this, PasswordRepository.getRepositoryDirectory())
private fun PasswordItem.fuzzyMatch(filter: String): Int {
val (_, score) = Fuzzy.fuzzyMatch(filter, longName)
@ -89,7 +99,7 @@ private fun PasswordItem.Companion.makeComparator(
}
val PasswordItem.stableId: String
get() = file.absolutePath
get() = file.absolutePathString()
enum class FilterMode {
NoFilter,
@ -151,7 +161,7 @@ constructor(
get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
private data class SearchAction(
val baseDirectory: File,
val baseDirectory: Path,
val filter: String,
val filterMode: FilterMode,
val searchMode: SearchMode,
@ -162,7 +172,7 @@ constructor(
)
private fun makeSearchAction(
baseDirectory: File,
baseDirectory: Path,
filter: String,
filterMode: FilterMode,
searchMode: SearchMode,
@ -206,9 +216,9 @@ constructor(
val prefilteredResultFlow =
when (searchAction.listMode) {
ListMode.FilesOnly ->
listResultFlow.filter { it.isFile }.flowOn(dispatcherProvider.io())
listResultFlow.filter { it.isRegularFile() }.flowOn(dispatcherProvider.io())
ListMode.DirectoriesOnly ->
listResultFlow.filter { it.isDirectory }.flowOn(dispatcherProvider.io())
listResultFlow.filter { it.isDirectory() }.flowOn(dispatcherProvider.io())
ListMode.AllEntries -> listResultFlow
}
val passwordList =
@ -223,7 +233,7 @@ constructor(
FilterMode.Exact -> {
prefilteredResultFlow
.filter { absoluteFile ->
absoluteFile.relativeTo(root).path.contains(searchAction.filter)
absoluteFile.relativeTo(root).pathString.contains(searchAction.filter)
}
.map { it.toPasswordItem() }
.flowOn(dispatcherProvider.io())
@ -238,7 +248,7 @@ constructor(
if (regex != null) {
prefilteredResultFlow
.filter { absoluteFile ->
regex.containsMatchIn(absoluteFile.relativeTo(root).path)
regex.containsMatchIn(absoluteFile.relativeTo(root).pathString)
}
.map { it.toPasswordItem() }
.flowOn(dispatcherProvider.io())
@ -268,27 +278,28 @@ constructor(
}
.flowOn(dispatcherProvider.io())
private fun shouldTake(file: File) =
private fun shouldTake(file: Path) =
with(file) {
if (showHiddenContents) {
return !file.name.startsWith(".git")
}
if (isDirectory) {
!isHidden
if (isDirectory()) {
!isHidden()
} else {
!isHidden && file.extension == "gpg"
!isHidden() && file.extension == "gpg"
}
}
private fun listFiles(dir: File): Flow<File> {
return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow()
private fun listFiles(dir: Path): Flow<Path> {
return Files.newDirectoryStream(dir, ::shouldTake).asFlow()
}
private fun listFilesRecursively(dir: File): Flow<File> {
private fun listFilesRecursively(dir: Path): Flow<Path> {
return dir
.toFile()
// Take top directory even if it is hidden.
.walkTopDown()
.onEnter { file -> file == dir || shouldTake(file) }
.onEnter { file -> file.toPath() == dir || shouldTake(file.toPath()) }
.asFlow()
// Skip the root directory
.drop(1)
@ -296,24 +307,25 @@ constructor(
yield()
it
}
.map { it.toPath() }
.filter(::shouldTake)
}
private val _currentDir = MutableStateFlow(root)
val currentDir = _currentDir.asStateFlow()
data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
data class NavigationStackEntry(val dir: Path, val recyclerViewState: Parcelable?)
private val navigationStack = ArrayDeque<NavigationStackEntry>()
fun navigateTo(
newDirectory: File = root,
newDirectory: Path = root,
listMode: ListMode = ListMode.AllEntries,
recyclerViewState: Parcelable? = null,
pushPreviousLocation: Boolean = true,
) {
if (!newDirectory.exists()) return
require(newDirectory.isDirectory) { "Can only navigate to a directory" }
require(newDirectory.isDirectory()) { "Can only navigate to a directory" }
if (pushPreviousLocation) {
navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState))
}
@ -353,12 +365,12 @@ constructor(
fun search(
filter: String,
baseDirectory: File? = null,
baseDirectory: Path? = null,
filterMode: FilterMode = FilterMode.Fuzzy,
searchMode: SearchMode? = null,
listMode: ListMode = ListMode.AllEntries,
) {
require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
require(baseDirectory?.isDirectory() != false) { "Can only search in a directory" }
searchActionFlow.update {
makeSearchAction(
filter = filter,
@ -401,7 +413,7 @@ constructor(
private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
oldItem.file.absolutePath == newItem.file.absolutePath
oldItem.file.absolutePathString() == newItem.file.absolutePathString()
override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
}
@ -479,11 +491,11 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
fun requireSelectionTracker() = selectionTracker!!
private val selectedFiles
get() = requireSelectionTracker().selection.map { File(it) }
get() = requireSelectionTracker().selection.map { Paths.get(it) }
fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
fun getPositionForFile(file: Path) = itemKeyProvider.getPosition(file.absolutePathString())
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)

View file

@ -19,9 +19,17 @@ import app.passwordstore.crypto.errors.NoKeysAvailableException
import app.passwordstore.crypto.errors.UnusableKeyException
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.coroutines.runSuspendCatching
import com.github.michaelbull.result.runCatching
import com.github.michaelbull.result.unwrap
import java.io.File
import java.nio.file.Paths
import javax.inject.Inject
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.readBytes
import kotlin.io.path.writeBytes
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.bouncycastle.openpgp.PGPPublicKeyRing
@ -34,7 +42,7 @@ public class PGPKeyManager
constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
KeyManager<PGPKey, PGPIdentifier> {
private val keyDir = File(filesDir, KEY_DIR_NAME)
private val keyDir = Paths.get(filesDir, KEY_DIR_NAME)
/** @see KeyManager.addKey */
override suspend fun addKey(key: PGPKey, replace: Boolean): Result<PGPKey, Throwable> =
@ -43,7 +51,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
if (!keyDirExists()) throw KeyDirectoryUnavailableException
val incomingKeyRing = tryParseKeyring(key) ?: throw InvalidKeyException
if (!isKeyUsable(key)) throw UnusableKeyException
val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
val keyFile = keyDir.resolve("${tryGetId(key)}.$KEY_EXTENSION")
if (keyFile.exists()) {
val existingKeyBytes = keyFile.readBytes()
val existingKeyRing =
@ -65,7 +73,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
throw KeyAlreadyExistsException(
tryGetId(key)?.toString() ?: "Failed to retrieve key ID"
)
if (!keyFile.delete()) throw KeyDeletionFailedException
if (!keyFile.deleteIfExists()) throw KeyDeletionFailedException
}
keyFile.writeBytes(key.contents)
@ -80,10 +88,8 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
runSuspendCatching {
if (!keyDirExists()) throw KeyDirectoryUnavailableException
val key = getKeyById(identifier).unwrap()
val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION")
if (keyFile.exists()) {
if (!keyFile.delete()) throw KeyDeletionFailedException
}
val keyFile = keyDir.resolve("${tryGetId(key)}.$KEY_EXTENSION")
if (!keyFile.deleteIfExists()) throw KeyDeletionFailedException
}
}
@ -92,8 +98,8 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
withContext(dispatcher) {
runSuspendCatching {
if (!keyDirExists()) throw KeyDirectoryUnavailableException
val keyFiles = keyDir.listFiles()
if (keyFiles.isNullOrEmpty()) throw NoKeysAvailableException
val keyFiles = keyDir.listDirectoryEntries().filter { it.isRegularFile() }
if (keyFiles.isEmpty()) throw NoKeysAvailableException
val keys = keyFiles.map { file -> PGPKey(file.readBytes()) }
val matchResult =
@ -128,8 +134,8 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
withContext(dispatcher) {
runSuspendCatching {
if (!keyDirExists()) throw KeyDirectoryUnavailableException
val keyFiles = keyDir.listFiles()
if (keyFiles.isNullOrEmpty()) return@runSuspendCatching emptyList()
val keyFiles = keyDir.listDirectoryEntries().filter { it.isRegularFile() }
if (keyFiles.isEmpty()) return@runSuspendCatching emptyList()
keyFiles.map { keyFile -> PGPKey(keyFile.readBytes()) }.toList()
}
}
@ -139,7 +145,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) :
/** Checks if [keyDir] exists and attempts to create it if not. */
private fun keyDirExists(): Boolean {
return keyDir.exists() || keyDir.mkdirs()
return keyDir.exists() || runCatching { keyDir.createDirectories() }.isOk
}
public companion object {

View file

@ -9,7 +9,9 @@ import app.passwordstore.crypto.errors.NoKeysAvailableException
import app.passwordstore.crypto.errors.UnusableKeyException
import com.github.michaelbull.result.unwrap
import com.github.michaelbull.result.unwrapError
import java.io.File
import kotlin.io.path.absolutePathString
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
@ -26,9 +28,9 @@ class PGPKeyManagerTest {
@get:Rule val temporaryFolder: TemporaryFolder = TemporaryFolder()
private val dispatcher = StandardTestDispatcher()
private val filesDir by unsafeLazy { temporaryFolder.root }
private val keysDir by unsafeLazy { File(filesDir, PGPKeyManager.KEY_DIR_NAME) }
private val keyManager by unsafeLazy { PGPKeyManager(filesDir.absolutePath, dispatcher) }
private val filesDir by unsafeLazy { temporaryFolder.root.toPath() }
private val keysDir by unsafeLazy { filesDir.resolve(PGPKeyManager.KEY_DIR_NAME) }
private val keyManager by unsafeLazy { PGPKeyManager(filesDir.absolutePathString(), dispatcher) }
private val secretKey = PGPKey(TestUtils.getArmoredSecretKey())
private val publicKey = PGPKey(TestUtils.getArmoredPublicKey())
@ -42,10 +44,10 @@ class PGPKeyManagerTest {
val keyId = keyManager.getKeyId(keyManager.addKey(secretKey).unwrap())
assertEquals(KeyId(CryptoConstants.KEY_ID), keyId)
// Check if the keys directory have one file
assertEquals(1, filesDir.list()?.size)
assertEquals(1, filesDir.listDirectoryEntries().size)
// Check if the file name is correct
val keyFile = keysDir.listFiles()?.first()
assertEquals(keyFile?.name, "$keyId.${PGPKeyManager.KEY_EXTENSION}")
val keyFile = keysDir.listDirectoryEntries().first()
assertEquals(keyFile.name, "$keyId.${PGPKeyManager.KEY_EXTENSION}")
}
@Test