aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/util/git/operation
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util/git/operation')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt36
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt22
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt98
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt216
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt31
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt15
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt23
8 files changed, 464 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
new file mode 100644
index 00000000..1aff34de
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.RebaseCommand
+
+class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // abort the rebase
+ git.rebase().setOperation(RebaseCommand.Operation.ABORT),
+ // git checkout -b conflict-branch
+ git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
+ // push the changes
+ git.push().setRemote("origin"),
+ // switch back to ${gitBranch}
+ git.checkout().setName(remoteBranch),
+ )
+
+ override fun preExecute() = if (!git.repository.repositoryState.isRebasing) {
+ MaterialAlertDialogBuilder(callingActivity)
+ .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
+ .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
+ callingActivity.finish()
+ }.show()
+ false
+ } else {
+ true
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
new file mode 100644
index 00000000..e1dd6760
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.Git
+import org.eclipse.jgit.api.GitCommand
+
+/**
+ * Creates a new clone operation
+ *
+ * @param uri URL to clone the repository from
+ * @param callingActivity the calling activity
+ */
+class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
+
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
new file mode 100644
index 00000000..173b7a50
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
@@ -0,0 +1,98 @@
+package dev.msfjarvis.aps.util.git.operation
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import androidx.annotation.StringRes
+import androidx.core.content.edit
+import androidx.core.widget.doOnTextChanged
+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 dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder
+import dev.msfjarvis.aps.util.settings.PreferenceKeys
+import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
+import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+
+class CredentialFinder(
+ val callingActivity: FragmentActivity,
+ val authMode: AuthMode
+) : InteractivePasswordFinder() {
+
+ override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
+ val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
+ val credentialPref: String
+ @StringRes val messageRes: Int
+ @StringRes val hintRes: Int
+ @StringRes val rememberRes: Int
+ @StringRes val errorRes: Int
+ when (authMode) {
+ AuthMode.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
+ }
+ AuthMode.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")
+ }
+ if (isRetry)
+ gitOperationPrefs.edit { remove(credentialPref) }
+ val storedCredential = gitOperationPrefs.getString(credentialPref, null)
+ if (storedCredential == null) {
+ val layoutInflater = LayoutInflater.from(callingActivity)
+
+ @SuppressLint("InflateParams")
+ val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
+ val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
+ val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
+ editCredential.setHint(hintRes)
+ val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
+ rememberCredential.setText(rememberRes)
+ if (isRetry) {
+ credentialLayout.error = callingActivity.resources.getString(errorRes)
+ // Reset error when user starts entering a password
+ editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
+ }
+ 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<TextInputEditText>(R.id.git_auth_credential)
+ show()
+ }
+ } else {
+ cont.resume(storedCredential)
+ }
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
new file mode 100644
index 00000000..44292fc6
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import android.content.Intent
+import android.widget.Toast
+import androidx.fragment.app.FragmentActivity
+import com.github.ajalt.timberkt.e
+import com.github.michaelbull.result.Err
+import com.github.michaelbull.result.Ok
+import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.runCatching
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dev.msfjarvis.aps.R
+import dev.msfjarvis.aps.ui.settings.UserPreference
+import dev.msfjarvis.aps.util.git.GitCommandExecutor
+import dev.msfjarvis.aps.util.settings.AuthMode
+import dev.msfjarvis.aps.util.settings.GitSettings
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod
+import dev.msfjarvis.aps.util.git.sshj.SshKey
+import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory
+import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
+import dev.msfjarvis.aps.data.repo.PasswordRepository
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+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.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.SshTransport
+import org.eclipse.jgit.transport.Transport
+import org.eclipse.jgit.transport.URIish
+
+/**
+ * Creates a new git operation
+ *
+ * @param callingActivity the calling activity
+ */
+abstract class GitOperation(protected val callingActivity: FragmentActivity) {
+
+ abstract val commands: Array<GitCommand<out Any>>
+ private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
+ private var sshSessionFactory: SshjSessionFactory? = null
+
+ protected val repository = PasswordRepository.getRepository(null)!!
+ protected val git = Git(repository)
+ protected val remoteBranch = GitSettings.branch
+ private val authActivity get() = callingActivity as ContinuationContainerActivity
+
+ private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
+
+ private var cachedPassword: CharArray? = null
+
+ override fun isInteractive() = true
+
+ override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
+ for (item in items) {
+ when (item) {
+ is CredentialItem.Username -> item.value = uri?.user
+ is CredentialItem.Password -> {
+ item.value = cachedPassword?.clone()
+ ?: passwordFinder.reqPassword(null).also {
+ cachedPassword = it.clone()
+ }
+ }
+ else -> UnsupportedCredentialItem(uri, item.javaClass.name)
+ }
+ }
+ return true
+ }
+
+ override fun supports(vararg items: CredentialItem) = items.all {
+ it is CredentialItem.Username || it is CredentialItem.Password
+ }
+
+ override fun reset(uri: URIish?) {
+ cachedPassword?.fill(0.toChar())
+ cachedPassword = null
+ }
+ }
+
+ private fun getSshKey(make: Boolean) {
+ runCatching {
+ // Ask the UserPreference to provide us with the ssh-key
+ val intent = Intent(callingActivity.applicationContext, UserPreference::class.java)
+ intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key")
+ callingActivity.startActivity(intent)
+ }.onFailure { e ->
+ e(e)
+ }
+ }
+
+ private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
+ sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
+ commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
+ command.setTransportConfigCallback { transport: Transport ->
+ (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
+ credentialsProvider?.let { transport.credentialsProvider = it }
+ }
+ command.setTimeout(CONNECT_TIMEOUT)
+ }
+ }
+
+ /**
+ * Executes the GitCommand in an async task.
+ */
+ suspend fun execute(): Result<Unit, Throwable> {
+ if (!preExecute()) {
+ return Ok(Unit)
+ }
+ val operationResult = GitCommandExecutor(
+ callingActivity,
+ this,
+ ).execute()
+ postExecute()
+ return operationResult
+ }
+
+ private fun onMissingSshKeyFile() {
+ 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()
+ }
+
+ suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
+ when (authMode) {
+ AuthMode.SshKey -> if (SshKey.exists) {
+ if (SshKey.mustAuthenticate) {
+ val result = withContext(Dispatchers.Main) {
+ suspendCoroutine<BiometricAuthenticator.Result> { cont ->
+ BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
+ if (it !is BiometricAuthenticator.Result.Failure)
+ cont.resume(it)
+ }
+ }
+ }
+ when (result) {
+ is BiometricAuthenticator.Result.Success -> {
+ registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ }
+ is BiometricAuthenticator.Result.Cancelled -> {
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+ }
+ is BiometricAuthenticator.Result.Failure -> {
+ throw IllegalStateException("Biometric authentication failures should be ignored")
+ }
+ else -> {
+ // There is a chance we succeed if the user recently confirmed
+ // their screen lock. Doing so would have a potential to confuse
+ // users though, who might deduce that the screen lock
+ // protection is not effective. Hence, we fail with an error.
+ Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
+ callingActivity.finish()
+ }
+ }
+ } else {
+ registerAuthProviders(SshAuthMethod.SshKey(authActivity))
+ }
+ } else {
+ onMissingSshKeyFile()
+ // This would correctly cancel the operation but won't surface a user-visible
+ // error, allowing users to make the SSH key selection.
+ return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
+ }
+ AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
+ AuthMode.Password -> {
+ val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
+ registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
+ }
+ AuthMode.None -> {
+ }
+ }
+ return execute()
+ }
+
+ /**
+ * Called before execution of the Git operation.
+ * Return false to cancel.
+ */
+ open fun preExecute() = true
+
+ private suspend fun postExecute() {
+ withContext(Dispatchers.IO) {
+ sshSessionFactory?.close()
+ }
+ }
+
+ companion object {
+
+ /**
+ * Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
+ */
+ private const val CONNECT_TIMEOUT = 10
+ }
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
new file mode 100644
index 00000000..7bee775a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.GitCommand
+
+class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ /**
+ * The story of why the pull operation is committing files goes like this: Once upon a time when
+ * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
+ * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
+ * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
+ * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
+ * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
+ * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
+ */
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(true).setRemote("origin"),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
new file mode 100644
index 00000000..31e5fcb7
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.GitCommand
+
+class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands: Array<GitCommand<out Any>> = arrayOf(
+ git.push().setPushAll().setRemote("origin"),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
new file mode 100644
index 00000000..9c1fb01a
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+import org.eclipse.jgit.api.ResetCommand
+
+class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Fetch everything from the origin remote
+ git.fetch().setRemote("origin"),
+ // Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch
+ git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
+ // Force-create $remoteBranch if it doesn't exist. This covers the case where you switched
+ // branches from 'master' to anything else.
+ git.branchCreate().setName(remoteBranch).setForce(true),
+ )
+}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
new file mode 100644
index 00000000..512d6b48
--- /dev/null
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package dev.msfjarvis.aps.util.git.operation
+
+import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
+
+class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
+
+ override val commands = arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(true).setRemote("origin"),
+ // Push it all back
+ git.push().setPushAll().setRemote("origin"),
+ )
+}