diff options
author | Harsh Shandilya <me@msfjarvis.dev> | 2024-05-31 17:30:59 +0530 |
---|---|---|
committer | Harsh Shandilya <me@msfjarvis.dev> | 2024-07-28 23:00:16 +0530 |
commit | 36b45f90f90e76ca2611e9cfcca9a6ee440683b3 (patch) | |
tree | def33851faa9c76fcfc4a6e06f77cffee6475cb4 | |
parent | 7010ee85b4bdfa0c8120f15597a9471151576b2c (diff) |
refactor: migrate to NIO
34 files changed, 402 insertions, 337 deletions
diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 330c1e40..22f14a08 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -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) } } } diff --git a/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt b/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt index ed18f87a..53dfc03a 100644 --- a/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt +++ b/app/src/main/java/app/passwordstore/data/repo/PasswordRepository.kt @@ -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 - } } diff --git a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt index f7d5cf9a..3e2993b4 100644 --- a/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt +++ b/app/src/main/java/app/passwordstore/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -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" diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 9601d75e..b575bdad 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -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, diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt index 70dcd78d..d6efab11 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt @@ -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 diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt index c645ea04..dc54b78e 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -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) { diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index a0cf6f7c..dbeb86e4 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -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 { 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 c25b5d1a..e3ba7962 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -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) { 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 525d9f95..37a09e2f 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -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) } diff --git a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt index 3923a997..77567f4d 100644 --- a/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/dialogs/FolderCreationDialogFragment.kt @@ -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 /* diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt index 9d4531f4..5410543b 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderActivity.kt @@ -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() } diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt index 9a8080a4..9cca7bbd 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt @@ -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 { diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt index e292cf4e..596c79d4 100644 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt @@ -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) diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt index 6702afc0..39a19f0d 100644 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt @@ -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() diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt index b5facaf3..fc370413 100644 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/KeySelectionFragment.kt @@ -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) } 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 5b6826bc..bfeccdc5 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordFragment.kt @@ -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 } 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 c4dd631e..6625903d 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -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() diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt index c6c30b88..20aaa5aa 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -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) } } diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt index d1257f14..b6a2d410 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt @@ -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) { diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt index 743d6944..a68c8dbe 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt @@ -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() diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt index 66edaafd..8a8f3c26 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt @@ -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" } diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt index 70de5972..2e308d9d 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -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) } diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index 639f1073..ca894f8f 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -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 = diff --git a/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt index a921f8d6..e1fbe1f0 100644 --- a/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt +++ b/app/src/main/java/app/passwordstore/util/extensions/Extensions.kt @@ -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. * diff --git a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt index 863721a5..0fe95ee9 100644 --- a/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt +++ b/app/src/main/java/app/passwordstore/util/git/operation/GitOperation.kt @@ -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) diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 3f1b58d0..8b9376c0 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -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 diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index c03575d4..06089369 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -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 { diff --git a/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt b/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt index ad70062a..e76eb7e9 100644 --- a/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt +++ b/app/src/main/java/app/passwordstore/util/services/PasswordExportService.kt @@ -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") diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index d15269be..7f3de73c 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -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() } diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 47187080..acbcf36d 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -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 && diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt index 61e4b118..3adc87cf 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -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 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 3c3db292..2a1be5a7 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -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) diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt index cbaa110d..8efa2a97 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -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 { diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt index 8893d0eb..8f4452c8 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt @@ -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 |