From 14c44bf584cb7d13ada46b264419efca923ed65c Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Wed, 5 Aug 2020 19:02:24 +0530 Subject: Remove GitAsyncTask and replace with non-blocking coroutines (#865) Co-authored-by: Fabian Henneke --- .../main/java/com/zeapo/pwdstore/Application.kt | 6 + .../java/com/zeapo/pwdstore/PasswordFragment.kt | 2 - .../main/java/com/zeapo/pwdstore/PasswordStore.kt | 41 +++- .../com/zeapo/pwdstore/SelectFolderFragment.kt | 1 - .../autofill/oreo/ui/AutofillFilterActivity.kt | 1 - .../autofill/oreo/ui/AutofillSaveActivity.kt | 12 +- .../pwdstore/crypto/PasswordCreationActivity.kt | 27 ++- .../java/com/zeapo/pwdstore/git/BaseGitActivity.kt | 25 ++- .../com/zeapo/pwdstore/git/BreakOutOfDetached.kt | 90 -------- .../java/com/zeapo/pwdstore/git/CloneOperation.kt | 53 ----- .../java/com/zeapo/pwdstore/git/ErrorMessages.kt | 75 +++++++ .../java/com/zeapo/pwdstore/git/GitAsyncTask.kt | 176 --------------- .../com/zeapo/pwdstore/git/GitCommandExecutor.kt | 164 ++++++++++++++ .../com/zeapo/pwdstore/git/GitConfigActivity.kt | 6 +- .../java/com/zeapo/pwdstore/git/GitOperation.kt | 250 --------------------- .../com/zeapo/pwdstore/git/GitOperationActivity.kt | 10 +- .../zeapo/pwdstore/git/GitServerConfigActivity.kt | 6 +- .../java/com/zeapo/pwdstore/git/PullOperation.kt | 52 ----- .../java/com/zeapo/pwdstore/git/PushOperation.kt | 50 ----- .../zeapo/pwdstore/git/ResetToRemoteOperation.kt | 69 ------ .../java/com/zeapo/pwdstore/git/SyncOperation.kt | 67 ------ .../pwdstore/git/operation/BreakOutOfDetached.kt | 52 +++++ .../zeapo/pwdstore/git/operation/CloneOperation.kt | 29 +++ .../pwdstore/git/operation/CredentialFinder.kt | 94 ++++++++ .../zeapo/pwdstore/git/operation/GitOperation.kt | 187 +++++++++++++++ .../zeapo/pwdstore/git/operation/PullOperation.kt | 28 +++ .../zeapo/pwdstore/git/operation/PushOperation.kt | 29 +++ .../git/operation/ResetToRemoteOperation.kt | 30 +++ .../zeapo/pwdstore/git/operation/SyncOperation.kt | 30 +++ .../java/com/zeapo/pwdstore/utils/Extensions.kt | 50 +++-- .../pwdstore/utils/FragmentViewBindingDelegate.kt | 1 - .../main/java/com/zeapo/pwdstore/utils/Result.kt | 16 ++ .../java/com/zeapo/pwdstore/utils/UriTotpFinder.kt | 1 + app/src/main/res/values/strings.xml | 12 +- 34 files changed, 862 insertions(+), 880 deletions(-) delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt create mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/Result.kt (limited to 'app') diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt index 3ccf37fe..611ccfe0 100644 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ b/app/src/main/java/com/zeapo/pwdstore/Application.kt @@ -23,6 +23,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere override fun onCreate() { super.onCreate() + instance = this prefs = PreferenceManager.getDefaultSharedPreferences(this) if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs?.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false) == true) { @@ -52,4 +53,9 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere else -> MODE_NIGHT_AUTO_BATTERY }) } + + companion object { + + lateinit var instance: Application + } } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index 1cd7c1ea..355d4578 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -18,8 +18,6 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo import androidx.appcompat.view.ActionMode import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.observe import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index 57e636d1..a4ada3c5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -34,7 +34,6 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.observe import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.d import com.github.ajalt.timberkt.e @@ -581,7 +580,12 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { intent.putExtra("REPO_PATH", getRepositoryDirectory(applicationContext).absolutePath) registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { - commitChange(resources.getString(R.string.git_commit_add_text, result.data?.extras?.getString("LONG_NAME"))) + lifecycleScope.launch { + commitChange( + resources.getString(R.string.git_commit_add_text, result.data?.extras?.getString("LONG_NAME")), + finishActivityOnEnd = false, + ) + } refreshPasswordList() } }.launch(intent) @@ -618,11 +622,15 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { selectedItems.map { item -> item.file.deleteRecursively() } refreshPasswordList() AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) - commitChange(resources.getString(R.string.git_commit_remove_text, - selectedItems.joinToString(separator = ", ") { item -> - item.file.toRelativeString(getRepositoryDirectory(this)) - } - )) + val fmt = selectedItems.joinToString(separator = ", ") { item -> + item.file.toRelativeString(getRepositoryDirectory(this@PasswordStore)) + } + lifecycleScope.launch { + commitChange( + resources.getString(R.string.git_commit_remove_text, fmt), + finishActivityOnEnd = false, + ) + } } .setNegativeButton(resources.getString(R.string.dialog_no), null) .show() @@ -688,14 +696,20 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { 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)) + commitChange( + resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName), + finishActivityOnEnd = false, + ) } } else -> { + val repoDir = getRepositoryDirectory(applicationContext).absolutePath + val relativePath = getRelativePath("${target.absolutePath}/", repoDir) withContext(Dispatchers.Main) { - commitChange(resources.getString(R.string.git_commit_move_multiple_text, - getRelativePath("${target.absolutePath}/", getRepositoryDirectory(applicationContext).absolutePath) - )) + commitChange( + resources.getString(R.string.git_commit_move_multiple_text, relativePath), + finishActivityOnEnd = false, + ) } } } @@ -746,7 +760,10 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) { else -> lifecycleScope.launch(Dispatchers.IO) { moveFile(oldCategory.file, newCategory) withContext(Dispatchers.Main) { - commitChange(resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name)) + commitChange( + resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name), + finishActivityOnEnd = false, + ) } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt b/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt index 2fd8c94c..7bc32211 100644 --- a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt @@ -10,7 +10,6 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager import com.zeapo.pwdstore.databinding.PasswordRecyclerViewBinding import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index a1d2fa59..7e29b061 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -22,7 +22,6 @@ import androidx.core.text.buildSpannedString import androidx.core.text.underline import androidx.core.widget.addTextChangedListener import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.observe import androidx.recyclerview.widget.LinearLayoutManager import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.FilterMode diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt index 4661716f..745d2d1e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.result.contract.ActivityResultContracts.StartActivityFo import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf +import androidx.lifecycle.lifecycleScope import com.github.ajalt.timberkt.e import com.zeapo.pwdstore.R import com.zeapo.pwdstore.autofill.oreo.AutofillAction @@ -27,6 +28,7 @@ import com.zeapo.pwdstore.crypto.PasswordCreationActivity import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.commitChange import java.io.File +import kotlinx.coroutines.launch @RequiresApi(Build.VERSION_CODES.O) class AutofillSaveActivity : AppCompatActivity() { @@ -144,10 +146,12 @@ class AutofillSaveActivity : AppCompatActivity() { } // PasswordCreationActivity delegates committing the added file to PasswordStore. Since // PasswordStore is not involved in an AutofillScenario, we have to commit the file ourselves. - commitChange( - getString(R.string.git_commit_add_text, longName), - finishWithResultOnEnd = resultIntent - ) + lifecycleScope.launch { + commitChange( + getString(R.string.git_commit_add_text, longName), + finishWithResultOnEnd = resultIntent + ) + } // GitAsyncTask will finish the activity for us. } }.launch(saveIntent) diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt index a05da554..d46f0a4e 100644 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -329,12 +329,14 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB gpgIdentifierFile.writeText(keyIds.joinToString("\n")) val repo = PasswordRepository.getRepository(null) if (repo != null) { - commitChange( - getString( - R.string.git_commit_gpg_id, - getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + lifecycleScope.launch { + commitChange( + getString( + R.string.git_commit_gpg_id, + getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + ) ) - ) + } } encrypt(data) } @@ -422,7 +424,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB AutofillPreferences.directoryStructure(applicationContext) val entry = PasswordEntry(content) returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = entry.username ?: directoryStructure.getUsernameFor(file) + val username = entry.username + ?: directoryStructure.getUsernameFor(file) returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) } @@ -430,12 +433,14 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB if (repo != null) { val status = Git(repo).status().call() if (status.modified.isNotEmpty()) { - commitChange( - getString( - R.string.git_commit_edit_text, - getLongName(fullPath, repoPath, editName) + lifecycleScope.launch { + commitChange( + getString( + R.string.git_commit_edit_text, + getLongName(fullPath, repoPath, editName) + ) ) - ) + } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt index 7a818bcf..706592fe 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -12,6 +12,7 @@ import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import androidx.core.text.isDigitsOnly +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.github.ajalt.timberkt.Timber.tag import com.github.ajalt.timberkt.e @@ -20,11 +21,19 @@ import com.zeapo.pwdstore.R import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.Protocol import com.zeapo.pwdstore.git.config.SshApiSessionFactory +import com.zeapo.pwdstore.git.operation.BreakOutOfDetached +import com.zeapo.pwdstore.git.operation.CloneOperation +import com.zeapo.pwdstore.git.operation.GitOperation +import com.zeapo.pwdstore.git.operation.PullOperation +import com.zeapo.pwdstore.git.operation.PushOperation +import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation +import com.zeapo.pwdstore.git.operation.SyncOperation import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.getEncryptedPrefs import java.io.File import java.net.URI +import kotlinx.coroutines.launch /** * Abstract AppCompatActivity that holds some information that is commonly shared across git-related @@ -166,7 +175,7 @@ abstract class BaseGitActivity : AppCompatActivity() { * * @param operation The type of git operation to launch */ - fun launchGitOperation(operation: Int) { + suspend fun launchGitOperation(operation: Int) { if (url == null) { setResult(RESULT_CANCELED) finish() @@ -190,12 +199,12 @@ abstract class BaseGitActivity : AppCompatActivity() { val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(this)) val op = when (operation) { - REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> CloneOperation(localDir, this).setCommand(url!!) - REQUEST_PULL -> PullOperation(localDir, this).setCommand() - REQUEST_PUSH -> PushOperation(localDir, this).setCommand() - REQUEST_SYNC -> SyncOperation(localDir, this).setCommands() - BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(localDir, this).setCommands() - REQUEST_RESET -> ResetToRemoteOperation(localDir, this).setCommands() + REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> CloneOperation(localDir, url!!, this) + REQUEST_PULL -> PullOperation(localDir, this) + REQUEST_PUSH -> PushOperation(localDir, this) + REQUEST_SYNC -> SyncOperation(localDir, this) + BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(localDir, this) + REQUEST_RESET -> ResetToRemoteOperation(localDir, this) SshApiSessionFactory.POST_SIGNATURE -> return else -> { tag(TAG).e { "Operation not recognized : $operation" } @@ -239,7 +248,7 @@ abstract class BaseGitActivity : AppCompatActivity() { if (identityBuilder != null) { identityBuilder!!.consume(data) } - launchGitOperation(requestCode) + lifecycleScope.launch { launchGitOperation(requestCode) } } super.onActivityResult(requestCode, resultCode, data) } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt deleted file mode 100644 index 149cabd8..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/BreakOutOfDetached.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.RebaseCommand - -class BreakOutOfDetached(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - private lateinit var commands: List> - private val gitBranch = PreferenceManager - .getDefaultSharedPreferences(callingActivity.applicationContext) - .getString(PreferenceKeys.GIT_BRANCH_NAME, "master") - - /** - * Sets the command - * - * @return the current object - */ - fun setCommands(): BreakOutOfDetached { - val git = Git(repository) - val branchName = "conflicting-$gitBranch-${System.currentTimeMillis()}" - - this.commands = listOf( - // abort the rebase - git.rebase().setOperation(RebaseCommand.Operation.ABORT), - // git checkout -b conflict-branch - git.checkout().setCreateBranch(true).setName(branchName), - // push the changes - git.push().setRemote("origin"), - // switch back to ${gitBranch} - git.checkout().setName(gitBranch) - ) - return this - } - - override fun execute() { - val git = Git(repository) - if (!git.repository.repositoryState.isRebasing) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage("The repository is not rebasing, no need to push to another branch") - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - }.show() - return - } - - if (this.provider != null) { - // set the credentials for push command - this.commands.forEach { cmd -> - if (cmd is PushCommand) { - cmd.setCredentialsProvider(this.provider) - } - } - } - GitAsyncTask(callingActivity, this, null) - .execute(*this.commands.toTypedArray()) - } - - override fun onError(err: Exception) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred when checking out another branch operation ${err.message}") - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - }.show() - } - - override fun onSuccess() { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage("There was a conflict when trying to rebase. " + - "Your local $gitBranch branch was pushed to another branch named conflicting-$gitBranch-....\n" + - "Use this branch to resolve conflict on your computer") - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - }.show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt deleted file mode 100644 index 8817967b..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/CloneOperation.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import java.io.File -import org.eclipse.jgit.api.CloneCommand -import org.eclipse.jgit.api.Git - -/** - * Creates a new clone operation - * - * @param fileDir the git working tree directory - * @param callingActivity the calling activity - */ -class CloneOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - /** - * Sets the command using the repository uri - * - * @param uri the uri of the repository - * @return the current object - */ - fun setCommand(uri: String): CloneOperation { - this.command = Git.cloneRepository() - .setCloneAllBranches(true) - .setDirectory(repository?.workTree) - .setURI(uri) - return this - } - - override fun execute() { - (this.command as? CloneCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, this, Intent()).execute(this.command) - } - - override fun onError(err: Exception) { - super.onError(err) - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred during the clone operation, " + - callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - err.message + - "\nPlease check the FAQ for possible reasons why this error might occur.") - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> } - .show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt new file mode 100644 index 00000000..dfc786b9 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt @@ -0,0 +1,75 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.git + +import android.os.RemoteException +import com.zeapo.pwdstore.Application +import com.zeapo.pwdstore.R + +/** + * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. + */ +sealed class GitException(message: String? = null) : Exception(message) { + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. + */ + class PullException(val reason: Reason) : GitException() { + + enum class Reason { + REBASE_FAILED, + } + } + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. + */ + class PushException(val reason: Reason, vararg val fmt: String) : GitException() { + enum class Reason { + NON_FAST_FORWARD, + REMOTE_REJECTED, + GENERIC, + } + } +} + +object ErrorMessages { + + private val PULL_REASON_MAP = mapOf( + GitException.PullException.Reason.REBASE_FAILED to R.string.git_pull_fail_error, + ) + + private val PUSH_REASON_MAP = mapOf( + GitException.PushException.Reason.NON_FAST_FORWARD to R.string.git_push_nff_error, + GitException.PushException.Reason.REMOTE_REJECTED to R.string.git_push_other_error, + GitException.PushException.Reason.GENERIC to R.string.git_push_generic_error, + ) + + operator fun get(throwable: Throwable?): String { + val resources = Application.instance.resources + if (throwable == null) return resources.getString(R.string.git_unknown_error) + return when (val rootCause = rootCause(throwable)) { + is GitException.PullException -> { + resources.getString(PULL_REASON_MAP.getValue(rootCause.reason)) + } + is GitException.PushException -> { + resources.getString(PUSH_REASON_MAP.getValue(rootCause.reason), *rootCause.fmt) + } + else -> throwable.message ?: resources.getString(R.string.git_unknown_error) + } + } + + private fun rootCause(throwable: Throwable): Throwable { + var cause = throwable + while (cause.cause != null) { + if (cause is GitException) break + val nextCause = cause.cause!! + if (nextCause is RemoteException) break + cause = nextCause + } + return cause + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt deleted file mode 100644 index b76c98f6..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitAsyncTask.kt +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.app.ProgressDialog -import android.content.Context -import android.content.Intent -import android.os.AsyncTask -import androidx.appcompat.app.AppCompatActivity -import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.SshjSessionFactory -import java.io.IOException -import java.lang.ref.WeakReference -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.UserAuthException -import org.eclipse.jgit.api.CommitCommand -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.PullCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.RebaseResult -import org.eclipse.jgit.api.StatusCommand -import org.eclipse.jgit.transport.RemoteRefUpdate -import org.eclipse.jgit.transport.SshSessionFactory - - -class GitAsyncTask( - activity: AppCompatActivity, - private val operation: GitOperation, - private val finishWithResultOnEnd: Intent?, - private val silentlyExecute: Boolean = false -) : AsyncTask, Int, GitAsyncTask.Result>() { - - private val activityWeakReference: WeakReference = WeakReference(activity) - private val activity: AppCompatActivity? - get() = activityWeakReference.get() - private val context: Context = activity.applicationContext - private val dialog = ProgressDialog(activity) - - sealed class Result { - object Ok : Result() - data class Err(val err: Exception) : Result() - } - - override fun onPreExecute() { - if (silentlyExecute) return - dialog.run { - setMessage(activity!!.resources.getString(R.string.running_dialog_text)) - setCancelable(false) - show() - } - } - - override fun doInBackground(vararg commands: GitCommand<*>): Result? { - var nbChanges: Int? = null - for (command in commands) { - try { - when (command) { - is StatusCommand -> { - // in case we have changes, we want to keep track of it - val status = command.call() - nbChanges = status.changed.size + status.missing.size - } - is CommitCommand -> { - // the previous status will eventually be used to avoid a commit - if (nbChanges == null || nbChanges > 0) command.call() - } - is PullCommand -> { - val result = command.call() - val rr = result.rebaseResult - if (rr.status === RebaseResult.Status.STOPPED) { - return Result.Err(IOException(context.getString(R.string - .git_pull_fail_error))) - } - } - is PushCommand -> { - for (result in command.call()) { - // Code imported (modified) from Gerrit PushOp, license Apache v2 - for (rru in result.remoteUpdates) { - val error = when (rru.status) { - RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> - context.getString(R.string.git_push_nff_error) - RemoteRefUpdate.Status.REJECTED_NODELETE, - RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, - RemoteRefUpdate.Status.NON_EXISTING, - RemoteRefUpdate.Status.NOT_ATTEMPTED - -> - (activity!!.getString(R.string.git_push_generic_error) + rru.status.name) - RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { - if - ("non-fast-forward" == rru.message) { - context.getString(R.string.git_push_other_error) - } else { - (context.getString(R.string.git_push_generic_error) - + rru.message) - } - } - else -> null - - } - if (error != null) - Result.Err(IOException(error)) - } - } - } - else -> { - command.call() - } - } - } catch (e: Exception) { - return Result.Err(e) - } - } - return Result.Ok - } - - private fun rootCauseException(e: Exception): Exception { - var rootCause = e - // JGit's TransportException hides the more helpful SSHJ exceptions. - // Also, SSHJ's UserAuthException about exhausting available authentication methods hides - // more useful exceptions. - while ((rootCause is org.eclipse.jgit.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.TransportException || - (rootCause is UserAuthException && - rootCause.message == "Exhausted available authentication methods"))) { - rootCause = rootCause.cause as? Exception ?: break - } - return rootCause - } - - private fun isExplicitlyUserInitiatedError(e: Exception): Boolean { - var cause: Exception? = e - while (cause != null) { - if (cause is SSHException && - cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) - return true - cause = cause.cause as? Exception - } - return false - } - - override fun onPostExecute(maybeResult: Result?) { - if (!silentlyExecute) dialog.dismiss() - when (val result = maybeResult ?: Result.Err(IOException("Unexpected error"))) { - is Result.Err -> { - if (isExplicitlyUserInitiatedError(result.err)) { - // Currently, this is only executed when the user cancels a password prompt - // during authentication. - if (finishWithResultOnEnd != null) { - activity?.setResult(AppCompatActivity.RESULT_CANCELED) - activity?.finish() - } - } else { - e(result.err) - operation.onError(rootCauseException(result.err)) - if (finishWithResultOnEnd != null) { - activity?.setResult(AppCompatActivity.RESULT_CANCELED) - } - } - } - is Result.Ok -> { - operation.onSuccess() - if (finishWithResultOnEnd != null) { - activity?.setResult(AppCompatActivity.RESULT_OK, finishWithResultOnEnd) - activity?.finish() - } - } - } - (SshSessionFactory.getInstance() as? SshjSessionFactory)?.clearCredentials() - SshSessionFactory.setInstance(null) - } - -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt new file mode 100644 index 00000000..11b06b8e --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt @@ -0,0 +1,164 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.git + +import android.app.Activity +import android.content.Intent +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.e +import com.google.android.material.snackbar.Snackbar +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.git.GitException.PullException +import com.zeapo.pwdstore.git.GitException.PushException +import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.git.operation.GitOperation +import com.zeapo.pwdstore.utils.Result +import com.zeapo.pwdstore.utils.snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.UserAuthException +import org.eclipse.jgit.api.CommitCommand +import org.eclipse.jgit.api.PullCommand +import org.eclipse.jgit.api.PushCommand +import org.eclipse.jgit.api.RebaseResult +import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.transport.RemoteRefUpdate +import org.eclipse.jgit.transport.SshSessionFactory + +class GitCommandExecutor( + private val activity: FragmentActivity, + private val operation: GitOperation, + private val finishWithResultOnEnd: Intent? = Intent(), + private val finishActivityOnEnd: Boolean = true, +) { + + suspend fun execute() { + operation.setCredentialProvider() + val snackbar = activity.snackbar( + message = activity.resources.getString(R.string.git_operation_running), + length = Snackbar.LENGTH_INDEFINITE, + ) + var nbChanges = 0 + var operationResult: Result = Result.Ok + for (command in operation.commands) { + try { + when (command) { + is StatusCommand -> { + // in case we have changes, we want to keep track of it + val status = withContext(Dispatchers.IO) { + command.call() + } + nbChanges = status.changed.size + status.missing.size + } + is CommitCommand -> { + // the previous status will eventually be used to avoid a commit + withContext(Dispatchers.IO) { + if (nbChanges > 0) command.call() + } + } + is PullCommand -> { + val result = withContext(Dispatchers.IO) { + command.call() + } + val rr = result.rebaseResult + if (rr.status === RebaseResult.Status.STOPPED) { + operationResult = Result.Err(PullException(PullException.Reason.REBASE_FAILED)) + } + } + is PushCommand -> { + val results = withContext(Dispatchers.IO) { + command.call() + } + for (result in results) { + // Code imported (modified) from Gerrit PushOp, license Apache v2 + for (rru in result.remoteUpdates) { + val error = when (rru.status) { + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> { + PushException(PushException.Reason.NON_FAST_FORWARD) + } + RemoteRefUpdate.Status.REJECTED_NODELETE, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NON_EXISTING, + RemoteRefUpdate.Status.NOT_ATTEMPTED, + -> { + PushException(PushException.Reason.GENERIC, rru.status.name) + } + RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { + if ("non-fast-forward" == rru.message) { + PushException(PushException.Reason.REMOTE_REJECTED) + } else { + PushException(PushException.Reason.GENERIC, rru.message) + } + } + else -> null + + } + if (error != null) { + operationResult = Result.Err(error) + } + } + } + } + else -> { + withContext(Dispatchers.IO) { + command.call() + } + } + } + } catch (e: Exception) { + operationResult = Result.Err(e) + } + } + when (operationResult) { + is Result.Err -> { + activity.setResult(Activity.RESULT_CANCELED) + if (isExplicitlyUserInitiatedError(operationResult.err)) { + // Currently, this is only executed when the user cancels a password prompt + // during authentication. + if (finishActivityOnEnd) activity.finish() + } else { + e(operationResult.err) + operation.onError(rootCauseException(operationResult.err)) + } + } + is Result.Ok -> { + operation.onSuccess() + activity.setResult(Activity.RESULT_OK, finishWithResultOnEnd) + if (finishActivityOnEnd) activity.finish() + } + } + snackbar.dismiss() + (SshSessionFactory.getInstance() as? SshjSessionFactory)?.clearCredentials() + SshSessionFactory.setInstance(null) + } + + private fun isExplicitlyUserInitiatedError(e: Exception): Boolean { + var cause: Exception? = e + while (cause != null) { + if (cause is SSHException && + cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) + return true + cause = cause.cause as? Exception + } + return false + } + + private fun rootCauseException(e: Exception): Exception { + var rootCause = e + // JGit's TransportException hides the more helpful SSHJ exceptions. + // Also, SSHJ's UserAuthException about exhausting available authentication methods hides + // more useful exceptions. + while ((rootCause is org.eclipse.jgit.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.TransportException || + (rootCause is UserAuthException && + rootCause.message == "Exhausted available authentication methods"))) { + rootCause = rootCause.cause as? Exception ?: break + } + return rootCause + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt index 0498f6ed..35a3c648 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt @@ -9,6 +9,7 @@ import android.os.Handler import android.util.Patterns import androidx.core.content.edit import androidx.core.os.postDelayed +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R @@ -16,6 +17,7 @@ import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding +import kotlinx.coroutines.launch import org.eclipse.jgit.lib.Constants class GitConfigActivity : BaseGitActivity() { @@ -47,8 +49,8 @@ class GitConfigActivity : BaseGitActivity() { } catch (ignored: Exception) { } } - binding.gitAbortRebase.setOnClickListener { launchGitOperation(BREAK_OUT_OF_DETACHED) } - binding.gitResetToRemote.setOnClickListener { launchGitOperation(REQUEST_RESET) } + binding.gitAbortRebase.setOnClickListener { lifecycleScope.launch { launchGitOperation(BREAK_OUT_OF_DETACHED) } } + binding.gitResetToRemote.setOnClickListener { lifecycleScope.launch { launchGitOperation(REQUEST_RESET) } } binding.saveButton.setOnClickListener { val email = binding.gitUserEmail.text.toString().trim() val name = binding.gitUserName.text.toString().trim() diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt deleted file mode 100644 index 90f72b7c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.annotation.SuppressLint -import android.content.Intent -import android.view.LayoutInflater -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.git.config.ConnectionMode -import com.zeapo.pwdstore.git.config.InteractivePasswordFinder -import com.zeapo.pwdstore.git.config.SshApiSessionFactory -import com.zeapo.pwdstore.git.config.SshAuthData -import com.zeapo.pwdstore.git.config.SshjSessionFactory -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedPrefs -import com.zeapo.pwdstore.utils.requestInputFocusOnView -import java.io.File -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import net.schmizz.sshj.userauth.password.PasswordFinder -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.errors.UnsupportedCredentialItem -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshSessionFactory -import org.eclipse.jgit.transport.URIish - - -private class GitOperationCredentialFinder( - val callingActivity: AppCompatActivity, - val connectionMode: ConnectionMode -) : InteractivePasswordFinder() { - - override fun askForPassword(cont: Continuation, isRetry: Boolean) { - val gitOperationPrefs = callingActivity.getEncryptedPrefs("git_operation") - val credentialPref: String - @StringRes val messageRes: Int - @StringRes val hintRes: Int - @StringRes val rememberRes: Int - @StringRes val errorRes: Int - when (connectionMode) { - ConnectionMode.SshKey -> { - credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE - messageRes = R.string.passphrase_dialog_text - hintRes = R.string.ssh_keygen_passphrase - rememberRes = R.string.git_operation_remember_passphrase - errorRes = R.string.git_operation_wrong_passphrase - } - ConnectionMode.Password -> { - // Could be either an SSH or an HTTPS password - credentialPref = PreferenceKeys.HTTPS_PASSWORD - messageRes = R.string.password_dialog_text - hintRes = R.string.git_operation_hint_password - rememberRes = R.string.git_operation_remember_password - errorRes = R.string.git_operation_wrong_password - } - else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") - } - val storedCredential = gitOperationPrefs.getString(credentialPref, null) - if (isRetry) - gitOperationPrefs.edit { remove(credentialPref) } - if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = dialogView.findViewById(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = dialogView.findViewById(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) - credentialLayout.error = callingActivity.resources.getString(errorRes) - MaterialAlertDialogBuilder(callingActivity).run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { - putString(credentialPref, credential) - } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> - cont.resume(null) - } - setOnCancelListener { - cont.resume(null) - } - create() - }.run { - requestInputFocusOnView(R.id.git_auth_credential) - show() - } - } else { - cont.resume(storedCredential) - } - } -} - -/** - * Creates a new git operation - * - * @param gitDir the git working tree directory - * @param callingActivity the calling activity - */ -abstract class GitOperation(gitDir: File, internal val callingActivity: AppCompatActivity) { - - protected val repository: Repository? = PasswordRepository.getRepository(gitDir) - internal var provider: CredentialsProvider? = null - internal var command: GitCommand<*>? = null - private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key") - private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") - - private class PasswordFinderCredentialsProvider(private val username: String, private val passwordFinder: PasswordFinder) : CredentialsProvider() { - - override fun isInteractive() = true - - override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { - for (item in items) { - when (item) { - is CredentialItem.Username -> item.value = username - is CredentialItem.Password -> item.value = passwordFinder.reqPassword(null) - else -> UnsupportedCredentialItem(uri, item.javaClass.name) - } - } - return true - } - - override fun supports(vararg items: CredentialItem) = items.all { - it is CredentialItem.Username || it is CredentialItem.Password - } - } - - private fun withPasswordAuthentication(username: String, passwordFinder: InteractivePasswordFinder): GitOperation { - val sessionFactory = SshjSessionFactory(username, SshAuthData.Password(passwordFinder), hostKeyFile) - SshSessionFactory.setInstance(sessionFactory) - this.provider = PasswordFinderCredentialsProvider(username, passwordFinder) - return this - } - - private fun withPublicKeyAuthentication(username: String, passphraseFinder: InteractivePasswordFinder): GitOperation { - val sessionFactory = SshjSessionFactory(username, SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile) - SshSessionFactory.setInstance(sessionFactory) - this.provider = null - return this - } - - private fun withOpenKeychainAuthentication(username: String, identity: SshApiSessionFactory.ApiIdentity?): GitOperation { - SshSessionFactory.setInstance(SshApiSessionFactory(username, identity)) - this.provider = null - return this - } - - private fun getSshKey(make: Boolean) { - try { - // Ask the UserPreference to provide us with the ssh-key - // onResult has to be handled by the callingActivity - val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) - intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") - callingActivity.startActivityForResult(intent, GET_SSH_KEY_FROM_CLONE) - } catch (e: Exception) { - println("Exception caught :(") - e.printStackTrace() - } - } - - /** - * Executes the GitCommand in an async task - */ - abstract fun execute() - - fun executeAfterAuthentication( - connectionMode: ConnectionMode, - username: String, - identity: SshApiSessionFactory.ApiIdentity? - ) { - when (connectionMode) { - ConnectionMode.SshKey -> if (!sshKeyFile.exists()) { - MaterialAlertDialogBuilder(callingActivity) - .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) - .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) - .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> - getSshKey(false) - } - .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> - getSshKey(true) - } - .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Finish the blank GitActivity so user doesn't have to press back - callingActivity.finish() - }.show() - } else { - withPublicKeyAuthentication(username, GitOperationCredentialFinder(callingActivity, - connectionMode)).execute() - } - ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(username, identity).execute() - ConnectionMode.Password -> withPasswordAuthentication( - username, GitOperationCredentialFinder(callingActivity, connectionMode)).execute() - ConnectionMode.None -> execute() - } - } - - /** - * Action to execute on error - */ - open fun onError(err: Exception) { - // Clear various auth related fields on failure - when (SshSessionFactory.getInstance()) { - is SshApiSessionFactory -> { - PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - } - is SshjSessionFactory -> { - callingActivity.applicationContext - .getEncryptedPrefs("git_operation") - .edit { - remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - remove(PreferenceKeys.HTTPS_PASSWORD) - } - } - } - } - - /** - * Action to execute on success - */ - open fun onSuccess() {} - - companion object { - - const val GET_SSH_KEY_FROM_CLONE = 201 - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt index c3a46fd7..121a3402 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt @@ -8,19 +8,21 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference import com.zeapo.pwdstore.utils.PasswordRepository +import kotlinx.coroutines.launch open class GitOperationActivity : BaseGitActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) when (intent.extras?.getInt(REQUEST_ARG_OP)) { - REQUEST_PULL -> syncRepository(REQUEST_PULL) - REQUEST_PUSH -> syncRepository(REQUEST_PUSH) - REQUEST_SYNC -> syncRepository(REQUEST_SYNC) + REQUEST_PULL -> lifecycleScope.launch { syncRepository(REQUEST_PULL) } + REQUEST_PUSH -> lifecycleScope.launch { syncRepository(REQUEST_PUSH) } + REQUEST_SYNC -> lifecycleScope.launch { syncRepository(REQUEST_SYNC) } else -> { setResult(RESULT_CANCELED) finish() @@ -54,7 +56,7 @@ open class GitOperationActivity : BaseGitActivity() { * * @param operation the operation to execute can be REQUEST_PULL or REQUEST_PUSH */ - private fun syncRepository(operation: Int) { + private suspend fun syncRepository(operation: Int) { if (serverUser.isEmpty() || serverHostname.isEmpty() || url.isNullOrEmpty()) MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.set_information_dialog_text)) diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt index 6c87e0f6..4830b2c8 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt @@ -10,6 +10,7 @@ import android.view.View import androidx.core.content.edit import androidx.core.os.postDelayed import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.R @@ -20,6 +21,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.PreferenceKeys import com.zeapo.pwdstore.utils.viewBinding import java.io.IOException +import kotlinx.coroutines.launch /** * Activity that encompasses both the initial clone as well as editing the server config for future @@ -171,7 +173,7 @@ class GitServerConfigActivity : BaseGitActivity() { .setPositiveButton(R.string.dialog_delete) { dialog, _ -> try { localDir.deleteRecursively() - launchGitOperation(REQUEST_CLONE) + lifecycleScope.launch { launchGitOperation(REQUEST_CLONE) } } catch (e: IOException) { // TODO Handle the exception correctly if we are unable to delete the directory... e.printStackTrace() @@ -201,7 +203,7 @@ class GitServerConfigActivity : BaseGitActivity() { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() } - launchGitOperation(REQUEST_CLONE) + lifecycleScope.launch { launchGitOperation(REQUEST_CLONE) } } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt deleted file mode 100644 index 0e36f46a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/PullOperation.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.PullCommand - -/** - * Creates a new git operation - * - * @param fileDir the git working tree directory - * @param callingActivity the calling activity - */ -class PullOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - /** - * Sets the command - * - * @return the current object - */ - fun setCommand(): PullOperation { - this.command = Git(repository) - .pull() - .setRebase(true) - .setRemote("origin") - return this - } - - override fun execute() { - (this.command as? PullCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, this, Intent()).execute(this.command) - } - - override fun onError(err: Exception) { - super.onError(err) - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred during the pull operation, " + - callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - err.message + - "\nPlease check the FAQ for possible reasons why this error might occur.") - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } - .show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt deleted file mode 100644 index 1d58b255..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/PushOperation.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.PushCommand - -/** - * Creates a new git operation - * - * @param fileDir the git working tree directory - * @param callingActivity the calling activity - */ -class PushOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - /** - * Sets the command - * - * @return the current object - */ - fun setCommand(): PushOperation { - this.command = Git(repository) - .push() - .setPushAll() - .setRemote("origin") - return this - } - - override fun execute() { - (this.command as? PushCommand)?.setCredentialsProvider(this.provider) - GitAsyncTask(callingActivity, this, Intent()).execute(this.command) - } - - override fun onError(err: Exception) { - // TODO handle the "Nothing to push" case - super.onError(err) - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage(callingActivity.getString(R.string.jgit_error_push_dialog_text) + err.message) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } - .show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt deleted file mode 100644 index 60a9fbf3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/ResetToRemoteOperation.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import java.io.File -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.ResetCommand -import org.eclipse.jgit.api.TransportCommand - -/** - * Creates a new git operation - * - * @param fileDir the git working tree directory - * @param callingActivity the calling activity - */ -class ResetToRemoteOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - private lateinit var commands: List> - - /** - * Sets the command - * - * @return the current object - */ - fun setCommands(): ResetToRemoteOperation { - val remoteBranch = PreferenceManager - .getDefaultSharedPreferences(callingActivity.applicationContext) - .getString(PreferenceKeys.GIT_BRANCH_NAME, "master") - val git = Git(repository) - val cmds = arrayListOf( - git.add().addFilepattern("."), - git.fetch().setRemote("origin"), - git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD) - ) - if (git.branchList().call().none { it.name == remoteBranch }) { - cmds.add( - git.branchCreate().setName(remoteBranch).setForce(true) - ) - } - commands = cmds - return this - } - - override fun execute() { - commands.filterIsInstance>().map { it.setCredentialsProvider(provider) } - GitAsyncTask(callingActivity, this, Intent()).execute(*commands.toTypedArray()) - } - - override fun onError(err: Exception) { - super.onError(err) - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred during the sync operation, " + - "\nPlease check the FAQ for possible reasons why this error might occur." + - callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - err) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> } - .show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt deleted file mode 100644 index 386bdf1e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/SyncOperation.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import androidx.appcompat.app.AppCompatActivity -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import java.io.File -import org.eclipse.jgit.api.AddCommand -import org.eclipse.jgit.api.CommitCommand -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.PullCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.StatusCommand - -/** - * Creates a new git operation - * - * @param fileDir the git working tree directory - * @param callingActivity the calling activity - */ -class SyncOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { - - private var addCommand: AddCommand? = null - private var statusCommand: StatusCommand? = null - private var commitCommand: CommitCommand? = null - private var pullCommand: PullCommand? = null - private var pushCommand: PushCommand? = null - - /** - * Sets the command - * - * @return the current object - */ - fun setCommands(): SyncOperation { - val git = Git(repository) - this.addCommand = git.add().addFilepattern(".") - this.statusCommand = git.status() - this.commitCommand = git.commit().setAll(true).setMessage("[Android Password Store] Sync") - this.pullCommand = git.pull().setRebase(true).setRemote("origin") - this.pushCommand = git.push().setPushAll().setRemote("origin") - return this - } - - override fun execute() { - if (this.provider != null) { - this.pullCommand?.setCredentialsProvider(this.provider) - this.pushCommand?.setCredentialsProvider(this.provider) - } - GitAsyncTask(callingActivity, this, Intent()).execute(this.addCommand, this.statusCommand, this.commitCommand, this.pullCommand, this.pushCommand) - } - - override fun onError(err: Exception) { - super.onError(err) - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) - .setMessage("Error occurred during the sync operation, " + - "\nPlease check the FAQ for possible reasons why this error might occur." + - callingActivity.resources.getString(R.string.jgit_error_dialog_text) + - err) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() } - .show() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt new file mode 100644 index 00000000..b46b66cf --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt @@ -0,0 +1,52 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File +import org.eclipse.jgit.api.RebaseCommand + +class BreakOutOfDetached(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + private val branchName = "conflicting-$remoteBranch-${System.currentTimeMillis()}" + + override val commands = arrayOf( + // abort the rebase + git.rebase().setOperation(RebaseCommand.Operation.ABORT), + // git checkout -b conflict-branch + git.checkout().setCreateBranch(true).setName(branchName), + // push the changes + git.push().setRemote("origin"), + // switch back to ${gitBranch} + git.checkout().setName(remoteBranch), + ) + + override suspend fun execute() { + if (!git.repository.repositoryState.isRebasing) { + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) + .setMessage("The repository is not rebasing, no need to push to another branch") + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + }.show() + return + } + GitCommandExecutor(callingActivity, this).execute() + } + + override fun onSuccess() { + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) + .setMessage("There was a conflict when trying to rebase. " + + "Your local $remoteBranch branch was pushed to another branch named conflicting-$remoteBranch-....\n" + + "Use this branch to resolve conflict on your computer") + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + }.show() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt new file mode 100644 index 00000000..8c516eac --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt @@ -0,0 +1,29 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new clone operation + * + * @param fileDir the git working tree directory + * @param uri URL to clone the repository from + * @param callingActivity the calling activity + */ +class CloneOperation(fileDir: File, uri: String, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + override val commands: Array> = arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository?.workTree).setURI(uri), + ) + + override suspend fun execute() { + GitCommandExecutor(callingActivity, this).execute() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt new file mode 100644 index 00000000..423ceb80 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt @@ -0,0 +1,94 @@ +package com.zeapo.pwdstore.git.operation + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.InteractivePasswordFinder +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.requestInputFocusOnView +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class CredentialFinder( + val callingActivity: FragmentActivity, + val connectionMode: ConnectionMode +) : InteractivePasswordFinder() { + + override fun askForPassword(cont: Continuation, isRetry: Boolean) { + val gitOperationPrefs = callingActivity.getEncryptedPrefs("git_operation") + val credentialPref: String + @StringRes val messageRes: Int + @StringRes val hintRes: Int + @StringRes val rememberRes: Int + @StringRes val errorRes: Int + when (connectionMode) { + ConnectionMode.SshKey -> { + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + messageRes = R.string.passphrase_dialog_text + hintRes = R.string.ssh_keygen_passphrase + rememberRes = R.string.git_operation_remember_passphrase + errorRes = R.string.git_operation_wrong_passphrase + } + ConnectionMode.Password -> { + // Could be either an SSH or an HTTPS password + credentialPref = PreferenceKeys.HTTPS_PASSWORD + messageRes = R.string.password_dialog_text + hintRes = R.string.git_operation_hint_password + rememberRes = R.string.git_operation_remember_password + errorRes = R.string.git_operation_wrong_password + } + else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") + } + val storedCredential = gitOperationPrefs.getString(credentialPref, null) + if (isRetry) + gitOperationPrefs.edit { remove(credentialPref) } + if (storedCredential == null) { + val layoutInflater = LayoutInflater.from(callingActivity) + + @SuppressLint("InflateParams") + val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = dialogView.findViewById(R.id.git_auth_passphrase_layout) + val editCredential = dialogView.findViewById(R.id.git_auth_credential) + editCredential.setHint(hintRes) + val rememberCredential = dialogView.findViewById(R.id.git_auth_remember_credential) + rememberCredential.setText(rememberRes) + if (isRetry) + credentialLayout.error = callingActivity.resources.getString(errorRes) + MaterialAlertDialogBuilder(callingActivity).run { + setTitle(R.string.passphrase_dialog_title) + setMessage(messageRes) + setView(dialogView) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val credential = editCredential.text.toString() + if (rememberCredential.isChecked) { + gitOperationPrefs.edit { + putString(credentialPref, credential) + } + } + cont.resume(credential) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> + cont.resume(null) + } + setOnCancelListener { + cont.resume(null) + } + create() + }.run { + requestInputFocusOnView(R.id.git_auth_credential) + show() + } + } else { + cont.resume(storedCredential) + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt new file mode 100644 index 00000000..45fefd64 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt @@ -0,0 +1,187 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import android.content.Intent +import androidx.annotation.CallSuper +import androidx.core.content.edit +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import com.github.ajalt.timberkt.Timber.d +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.git.ErrorMessages +import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.InteractivePasswordFinder +import com.zeapo.pwdstore.git.config.SshApiSessionFactory +import com.zeapo.pwdstore.git.config.SshAuthData +import com.zeapo.pwdstore.git.config.SshjSessionFactory +import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.PreferenceKeys +import com.zeapo.pwdstore.utils.getEncryptedPrefs +import java.io.File +import net.schmizz.sshj.userauth.password.PasswordFinder +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.errors.UnsupportedCredentialItem +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.URIish + +/** + * Creates a new git operation + * + * @param gitDir the git working tree directory + * @param callingActivity the calling activity + */ +abstract class GitOperation(gitDir: File, internal val callingActivity: FragmentActivity) { + + abstract val commands: Array> + private var provider: CredentialsProvider? = null + private val sshKeyFile = callingActivity.filesDir.resolve(".ssh_key") + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") + protected val repository = PasswordRepository.getRepository(gitDir) + protected val git = Git(repository) + protected val remoteBranch = PreferenceManager + .getDefaultSharedPreferences(callingActivity.applicationContext) + .getString(PreferenceKeys.GIT_BRANCH_NAME, "master") + + private class PasswordFinderCredentialsProvider(private val username: String, private val passwordFinder: PasswordFinder) : CredentialsProvider() { + + override fun isInteractive() = true + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + for (item in items) { + when (item) { + is CredentialItem.Username -> item.value = username + is CredentialItem.Password -> item.value = passwordFinder.reqPassword(null) + else -> UnsupportedCredentialItem(uri, item.javaClass.name) + } + } + return true + } + + override fun supports(vararg items: CredentialItem) = items.all { + it is CredentialItem.Username || it is CredentialItem.Password + } + } + + private fun withPasswordAuthentication(username: String, passwordFinder: InteractivePasswordFinder): GitOperation { + val sessionFactory = SshjSessionFactory(username, SshAuthData.Password(passwordFinder), hostKeyFile) + SshSessionFactory.setInstance(sessionFactory) + this.provider = PasswordFinderCredentialsProvider(username, passwordFinder) + return this + } + + private fun withPublicKeyAuthentication(username: String, passphraseFinder: InteractivePasswordFinder): GitOperation { + val sessionFactory = SshjSessionFactory(username, SshAuthData.PublicKeyFile(sshKeyFile, passphraseFinder), hostKeyFile) + SshSessionFactory.setInstance(sessionFactory) + this.provider = null + return this + } + + private fun withOpenKeychainAuthentication(username: String, identity: SshApiSessionFactory.ApiIdentity?): GitOperation { + SshSessionFactory.setInstance(SshApiSessionFactory(username, identity)) + this.provider = null + return this + } + + private fun getSshKey(make: Boolean) { + try { + // Ask the UserPreference to provide us with the ssh-key + // onResult has to be handled by the callingActivity + val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) + intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") + callingActivity.startActivityForResult(intent, GET_SSH_KEY_FROM_CLONE) + } catch (e: Exception) { + println("Exception caught :(") + e.printStackTrace() + } + } + + fun setCredentialProvider() { + provider?.let { credentialsProvider -> + commands.filterIsInstance>().forEach { it.setCredentialsProvider(credentialsProvider) } + } + } + + /** + * Executes the GitCommand in an async task + */ + abstract suspend fun execute() + + suspend fun executeAfterAuthentication( + connectionMode: ConnectionMode, + username: String, + identity: SshApiSessionFactory.ApiIdentity? + ) { + when (connectionMode) { + ConnectionMode.SshKey -> if (!sshKeyFile.exists()) { + MaterialAlertDialogBuilder(callingActivity) + .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) + .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) + .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> + getSshKey(false) + } + .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> + getSshKey(true) + } + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + // Finish the blank GitActivity so user doesn't have to press back + callingActivity.finish() + }.show() + } else { + withPublicKeyAuthentication(username, CredentialFinder(callingActivity, + connectionMode)).execute() + } + ConnectionMode.OpenKeychain -> withOpenKeychainAuthentication(username, identity).execute() + ConnectionMode.Password -> withPasswordAuthentication( + username, CredentialFinder(callingActivity, connectionMode)).execute() + ConnectionMode.None -> execute() + } + } + + /** + * Action to execute on error + */ + @CallSuper + open fun onError(err: Exception) { + // Clear various auth related fields on failure + when (SshSessionFactory.getInstance()) { + is SshApiSessionFactory -> { + PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) + .edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } + } + is SshjSessionFactory -> { + callingActivity.applicationContext + .getEncryptedPrefs("git_operation") + .edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + remove(PreferenceKeys.HTTPS_PASSWORD) + } + } + } + d(err) + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.jgit_error_dialog_title)) + .setMessage(ErrorMessages[err]) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + }.show() + } + + /** + * Action to execute on success + */ + open fun onSuccess() {} + + companion object { + + const val GET_SSH_KEY_FROM_CLONE = 201 + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt new file mode 100644 index 00000000..d31b2aa4 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt @@ -0,0 +1,28 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new git operation + * + * @param fileDir the git working tree directory + * @param callingActivity the calling activity + */ +class PullOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + override val commands: Array> = arrayOf( + Git(repository).pull().setRebase(true).setRemote("origin"), + ) + + override suspend fun execute() { + GitCommandExecutor(callingActivity, this).execute() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt new file mode 100644 index 00000000..33b20a06 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt @@ -0,0 +1,29 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new git operation + * + * @param fileDir the git working tree directory + * @param callingActivity the calling activity + */ +class PushOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + override val commands: Array> = arrayOf( + Git(repository).push().setPushAll().setRemote("origin"), + ) + + override suspend fun execute() { + setCredentialProvider() + GitCommandExecutor(callingActivity, this).execute() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt new file mode 100644 index 00000000..f0aee212 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt @@ -0,0 +1,30 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File +import org.eclipse.jgit.api.ResetCommand + +/** + * Creates a new git operation + * + * @param fileDir the git working tree directory + * @param callingActivity the calling activity + */ +class ResetToRemoteOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + override val commands = arrayOf( + git.add().addFilepattern("."), + git.fetch().setRemote("origin"), + git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), + git.branchCreate().setName(remoteBranch).setForce(true), + ) + + override suspend fun execute() { + GitCommandExecutor(callingActivity, this).execute() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt new file mode 100644 index 00000000..6edf2994 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt @@ -0,0 +1,30 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.operation + +import androidx.appcompat.app.AppCompatActivity +import com.zeapo.pwdstore.git.GitCommandExecutor +import java.io.File + +/** + * Creates a new git operation + * + * @param fileDir the git working tree directory + * @param callingActivity the calling activity + */ +class SyncOperation(fileDir: File, callingActivity: AppCompatActivity) : GitOperation(fileDir, callingActivity) { + + override val commands = arrayOf( + git.add().addFilepattern("."), + git.status(), + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + git.pull().setRebase(true).setRemote("origin"), + git.push().setPushAll().setRemote("origin"), + ) + + override suspend fun execute() { + GitCommandExecutor(callingActivity, this).execute() + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt index b274b47d..6ce45fe5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -14,20 +14,18 @@ import android.view.View import android.view.autofill.AutofillManager import android.view.inputmethod.InputMethodManager import androidx.annotation.IdRes -import androidx.annotation.MainThread import androidx.annotation.RequiresApi import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.github.ajalt.timberkt.d import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.git.GitAsyncTask -import com.zeapo.pwdstore.git.GitOperation +import com.zeapo.pwdstore.git.GitCommandExecutor +import com.zeapo.pwdstore.git.operation.GitOperation import com.zeapo.pwdstore.utils.PasswordRepository.Companion.getRepositoryDirectory import java.io.File -import org.eclipse.jgit.api.Git const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" @@ -51,12 +49,14 @@ fun CharArray.clear() { val Context.clipboard get() = getSystemService() -fun AppCompatActivity.snackbar( +fun FragmentActivity.snackbar( view: View = findViewById(android.R.id.content), message: String, - length: Int = Snackbar.LENGTH_SHORT -) { - Snackbar.make(view, message, length).show() + length: Int = Snackbar.LENGTH_SHORT, +): Snackbar { + val snackbar = Snackbar.make(view, message, length) + snackbar.show() + return snackbar } fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() @@ -97,24 +97,33 @@ fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { ) } -@MainThread -fun AppCompatActivity.commitChange(message: String, finishWithResultOnEnd: Intent? = null) { +suspend fun FragmentActivity.commitChange( + message: String, + finishWithResultOnEnd: Intent? = null, + finishActivityOnEnd: Boolean = true, +) { if (!PasswordRepository.isGitRepo()) { if (finishWithResultOnEnd != null) { - setResult(AppCompatActivity.RESULT_OK, finishWithResultOnEnd) + setResult(FragmentActivity.RESULT_OK, finishWithResultOnEnd) finish() } return } object : GitOperation(getRepositoryDirectory(this@commitChange), this@commitChange) { - override fun execute() { + override val commands = arrayOf( + git.add().addFilepattern("."), + git.status(), + git.commit().setAll(true).setMessage(message), + ) + + override suspend fun execute() { d { "Comitting with message: '$message'" } - val git = Git(repository) - val task = GitAsyncTask(this@commitChange, this, finishWithResultOnEnd, silentlyExecute = true) - task.execute( - git.add().addFilepattern("."), - git.commit().setAll(true).setMessage(message) - ) + GitCommandExecutor( + this@commitChange, + this, + finishWithResultOnEnd, + finishActivityOnEnd, + ).execute() } }.execute() } @@ -124,7 +133,6 @@ fun AppCompatActivity.commitChange(message: String, finishWithResultOnEnd: Inten * view whose id is [id]. Solution based on a StackOverflow * answer: https://stackoverflow.com/a/13056259/297261 */ -@MainThread fun AlertDialog.requestInputFocusOnView(@IdRes id: Int) { setOnShowListener { findViewById(id)?.apply { @@ -143,6 +151,6 @@ val Context.autofillManager: AutofillManager? @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService() -fun AppCompatActivity.isInsideRepository(file: File): Boolean { +fun FragmentActivity.isInsideRepository(file: File): Boolean { return file.canonicalPath.contains(getRepositoryDirectory(this).canonicalPath) } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt b/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt index 35eb7ae3..55d654e0 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt @@ -13,7 +13,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.observe import androidx.viewbinding.ViewBinding import kotlin.properties.ReadOnlyProperty import kotlin.reflect.KProperty diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Result.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Result.kt new file mode 100644 index 00000000..d152cba6 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Result.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.utils + +/** + * Emulates the Rust Result enum but without returning a value in the [Ok] case. + * https://doc.rust-lang.org/std/result/enum.Result.html + */ +sealed class Result { + + object Ok : Result() + data class Err(val err: Exception) : Result() +} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt index aab72d38..23101a13 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt @@ -57,6 +57,7 @@ class UriTotpFinder : TotpFinder { } companion object { + val TOTP_FIELDS = arrayOf( "otpauth://totp", "totp:" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9aaebf98..b5f1192d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,10 +276,6 @@ Screenshot of accessibility services Screenshot of toggle in accessibility services Screenshot of autofill service in action - Pull has failed, you\'re in a detached head. Using "settings > git utils", save your changes to the remote in a new branch and resolve the conflict on your computer. - Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both - Push was rejected by remote, reason: - Remote rejected non-fast-forward push. Check receive.denyNonFastForwards variable in config file of destination repository. Error occurred during the push operation: Clear saved passphrase for local SSH key Clear saved HTTPS password @@ -371,4 +367,12 @@ A key ID in .gpg-id is too short, please use either long key IDs (16 characters) or fingerprints (40 characters) File name must not contain \'/\', set directory above Directory + + + Unknown error + Pull has failed, you\'re in a detached head. Using "settings > git utils", save your changes to the remote in a new branch and resolve the conflict on your computer. + Push was rejected by remote, run pull before pushing again. You can use Synchronize rather than pull/push as it implements both + Push was rejected by remote, reason: %1$s + Remote rejected non-fast-forward push. Check receive.denyNonFastForwards variable in config file of destination repository. + Running git operation… -- cgit v1.2.3