aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/ui/passwords
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/ui/passwords')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt518
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt1159
2 files changed, 823 insertions, 854 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
index cc93b77f..c5712353 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
@@ -49,296 +49,278 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
class PasswordFragment : Fragment(R.layout.password_recycler_view) {
- private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
- private lateinit var listener: OnFragmentInteractionListener
- private lateinit var settings: SharedPreferences
+ private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
+ private lateinit var listener: OnFragmentInteractionListener
+ private lateinit var settings: SharedPreferences
+
+ private var recyclerViewStateToRestore: Parcelable? = null
+ private var actionMode: ActionMode? = null
+ private var scrollTarget: File? = null
+
+ private val model: SearchableRepositoryViewModel by activityViewModels()
+ private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
+ private val swipeResult =
+ registerForActivityResult(StartActivityForResult()) {
+ binding.swipeRefresher.isRefreshing = false
+ requireStore().refreshPasswordList()
+ }
- private var recyclerViewStateToRestore: Parcelable? = null
- private var actionMode: ActionMode? = null
- private var scrollTarget: File? = null
+ val currentDir: File
+ get() = model.currentDir.value!!
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ settings = requireContext().sharedPrefs
+ initializePasswordList()
+ binding.fab.setOnClickListener { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
+ childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
+ when (bundle.getString(ACTION_KEY)) {
+ ACTION_FOLDER -> requireStore().createFolder()
+ ACTION_PASSWORD -> requireStore().createPassword()
+ }
+ }
+ }
- private val model: SearchableRepositoryViewModel by activityViewModels()
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private val swipeResult = registerForActivityResult(StartActivityForResult()) {
- binding.swipeRefresher.isRefreshing = false
+ private fun initializePasswordList() {
+ val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
+ val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
+ binding.swipeRefresher.setOnRefreshListener {
+ if (!hasGitDir) {
requireStore().refreshPasswordList()
+ binding.swipeRefresher.isRefreshing = false
+ } else if (!PasswordRepository.isGitRepo()) {
+ BasicBottomSheet.Builder(requireContext())
+ .setMessageRes(R.string.clone_git_repo)
+ .setPositiveButtonClickListener(getString(R.string.clone_button)) {
+ swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
+ }
+ .build()
+ .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
+ binding.swipeRefresher.isRefreshing = false
+ } else {
+ // When authentication is set to AuthMode.None then the only git operation we can
+ // run is a pull, so automatically fallback to that.
+ val operationId =
+ when (GitSettings.authMode) {
+ AuthMode.None -> BaseGitActivity.GitOp.PULL
+ else -> BaseGitActivity.GitOp.SYNC
+ }
+ requireStore().apply {
+ lifecycleScope.launch {
+ launchGitOperation(operationId)
+ .fold(
+ success = {
+ binding.swipeRefresher.isRefreshing = false
+ refreshPasswordList()
+ },
+ failure = { err -> promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } },
+ )
+ }
+ }
+ }
}
- val currentDir: File
- get() = model.currentDir.value!!
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- settings = requireContext().sharedPrefs
- initializePasswordList()
- binding.fab.setOnClickListener {
- ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
- }
- childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
- when (bundle.getString(ACTION_KEY)) {
- ACTION_FOLDER -> requireStore().createFolder()
- ACTION_PASSWORD -> requireStore().createPassword()
- }
+ recyclerAdapter =
+ PasswordItemRecyclerAdapter()
+ .onItemClicked { _, item -> listener.onFragmentInteraction(item) }
+ .onSelectionChanged { selection ->
+ // In order to not interfere with drag selection, we disable the
+ // SwipeRefreshLayout
+ // once an item is selected.
+ binding.swipeRefresher.isEnabled = selection.isEmpty
+
+ if (actionMode == null)
+ actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
+
+ if (!selection.isEmpty) {
+ actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
+ actionMode!!.invalidate()
+ } else {
+ actionMode!!.finish()
+ }
}
+ val recyclerView = binding.passRecycler
+ recyclerView.apply {
+ addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
+ layoutManager = LinearLayoutManager(requireContext())
+ itemAnimator = OnOffItemAnimator()
+ adapter = recyclerAdapter
}
- private fun initializePasswordList() {
- val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
- val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
- binding.swipeRefresher.setOnRefreshListener {
- if (!hasGitDir) {
- requireStore().refreshPasswordList()
- binding.swipeRefresher.isRefreshing = false
- } else if (!PasswordRepository.isGitRepo()) {
- BasicBottomSheet.Builder(requireContext())
- .setMessageRes(R.string.clone_git_repo)
- .setPositiveButtonClickListener(getString(R.string.clone_button)) {
- swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
- }
- .build()
- .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
- binding.swipeRefresher.isRefreshing = false
- } else {
- // When authentication is set to AuthMode.None then the only git operation we can
- // run is a pull, so automatically fallback to that.
- val operationId = when (GitSettings.authMode) {
- AuthMode.None -> BaseGitActivity.GitOp.PULL
- else -> BaseGitActivity.GitOp.SYNC
- }
- requireStore().apply {
- lifecycleScope.launch {
- launchGitOperation(operationId).fold(
- success = {
- binding.swipeRefresher.isRefreshing = false
- refreshPasswordList()
- },
- failure = { err ->
- promptOnErrorHandler(err) {
- binding.swipeRefresher.isRefreshing = false
- }
- },
- )
- }
- }
- }
+ FastScrollerBuilder(recyclerView).build()
+ recyclerAdapter.makeSelectable(recyclerView)
+ registerForContextMenu(recyclerView)
+
+ val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
+ model.navigateTo(File(path), pushPreviousLocation = false)
+ model.searchResult.observe(viewLifecycleOwner) { result ->
+ // Only run animations when the new list is filtered, i.e., the user submitted a search,
+ // and not on folder navigations since the latter leads to too many removal animations.
+ (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
+ recyclerAdapter.submitList(result.passwordItems) {
+ when {
+ result.isFiltered -> {
+ // When the result is filtered, we always scroll to the top since that is
+ // where
+ // the best fuzzy match appears.
+ recyclerView.scrollToPosition(0)
+ }
+ scrollTarget != null -> {
+ scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
+ scrollTarget = null
+ }
+ else -> {
+ // When the result is not filtered and there is a saved scroll position for
+ // it,
+ // we try to restore it.
+ recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
+ recyclerViewStateToRestore = null
+ }
}
-
- recyclerAdapter = PasswordItemRecyclerAdapter()
- .onItemClicked { _, item ->
- listener.onFragmentInteraction(item)
- }
- .onSelectionChanged { selection ->
- // In order to not interfere with drag selection, we disable the SwipeRefreshLayout
- // once an item is selected.
- binding.swipeRefresher.isEnabled = selection.isEmpty
-
- if (actionMode == null)
- actionMode = requireStore().startSupportActionMode(actionModeCallback)
- ?: return@onSelectionChanged
-
- if (!selection.isEmpty) {
- actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
- actionMode!!.invalidate()
- } else {
- actionMode!!.finish()
- }
- }
- val recyclerView = binding.passRecycler
- recyclerView.apply {
- addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
- layoutManager = LinearLayoutManager(requireContext())
- itemAnimator = OnOffItemAnimator()
- adapter = recyclerAdapter
+ }
+ }
+ }
+
+ private val actionModeCallback =
+ object : ActionMode.Callback {
+ // Called when the action mode is created; startActionMode() was called
+ override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
+ // Inflate a menu resource providing context menu items
+ mode.menuInflater.inflate(R.menu.context_pass, menu)
+ // hide the fab
+ animateFab(false)
+ return true
+ }
+
+ // Called each time the action mode is shown. Always called after onCreateActionMode,
+ // but
+ // may be called multiple times if the mode is invalidated.
+ override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
+ menu.findItem(R.id.menu_edit_password).isVisible =
+ recyclerAdapter.getSelectedItems().all { it.type == PasswordItem.TYPE_CATEGORY }
+ return true
+ }
+
+ // Called when the user selects a contextual menu item
+ override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.menu_delete_password -> {
+ requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
+ // Action picked, so close the CAB
+ mode.finish()
+ true
+ }
+ R.id.menu_move_password -> {
+ requireStore().movePasswords(recyclerAdapter.getSelectedItems())
+ false
+ }
+ R.id.menu_edit_password -> {
+ requireStore().renameCategory(recyclerAdapter.getSelectedItems())
+ mode.finish()
+ false
+ }
+ else -> false
}
-
- FastScrollerBuilder(recyclerView).build()
- recyclerAdapter.makeSelectable(recyclerView)
- registerForContextMenu(recyclerView)
-
- val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
- model.navigateTo(File(path), pushPreviousLocation = false)
- model.searchResult.observe(viewLifecycleOwner) { result ->
- // Only run animations when the new list is filtered, i.e., the user submitted a search,
- // and not on folder navigations since the latter leads to too many removal animations.
- (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
- recyclerAdapter.submitList(result.passwordItems) {
- when {
- result.isFiltered -> {
- // When the result is filtered, we always scroll to the top since that is where
- // the best fuzzy match appears.
- recyclerView.scrollToPosition(0)
- }
- scrollTarget != null -> {
- scrollTarget?.let {
- recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
- }
- scrollTarget = null
- }
- else -> {
- // When the result is not filtered and there is a saved scroll position for it,
- // we try to restore it.
- recyclerViewStateToRestore?.let {
- recyclerView.layoutManager!!.onRestoreInstanceState(it)
- }
- recyclerViewStateToRestore = null
- }
- }
+ }
+
+ // Called when the user exits the action mode
+ override fun onDestroyActionMode(mode: ActionMode) {
+ recyclerAdapter.requireSelectionTracker().clearSelection()
+ actionMode = null
+ // show the fab
+ animateFab(true)
+ }
+
+ private fun animateFab(show: Boolean) =
+ with(binding.fab) {
+ val animation = AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
+ animation.setAnimationListener(
+ object : Animation.AnimationListener {
+ override fun onAnimationRepeat(animation: Animation?) {}
+
+ override fun onAnimationEnd(animation: Animation?) {
+ if (!show) visibility = View.GONE
+ }
+
+ override fun onAnimationStart(animation: Animation?) {
+ if (show) visibility = View.VISIBLE
+ }
}
+ )
+ animate().rotationBy(if (show) -90f else 90f).setStartDelay(if (show) 100 else 0).setDuration(100).start()
+ startAnimation(animation)
}
}
- private val actionModeCallback = object : ActionMode.Callback {
- // Called when the action mode is created; startActionMode() was called
- override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
- // Inflate a menu resource providing context menu items
- mode.menuInflater.inflate(R.menu.context_pass, menu)
- // hide the fab
- animateFab(false)
- return true
- }
-
- // Called each time the action mode is shown. Always called after onCreateActionMode, but
- // may be called multiple times if the mode is invalidated.
- override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
- menu.findItem(R.id.menu_edit_password).isVisible =
- recyclerAdapter.getSelectedItems()
- .all { it.type == PasswordItem.TYPE_CATEGORY }
- return true
- }
-
- // Called when the user selects a contextual menu item
- override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
- return when (item.itemId) {
- R.id.menu_delete_password -> {
- requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
- // Action picked, so close the CAB
- mode.finish()
- true
- }
- R.id.menu_move_password -> {
- requireStore().movePasswords(recyclerAdapter.getSelectedItems())
- false
- }
- R.id.menu_edit_password -> {
- requireStore().renameCategory(recyclerAdapter.getSelectedItems())
- mode.finish()
- false
- }
- else -> false
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ runCatching {
+ listener =
+ object : OnFragmentInteractionListener {
+ override fun onFragmentInteraction(item: PasswordItem) {
+ if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
+ // save the time when password was used
+ val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ preferences.edit { putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) }
}
- }
-
- // Called when the user exits the action mode
- override fun onDestroyActionMode(mode: ActionMode) {
- recyclerAdapter.requireSelectionTracker().clearSelection()
- actionMode = null
- // show the fab
- animateFab(true)
- }
- private fun animateFab(show: Boolean) = with(binding.fab) {
- val animation = AnimationUtils.loadAnimation(
- context, if (show) R.anim.scale_up else R.anim.scale_down
- )
- animation.setAnimationListener(object : Animation.AnimationListener {
- override fun onAnimationRepeat(animation: Animation?) {
- }
-
- override fun onAnimationEnd(animation: Animation?) {
- if (!show) visibility = View.GONE
- }
-
- override fun onAnimationStart(animation: Animation?) {
- if (show) visibility = View.VISIBLE
- }
- })
- animate().rotationBy(if (show) -90f else 90f)
- .setStartDelay(if (show) 100 else 0)
- .setDuration(100)
- .start()
- startAnimation(animation)
- }
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
- runCatching {
- listener = object : OnFragmentInteractionListener {
- override fun onFragmentInteraction(item: PasswordItem) {
- if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
- //save the time when password was used
- val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- preferences.edit {
- putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
- }
- }
-
- if (item.type == PasswordItem.TYPE_CATEGORY) {
- navigateTo(item.file)
- } else {
- if (requireArguments().getBoolean("matchWith", false)) {
- requireStore().matchPasswordWithApp(item)
- } else {
- requireStore().decryptPassword(item)
- }
- }
- }
+ if (item.type == PasswordItem.TYPE_CATEGORY) {
+ navigateTo(item.file)
+ } else {
+ if (requireArguments().getBoolean("matchWith", false)) {
+ requireStore().matchPasswordWithApp(item)
+ } else {
+ requireStore().decryptPassword(item)
+ }
}
- }.onFailure {
- throw ClassCastException("$context must implement OnFragmentInteractionListener")
+ }
}
}
-
- private fun requireStore() = requireActivity() as PasswordStore
-
- /**
- * Returns true if the back press was handled by the [Fragment].
- */
- fun onBackPressedInActivity(): Boolean {
- if (!model.canNavigateBack)
- return false
- // The RecyclerView state is restored when the asynchronous update operation on the
- // adapter is completed.
- recyclerViewStateToRestore = model.navigateBack()
- if (!model.canNavigateBack)
- requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
- return true
+ .onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
+ }
+
+ private fun requireStore() = requireActivity() as PasswordStore
+
+ /** Returns true if the back press was handled by the [Fragment]. */
+ fun onBackPressedInActivity(): Boolean {
+ if (!model.canNavigateBack) return false
+ // The RecyclerView state is restored when the asynchronous update operation on the
+ // adapter is completed.
+ recyclerViewStateToRestore = model.navigateBack()
+ if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
+ return true
+ }
+
+ fun dismissActionMode() {
+ actionMode?.finish()
+ }
+
+ companion object {
+
+ const val ITEM_CREATION_REQUEST_KEY = "creation_key"
+ const val ACTION_KEY = "action"
+ const val ACTION_FOLDER = "folder"
+ const val ACTION_PASSWORD = "password"
+
+ fun newInstance(args: Bundle): PasswordFragment {
+ val fragment = PasswordFragment()
+ fragment.arguments = args
+ return fragment
}
+ }
- fun dismissActionMode() {
- actionMode?.finish()
- }
+ fun navigateTo(file: File) {
+ requireStore().clearSearch()
+ model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
+ requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
- companion object {
+ fun scrollToOnNextRefresh(file: File) {
+ scrollTarget = file
+ }
- const val ITEM_CREATION_REQUEST_KEY = "creation_key"
- const val ACTION_KEY = "action"
- const val ACTION_FOLDER = "folder"
- const val ACTION_PASSWORD = "password"
+ interface OnFragmentInteractionListener {
- fun newInstance(args: Bundle): PasswordFragment {
- val fragment = PasswordFragment()
- fragment.arguments = args
- return fragment
- }
- }
-
-
- fun navigateTo(file: File) {
- requireStore().clearSearch()
- model.navigateTo(
- file,
- recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
- )
- requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
- }
-
- fun scrollToOnNextRefresh(file: File) {
- scrollTarget = file
- }
-
- interface OnFragmentInteractionListener {
-
- fun onFragmentInteraction(item: PasswordItem)
- }
+ fun onFragmentInteraction(item: PasswordItem)
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
index a337a189..96fce533 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
@@ -78,625 +78,612 @@ const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
class PasswordStore : BaseGitActivity() {
- private lateinit var searchItem: MenuItem
- private val settings by lazy { sharedPrefs }
+ private lateinit var searchItem: MenuItem
+ private val settings by lazy { sharedPrefs }
- private val model: SearchableRepositoryViewModel by viewModels {
- ViewModelProvider.AndroidViewModelFactory(application)
- }
+ private val model: SearchableRepositoryViewModel by viewModels {
+ ViewModelProvider.AndroidViewModelFactory(application)
+ }
- private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted ->
- if (granted) checkLocalRepository()
- }
+ private val storagePermissionRequest =
+ registerForActivityResult(RequestPermission()) { granted -> if (granted) checkLocalRepository() }
- private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- checkLocalRepository()
- }
+ private val directorySelectAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ checkLocalRepository()
+ }
}
- private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- refreshPasswordList()
- }
- }
-
- private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result ->
- val intentData = result.data ?: return@registerForActivityResult
- val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files"))
- val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")))
- val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
- if (!target.isDirectory) {
- e { "Tried moving passwords to a non-existing folder." }
- return@registerForActivityResult
- }
-
- d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
- d { filesToMove.joinToString(", ") }
-
- lifecycleScope.launch(Dispatchers.IO) {
- for (file in filesToMove) {
- val source = File(file)
- if (!source.exists()) {
- e { "Tried moving something that appears non-existent." }
- continue
- }
- val destinationFile = File(target.absolutePath + "/" + source.name)
- val basename = source.nameWithoutExtension
- val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- if (destinationFile.exists()) {
- e { "Trying to move a file that already exists." }
- withContext(Dispatchers.Main) {
- MaterialAlertDialogBuilder(this@PasswordStore)
- .setTitle(resources.getString(R.string.password_exists_title))
- .setMessage(resources.getString(
- R.string.password_exists_message,
- destinationLongName,
- sourceLongName)
- )
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- launch(Dispatchers.IO) {
- moveFile(source, destinationFile)
- }
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .show()
- }
- } else {
- launch(Dispatchers.IO) {
- moveFile(source, destinationFile)
- }
- }
- }
- when (filesToMove.size) {
- 1 -> {
- val source = File(filesToMove[0])
- val basename = source.nameWithoutExtension
- val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
- val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
- withContext(Dispatchers.Main) {
- commitChange(
- resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName),
- )
- }
- }
- else -> {
- val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
- val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
- withContext(Dispatchers.Main) {
- commitChange(
- resources.getString(R.string.git_commit_move_multiple_text, relativePath),
- )
- }
+ private val listRefreshAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ refreshPasswordList()
+ }
+ }
+
+ private val passwordMoveAction =
+ registerForActivityResult(StartActivityForResult()) { result ->
+ val intentData = result.data ?: return@registerForActivityResult
+ val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files"))
+ val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")))
+ val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath
+ if (!target.isDirectory) {
+ e { "Tried moving passwords to a non-existing folder." }
+ return@registerForActivityResult
+ }
+
+ d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" }
+ d { filesToMove.joinToString(", ") }
+
+ lifecycleScope.launch(Dispatchers.IO) {
+ for (file in filesToMove) {
+ val source = File(file)
+ if (!source.exists()) {
+ e { "Tried moving something that appears non-existent." }
+ continue
+ }
+ val destinationFile = File(target.absolutePath + "/" + source.name)
+ val basename = source.nameWithoutExtension
+ val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
+ val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
+ if (destinationFile.exists()) {
+ e { "Trying to move a file that already exists." }
+ withContext(Dispatchers.Main) {
+ MaterialAlertDialogBuilder(this@PasswordStore)
+ .setTitle(resources.getString(R.string.password_exists_title))
+ .setMessage(resources.getString(R.string.password_exists_message, destinationLongName, sourceLongName))
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ launch(Dispatchers.IO) { moveFile(source, destinationFile) }
}
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
}
+ } else {
+ launch(Dispatchers.IO) { moveFile(source, destinationFile) }
+ }
}
- refreshPasswordList()
- getPasswordFragment()?.dismissActionMode()
- }
-
- override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
- // open search view on search key, or Ctr+F
- if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) &&
- !searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- return true
- }
-
- // open search view on any printable character and query for it
- val c = event.unicodeChar.toChar()
- val printable = isPrintable(c)
- if (printable && !searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
- (searchItem.actionView as SearchView).setQuery(c.toString(), true)
- return true
- }
- return super.onKeyDown(keyCode, event)
- }
-
- @SuppressLint("NewApi")
- override fun onCreate(savedInstanceState: Bundle?) {
- // If user opens app with permission granted then revokes and returns,
- // prevent attempt to create password list fragment
- var savedInstance = savedInstanceState
- if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) ||
- !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) {
- savedInstance = null
- }
- super.onCreate(savedInstance)
- setContentView(R.layout.activity_pwdstore)
-
- model.currentDir.observe(this) { dir ->
- val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
- supportActionBar!!.apply {
- if (dir != basePath)
- title = dir.name
- else
- setTitle(R.string.app_name)
+ when (filesToMove.size) {
+ 1 -> {
+ val source = File(filesToMove[0])
+ val basename = source.nameWithoutExtension
+ val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
+ val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName),
+ )
}
- }
- }
-
- override fun onStart() {
- super.onStart()
- refreshPasswordList()
- }
-
- override fun onResume() {
- super.onResume()
- if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
- hasRequiredStoragePermissions()
- } else {
- checkLocalRepository()
- }
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
- if (!searchItem.isActionViewExpanded) {
- searchItem.expandActionView()
+ }
+ else -> {
+ val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath
+ val relativePath = getRelativePath("${target.absolutePath}/", repoDir)
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_multiple_text, relativePath),
+ )
}
+ }
}
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- val menuRes = when {
- GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
- PasswordRepository.isGitRepo() -> R.menu.main_menu_git
- else -> R.menu.main_menu_non_git
+ }
+ refreshPasswordList()
+ getPasswordFragment()?.dismissActionMode()
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ // open search view on search key, or Ctr+F
+ if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) &&
+ !searchItem.isActionViewExpanded
+ ) {
+ searchItem.expandActionView()
+ return true
+ }
+
+ // open search view on any printable character and query for it
+ val c = event.unicodeChar.toChar()
+ val printable = isPrintable(c)
+ if (printable && !searchItem.isActionViewExpanded) {
+ searchItem.expandActionView()
+ (searchItem.actionView as SearchView).setQuery(c.toString(), true)
+ return true
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ @SuppressLint("NewApi")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // If user opens app with permission granted then revokes and returns,
+ // prevent attempt to create password list fragment
+ var savedInstance = savedInstanceState
+ if (savedInstanceState != null &&
+ (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) ||
+ !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))
+ ) {
+ savedInstance = null
+ }
+ super.onCreate(savedInstance)
+ setContentView(R.layout.activity_pwdstore)
+
+ model.currentDir.observe(this) { dir ->
+ val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
+ supportActionBar!!.apply { if (dir != basePath) title = dir.name else setTitle(R.string.app_name) }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ refreshPasswordList()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
+ hasRequiredStoragePermissions()
+ } else {
+ checkLocalRepository()
+ }
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) {
+ if (!searchItem.isActionViewExpanded) {
+ searchItem.expandActionView()
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ val menuRes =
+ when {
+ GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
+ PasswordRepository.isGitRepo() -> R.menu.main_menu_git
+ else -> R.menu.main_menu_non_git
+ }
+ menuInflater.inflate(menuRes, menu)
+ return super.onCreateOptionsMenu(menu)
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+ // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
+ // we can get by without any noticeable difference in performance.
+ invalidateOptionsMenu()
+ searchItem = menu.findItem(R.id.action_search)
+ val searchView = searchItem.actionView as SearchView
+ searchView.setOnQueryTextListener(
+ object : OnQueryTextListener {
+ override fun onQueryTextSubmit(s: String): Boolean {
+ searchView.clearFocus()
+ return true
}
- menuInflater.inflate(menuRes, menu)
- return super.onCreateOptionsMenu(menu)
- }
- override fun onPrepareOptionsMenu(menu: Menu): Boolean {
- // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so
- // we can get by without any noticeable difference in performance.
- invalidateOptionsMenu()
- searchItem = menu.findItem(R.id.action_search)
- val searchView = searchItem.actionView as SearchView
- searchView.setOnQueryTextListener(
- object : OnQueryTextListener {
- override fun onQueryTextSubmit(s: String): Boolean {
- searchView.clearFocus()
- return true
- }
-
- override fun onQueryTextChange(s: String): Boolean {
- val filter = s.trim()
- // List the contents of the current directory if the user enters a blank
- // search term.
- if (filter.isEmpty())
- model.navigateTo(
- newDirectory = model.currentDir.value!!,
- pushPreviousLocation = false
- )
- else
- model.search(filter)
- return true
- }
- })
-
- // When using the support library, the setOnActionExpandListener() method is
- // static and accepts the MenuItem object as an argument
- searchItem.setOnActionExpandListener(
- object : OnActionExpandListener {
- override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
- refreshPasswordList()
- return true
- }
-
- override fun onMenuItemActionExpand(item: MenuItem): Boolean {
- return true
- }
- })
- if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
- searchItem.expandActionView()
+ override fun onQueryTextChange(s: String): Boolean {
+ val filter = s.trim()
+ // List the contents of the current directory if the user enters a blank
+ // search term.
+ if (filter.isEmpty()) model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false)
+ else model.search(filter)
+ return true
}
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- val id = item.itemId
- val initBefore = MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- when (id) {
- R.id.user_pref -> {
- runCatching {
- startActivity(Intent(this, SettingsActivity::class.java))
- }.onFailure { e ->
- e.printStackTrace()
- }
- }
- R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
- }
- R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
- }
- R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
- }
- R.id.refresh -> refreshPasswordList()
- android.R.id.home -> onBackPressed()
- else -> return super.onOptionsItemSelected(item)
+ }
+ )
+
+ // When using the support library, the setOnActionExpandListener() method is
+ // static and accepts the MenuItem object as an argument
+ searchItem.setOnActionExpandListener(
+ object : OnActionExpandListener {
+ override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
+ refreshPasswordList()
+ return true
}
- return true
- }
-
- override fun onBackPressed() {
- if (getPasswordFragment()?.onBackPressedInActivity() != true)
- super.onBackPressed()
- }
-
- private fun getPasswordFragment(): PasswordFragment? {
- return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
- }
-
- fun clearSearch() {
- if (searchItem.isActionViewExpanded)
- searchItem.collapseActionView()
- }
-
- private fun runGitOperation(operation: GitOp) = lifecycleScope.launch {
- launchGitOperation(operation).fold(
- success = { refreshPasswordList() },
- failure = { promptOnErrorHandler(it) },
- )
- }
- /**
- * Validates if storage permission is granted, and requests for it if not. The return value
- * is true if the permission has been granted.
- */
- private fun hasRequiredStoragePermissions(): Boolean {
- return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
- BasicBottomSheet.Builder(this)
- .setMessageRes(R.string.access_sdcard_text)
- .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) {
- storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
- }
- .build()
- .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING")
- false
+ override fun onMenuItemActionExpand(item: MenuItem): Boolean {
+ return true
+ }
+ }
+ )
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
+ searchItem.expandActionView()
+ }
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ val id = item.itemId
+ val initBefore =
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getString(R.string.creation_dialog_text))
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ when (id) {
+ R.id.user_pref -> {
+ runCatching { startActivity(Intent(this, SettingsActivity::class.java)) }.onFailure { e -> e.printStackTrace() }
+ }
+ R.id.git_push -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- checkLocalRepository()
- true
+ runGitOperation(GitOp.PUSH)
}
- }
-
- private fun checkLocalRepository() {
- val repo = PasswordRepository.initialize()
- if (repo == null) {
- directorySelectAction.launch(Intent(this, DirectorySelectionActivity::class.java))
+ }
+ R.id.git_pull -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- checkLocalRepository(PasswordRepository.getRepositoryDirectory())
+ runGitOperation(GitOp.PULL)
}
- }
-
- private fun checkLocalRepository(localDir: File?) {
- if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
- d { "Check, dir: ${localDir.absolutePath}" }
- // do not push the fragment if we already have it
- if (getPasswordFragment() == null ||
- settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
- settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
- val args = Bundle()
- args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
-
- // if the activity was started from the autofill settings, the
- // intent is to match a clicked pwd with app. pass this to fragment
- if (intent.getBooleanExtra("matchWith", false)) {
- args.putBoolean("matchWith", true)
- }
- supportActionBar?.apply {
- show()
- setDisplayHomeAsUpEnabled(false)
- }
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- supportFragmentManager.commit {
- replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
- }
- }
+ }
+ R.id.git_sync -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- startActivity(Intent(this, OnboardingActivity::class.java))
+ runGitOperation(GitOp.SYNC)
}
+ }
+ R.id.refresh -> refreshPasswordList()
+ android.R.id.home -> onBackPressed()
+ else -> return super.onOptionsItemSelected(item)
+ }
+ return true
+ }
+
+ override fun onBackPressed() {
+ if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed()
+ }
+
+ private fun getPasswordFragment(): PasswordFragment? {
+ return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
+ }
+
+ fun clearSearch() {
+ if (searchItem.isActionViewExpanded) searchItem.collapseActionView()
+ }
+
+ private fun runGitOperation(operation: GitOp) =
+ lifecycleScope.launch {
+ launchGitOperation(operation)
+ .fold(
+ success = { refreshPasswordList() },
+ failure = { promptOnErrorHandler(it) },
+ )
}
- private fun getRelativePath(fullPath: String, repositoryPath: String): String {
- return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
- }
-
- private fun getLastChangedTimestamp(fullPath: String): Long {
- val repoPath = PasswordRepository.getRepositoryDirectory()
- val repository = PasswordRepository.getRepository(repoPath)
- if (repository == null) {
- d { "getLastChangedTimestamp: No git repository" }
- return File(fullPath).lastModified()
+ /**
+ * Validates if storage permission is granted, and requests for it if not. The return value is
+ * true if the permission has been granted.
+ */
+ private fun hasRequiredStoragePermissions(): Boolean {
+ return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ BasicBottomSheet.Builder(this)
+ .setMessageRes(R.string.access_sdcard_text)
+ .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) {
+ storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
- val git = Git(repository)
- val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/'
- return runCatching {
- val iterator = git.log().addPath(relativePath).call().iterator()
- if (!iterator.hasNext()) {
- w { "getLastChangedTimestamp: No commits for file: $relativePath" }
- return -1
- }
- iterator.next().commitTime.toLong() * 1000
- }.getOr(-1)
- }
-
- fun decryptPassword(item: PasswordItem) {
- val decryptIntent = Intent(this, DecryptActivity::class.java)
- val authDecryptIntent = Intent(this, LaunchActivity::class.java)
- for (intent in arrayOf(decryptIntent, authDecryptIntent)) {
- intent.putExtra("NAME", item.toString())
- intent.putExtra("FILE_PATH", item.file.absolutePath)
- intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
- intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath))
+ .build()
+ .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING")
+ false
+ } else {
+ checkLocalRepository()
+ true
+ }
+ }
+
+ private fun checkLocalRepository() {
+ val repo = PasswordRepository.initialize()
+ if (repo == null) {
+ directorySelectAction.launch(Intent(this, DirectorySelectionActivity::class.java))
+ } else {
+ checkLocalRepository(PasswordRepository.getRepositoryDirectory())
+ }
+ }
+
+ private fun checkLocalRepository(localDir: File?) {
+ if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
+ d { "Check, dir: ${localDir.absolutePath}" }
+ // do not push the fragment if we already have it
+ if (getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
+ settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
+ val args = Bundle()
+ args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
+
+ // if the activity was started from the autofill settings, the
+ // intent is to match a clicked pwd with app. pass this to fragment
+ if (intent.getBooleanExtra("matchWith", false)) {
+ args.putBoolean("matchWith", true)
}
- // Needs an action to be a shortcut intent
- authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS
-
- startActivity(decryptIntent)
-
- // Adds shortcut
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
- addShortcut(item, authDecryptIntent)
+ supportActionBar?.apply {
+ show()
+ setDisplayHomeAsUpEnabled(false)
}
- }
-
- @RequiresApi(Build.VERSION_CODES.N_MR1)
- private fun addShortcut(item: PasswordItem, intent: Intent) {
- val shortcutManager: ShortcutManager = getSystemService() ?: return
- val shortcut = Builder(this, item.fullPathToParent)
- .setShortLabel(item.toString())
- .setLongLabel(item.fullPathToParent + item.toString())
- .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px))
- .setIntent(intent)
- .build()
- val shortcuts = shortcutManager.dynamicShortcuts
- // If we're above or equal to the maximum shortcuts allowed, drop the last item.
- if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
- shortcuts.removeLast()
+ supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ supportFragmentManager.commit {
+ replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
}
- // Reverse the list so we can append our new shortcut at the 'end'.
- shortcuts.reverse()
- shortcuts.add(shortcut)
- // Reverse it again, so the previous items are now in the correct order and our new item
- // is at the front like it's supposed to.
- shortcuts.reverse()
- // Write back the new shortcuts.
- shortcutManager.dynamicShortcuts = shortcuts
- }
-
- private fun validateState(): Boolean {
- if (!PasswordRepository.isInitialized) {
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- .show()
- return false
+ }
+ } else {
+ startActivity(Intent(this, OnboardingActivity::class.java))
+ }
+ }
+
+ private fun getRelativePath(fullPath: String, repositoryPath: String): String {
+ return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
+ }
+
+ private fun getLastChangedTimestamp(fullPath: String): Long {
+ val repoPath = PasswordRepository.getRepositoryDirectory()
+ val repository = PasswordRepository.getRepository(repoPath)
+ if (repository == null) {
+ d { "getLastChangedTimestamp: No git repository" }
+ return File(fullPath).lastModified()
+ }
+ val git = Git(repository)
+ val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/'
+ return runCatching {
+ val iterator = git.log().addPath(relativePath).call().iterator()
+ if (!iterator.hasNext()) {
+ w { "getLastChangedTimestamp: No commits for file: $relativePath" }
+ return -1
}
- return true
- }
-
- fun createPassword() {
- if (!validateState()) return
- val currentDir = currentDir
- i { "Adding file to : ${currentDir.absolutePath}" }
- val intent = Intent(this, PasswordCreationActivity::class.java)
- intent.putExtra("FILE_PATH", currentDir.absolutePath)
- intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
- listRefreshAction.launch(intent)
- }
-
- fun createFolder() {
- if (!validateState()) return
- FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
- }
-
- fun deletePasswords(selectedItems: List<PasswordItem>) {
- var size = 0
- selectedItems.forEach {
- if (it.file.isFile)
- size++
- else
- size += it.file.listFilesRecursively().size
+ iterator.next().commitTime.toLong() * 1000
+ }
+ .getOr(-1)
+ }
+
+ fun decryptPassword(item: PasswordItem) {
+ val decryptIntent = Intent(this, DecryptActivity::class.java)
+ val authDecryptIntent = Intent(this, LaunchActivity::class.java)
+ for (intent in arrayOf(decryptIntent, authDecryptIntent)) {
+ intent.putExtra("NAME", item.toString())
+ intent.putExtra("FILE_PATH", item.file.absolutePath)
+ intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath))
+ }
+ // Needs an action to be a shortcut intent
+ authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS
+
+ startActivity(decryptIntent)
+
+ // Adds shortcut
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ addShortcut(item, authDecryptIntent)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N_MR1)
+ private fun addShortcut(item: PasswordItem, intent: Intent) {
+ val shortcutManager: ShortcutManager = getSystemService() ?: return
+ val shortcut =
+ Builder(this, item.fullPathToParent)
+ .setShortLabel(item.toString())
+ .setLongLabel(item.fullPathToParent + item.toString())
+ .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px))
+ .setIntent(intent)
+ .build()
+ val shortcuts = shortcutManager.dynamicShortcuts
+ // If we're above or equal to the maximum shortcuts allowed, drop the last item.
+ if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
+ shortcuts.removeLast()
+ }
+ // Reverse the list so we can append our new shortcut at the 'end'.
+ shortcuts.reverse()
+ shortcuts.add(shortcut)
+ // Reverse it again, so the previous items are now in the correct order and our new item
+ // is at the front like it's supposed to.
+ shortcuts.reverse()
+ // Write back the new shortcuts.
+ shortcutManager.dynamicShortcuts = shortcuts
+ }
+
+ private fun validateState(): Boolean {
+ if (!PasswordRepository.isInitialized) {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getString(R.string.creation_dialog_text))
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ .show()
+ return false
+ }
+ return true
+ }
+
+ fun createPassword() {
+ if (!validateState()) return
+ val currentDir = currentDir
+ i { "Adding file to : ${currentDir.absolutePath}" }
+ val intent = Intent(this, PasswordCreationActivity::class.java)
+ intent.putExtra("FILE_PATH", currentDir.absolutePath)
+ intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath)
+ listRefreshAction.launch(intent)
+ }
+
+ fun createFolder() {
+ if (!validateState()) return
+ FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null)
+ }
+
+ fun deletePasswords(selectedItems: List<PasswordItem>) {
+ var size = 0
+ selectedItems.forEach { if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size }
+ if (size == 0) {
+ selectedItems.map { item -> item.file.deleteRecursively() }
+ refreshPasswordList()
+ return
+ }
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
+ .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
+ val filesToDelete = arrayListOf<File>()
+ selectedItems.forEach { item ->
+ if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
+ else filesToDelete.add(item.file)
}
- if (size == 0) {
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- return
+ selectedItems.map { item -> item.file.deleteRecursively() }
+ refreshPasswordList()
+ AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
+ val fmt =
+ selectedItems.joinToString(separator = ", ") { item ->
+ item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
+ }
+ lifecycleScope.launch {
+ commitChange(
+ resources.getString(R.string.git_commit_remove_text, fmt),
+ )
}
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
- .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
- val filesToDelete = arrayListOf<File>()
- selectedItems.forEach { item ->
- if (item.file.isDirectory)
- filesToDelete.addAll(item.file.listFilesRecursively())
- else
- filesToDelete.add(item.file)
- }
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
- val fmt = selectedItems.joinToString(separator = ", ") { item ->
- item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
+ }
+ .setNegativeButton(resources.getString(R.string.dialog_no), null)
+ .show()
+ }
+
+ fun movePasswords(values: List<PasswordItem>) {
+ val intent = Intent(this, SelectFolderActivity::class.java)
+ val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
+ intent.putExtra("Files", fileLocations)
+ passwordMoveAction.launch(intent)
+ }
+
+ enum class CategoryRenameError(val resource: Int) {
+ None(0),
+ EmptyField(R.string.message_category_error_empty_field),
+ CategoryExists(R.string.message_category_error_category_exists),
+ DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
+ }
+
+ /**
+ * Prompt the user with a new category name to assign, if the new category forms/leads a path
+ * (i.e. contains "/"), intermediate directories will be created and new category will be placed
+ * inside.
+ *
+ * @param oldCategory The category to change its name
+ * @param error Determines whether to show an error to the user in the alert dialog, this error
+ * may be due to the new category the user entered already exists or the field was empty or the
+ * destination path is outside the repository
+ *
+ * @see [CategoryRenameError]
+ * @see [isInsideRepository]
+ */
+ private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
+ val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
+ val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
+
+ if (error != CategoryRenameError.None) {
+ newCategoryEditText.error = getString(error.resource)
+ }
+
+ val dialog =
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.title_rename_folder)
+ .setView(view)
+ .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
+ when {
+ newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
+ newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
+ !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
+ else ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ moveFile(oldCategory.file, newCategory)
+
+ // associate the new category with the last category's timestamp in
+ // history
+ val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
+ if (timestamp != null) {
+ preference.edit {
+ remove(oldCategory.file.absolutePath.base64())
+ putString(newCategory.absolutePath.base64(), timestamp)
+ }
}
- lifecycleScope.launch {
- commitChange(
- resources.getString(R.string.git_commit_remove_text, fmt),
- )
- }
- }
- .setNegativeButton(resources.getString(R.string.dialog_no), null)
- .show()
- }
-
- fun movePasswords(values: List<PasswordItem>) {
- val intent = Intent(this, SelectFolderActivity::class.java)
- val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
- intent.putExtra("Files", fileLocations)
- passwordMoveAction.launch(intent)
- }
-
- enum class CategoryRenameError(val resource: Int) {
- None(0),
- EmptyField(R.string.message_category_error_empty_field),
- CategoryExists(R.string.message_category_error_category_exists),
- DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
- }
-
- /**
- * Prompt the user with a new category name to assign,
- * if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created
- * and new category will be placed inside.
- *
- * @param oldCategory The category to change its name
- * @param error Determines whether to show an error to the user in the alert dialog,
- * this error may be due to the new category the user entered already exists or the field was empty or the
- * destination path is outside the repository
- *
- * @see [CategoryRenameError]
- * @see [isInsideRepository]
- */
- private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
- val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
- val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
-
- if (error != CategoryRenameError.None) {
- newCategoryEditText.error = getString(error.resource)
- }
- val dialog = MaterialAlertDialogBuilder(this)
- .setTitle(R.string.title_rename_folder)
- .setView(view)
- .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
- when {
- newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
- newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
- !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
- else -> lifecycleScope.launch(Dispatchers.IO) {
- moveFile(oldCategory.file, newCategory)
-
- //associate the new category with the last category's timestamp in history
- val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
- if (timestamp != null) {
- preference.edit {
- remove(oldCategory.file.absolutePath.base64())
- putString(newCategory.absolutePath.base64(), timestamp)
- }
- }
-
- withContext(Dispatchers.Main) {
- commitChange(
- resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name),
- )
- }
- }
+ withContext(Dispatchers.Main) {
+ commitChange(
+ resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name),
+ )
}
- }
- .setNegativeButton(R.string.dialog_skip, null)
- .create()
-
- dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
- dialog.show()
- }
-
- fun renameCategory(categories: List<PasswordItem>) {
- for (oldCategory in categories) {
- renameCategory(oldCategory)
- }
- }
-
- /**
- * Refreshes the password list by re-executing the last navigation or search action, preserving
- * the navigation stack and scroll position. If the current directory no longer exists,
- * navigation is reset to the repository root. If the optional [target] argument is provided,
- * it will be entered if it is a directory or scrolled into view if it is a file (both inside
- * the current directory).
- */
- fun refreshPasswordList(target: File? = null) {
- val plist = getPasswordFragment()
- if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
- plist?.navigateTo(target)
- } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
- // Creating new passwords is handled by an activity, so we will refresh in onStart.
- plist?.scrollToOnNextRefresh(target)
- } else if (model.currentDir.value?.isDirectory == true) {
- model.forceRefresh()
- } else {
- model.reset()
- supportActionBar!!.setDisplayHomeAsUpEnabled(false)
- }
- }
-
- private val currentDir: File
- get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
-
- private suspend fun moveFile(source: File, destinationFile: File) {
- val sourceDestinationMap = if (source.isDirectory) {
- destinationFile.mkdirs()
- // Recursively list all files (not directories) below `source`, then
- // obtain the corresponding target file by resolving the relative path
- // starting at the destination folder.
- source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
- } else {
- mapOf(source to destinationFile)
- }
- if (!source.renameTo(destinationFile)) {
- e { "Something went wrong while moving $source to $destinationFile." }
- withContext(Dispatchers.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)
- }
- }
-
- fun matchPasswordWithApp(item: PasswordItem) {
- val path = item.file
- .absolutePath
- .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
- .replace(".gpg", "")
- val data = Intent()
- data.putExtra("path", path)
- setResult(RESULT_OK, data)
- finish()
- }
-
- companion object {
-
- // The max shortcut count from the system is set to 15 for some godforsaken reason, which
- // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
- // what most launchers seem to have agreed upon is the only reasonable solution.
- private const val MAX_SHORTCUT_COUNT = 4
- const val REQUEST_ARG_PATH = "PATH"
- private fun isPrintable(c: Char): Boolean {
- val block = UnicodeBlock.of(c)
- return (!Character.isISOControl(c) &&
- block != null && block !== UnicodeBlock.SPECIALS)
+ }
+ }
}
- }
+ .setNegativeButton(R.string.dialog_skip, null)
+ .create()
+
+ dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
+ dialog.show()
+ }
+
+ fun renameCategory(categories: List<PasswordItem>) {
+ for (oldCategory in categories) {
+ renameCategory(oldCategory)
+ }
+ }
+
+ /**
+ * Refreshes the password list by re-executing the last navigation or search action, preserving
+ * the navigation stack and scroll position. If the current directory no longer exists, navigation
+ * is reset to the repository root. If the optional [target] argument is provided, it will be
+ * entered if it is a directory or scrolled into view if it is a file (both inside the current
+ * directory).
+ */
+ fun refreshPasswordList(target: File? = null) {
+ val plist = getPasswordFragment()
+ if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
+ plist?.navigateTo(target)
+ } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
+ // Creating new passwords is handled by an activity, so we will refresh in onStart.
+ plist?.scrollToOnNextRefresh(target)
+ } else if (model.currentDir.value?.isDirectory == true) {
+ model.forceRefresh()
+ } else {
+ model.reset()
+ supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+ }
+ }
+
+ private val currentDir: File
+ get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
+
+ private suspend fun moveFile(source: File, destinationFile: File) {
+ val sourceDestinationMap =
+ if (source.isDirectory) {
+ destinationFile.mkdirs()
+ // Recursively list all files (not directories) below `source`, then
+ // obtain the corresponding target file by resolving the relative path
+ // starting at the destination folder.
+ source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destinationFile)
+ }
+ if (!source.renameTo(destinationFile)) {
+ e { "Something went wrong while moving $source to $destinationFile." }
+ withContext(Dispatchers.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)
+ }
+ }
+
+ fun matchPasswordWithApp(item: PasswordItem) {
+ val path =
+ item
+ .file
+ .absolutePath
+ .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
+ .replace(".gpg", "")
+ val data = Intent()
+ data.putExtra("path", path)
+ setResult(RESULT_OK, data)
+ finish()
+ }
+
+ companion object {
+
+ // The max shortcut count from the system is set to 15 for some godforsaken reason, which
+ // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
+ // what most launchers seem to have agreed upon is the only reasonable solution.
+ private const val MAX_SHORTCUT_COUNT = 4
+ const val REQUEST_ARG_PATH = "PATH"
+ private fun isPrintable(c: Char): Boolean {
+ val block = UnicodeBlock.of(c)
+ return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS)
+ }
+ }
}