/* * 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 android.content.SharedPreferences import android.os.Bundle import android.view.MenuItem import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import androidx.core.text.isDigitsOnly import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.utils.PasswordRepository import com.zeapo.pwdstore.utils.getEncryptedPrefs import java.io.File import timber.log.Timber /** * Abstract AppCompatActivity that holds some information that is commonly shared across git-related * tasks and makes sense to be held here. */ abstract class BaseGitActivity : AppCompatActivity() { lateinit var protocol: Protocol lateinit var connectionMode: ConnectionMode lateinit var url: String lateinit var serverHostname: String lateinit var serverPort: String lateinit var serverUser: String lateinit var serverPath: String lateinit var username: String lateinit var email: String var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null var identity: SshApiSessionFactory.ApiIdentity? = null lateinit var settings: SharedPreferences private set private lateinit var encryptedSettings: SharedPreferences @CallSuper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) settings = PreferenceManager.getDefaultSharedPreferences(this) encryptedSettings = getEncryptedPrefs("git_operation") protocol = Protocol.fromString(settings.getString("git_remote_protocol", null)) connectionMode = ConnectionMode.fromString(settings.getString("git_remote_auth", null)) serverHostname = settings.getString("git_remote_server", null) ?: "" serverPort = settings.getString("git_remote_port", null) ?: "" serverUser = settings.getString("git_remote_username", null) ?: "" serverPath = settings.getString("git_remote_location", null) ?: "" username = settings.getString("git_config_user_name", null) ?: "" email = settings.getString("git_config_user_email", null) ?: "" updateUrl() } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { finish() true } else -> super.onOptionsItemSelected(item) } } @CallSuper override fun onDestroy() { // Do not leak the service connection if (identityBuilder != null) { identityBuilder!!.close() identityBuilder = null } super.onDestroy() } /** * Update the [url] field with the values that build it up. This function returns a boolean * indicating whether or not the values are likely valid or not, and only adds the `origin` * remote when it is. This check is not perfect, it is mostly meant to catch typos. */ fun updateUrl(): Boolean { if (serverHostname.isEmpty() || !serverPort.isDigitsOnly()) return false val previousUrl = if (::url.isInitialized) url else "" val hostnamePart = serverHostname val pathPart = if (serverPath.startsWith('/')) serverPath else "/$serverPath" url = when (protocol) { Protocol.Ssh -> { val userPart = if (serverUser.isEmpty()) "" else "$serverUser@" val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" // We have to specify the ssh scheme as this is the only way to pass a custom port. "ssh://$userPart$hostnamePart$portPart$pathPart" } Protocol.Https -> { val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" "https://$hostnamePart$portPart$pathPart" } } if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", url, true) // HTTPS authentication sends the password to the server, so we must wipe the password when // the server is changed. if (url != previousUrl && protocol == Protocol.Https) encryptedSettings.edit { remove("https_password") } return true } /** * Attempt to launch the requested Git operation. Depending on the configured auth, it may not * be possible to launch the operation immediately. In that case, this function may launch an * intermediate activity instead, which will gather necessary information and post it back via * onActivityResult, which will then re-call this function. This may happen multiple times, * until either an error is encountered or the operation is successfully launched. * * @param operation The type of git operation to launch */ fun launchGitOperation(operation: Int) { val op: GitOperation val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(this)) try { // Before launching the operation with OpenKeychain auth, we need to issue several requests // to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents, // we just need to keep calling it until it returns a completed ApiIdentity. if (connectionMode == ConnectionMode.OpenKeychain && identity == null) { // Lazy initialization of the IdentityBuilder if (identityBuilder == null) { identityBuilder = SshApiSessionFactory.IdentityBuilder(this) } // Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure // that onActivityResult is called with operation again, which will re-invoke us here identity = identityBuilder!!.tryBuild(operation) if (identity == null) return } 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() SshApiSessionFactory.POST_SIGNATURE -> return else -> { Timber.tag(TAG).e("Operation not recognized : $operation") setResult(RESULT_CANCELED) finish() return } } op.executeAfterAuthentication(connectionMode, serverUser, File("$filesDir/.ssh_key"), identity) } catch (e: Exception) { e.printStackTrace() MaterialAlertDialogBuilder(this).setMessage(e.message).show() } } public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { // In addition to the pre-operation-launch series of intents for OpenKeychain auth // that will pass through here and back to launchGitOperation, there is one // synchronous operation that happens /after/ the operation has been launched in the // background thread - the actual signing of the SSH challenge. We pass through the // completed signature to the ApiIdentity, which will be blocked in the other thread // waiting for it. if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null) { identity!!.postSignature(data) // If the signature failed (usually because it was cancelled), reset state if (data == null) { identity = null identityBuilder = null } return } if (resultCode == RESULT_CANCELED) { setResult(RESULT_CANCELED) finish() } else if (resultCode == RESULT_OK) { // If an operation has been re-queued via this mechanism, let the // IdentityBuilder attempt to extract some updated state from the intent before // trying to re-launch the operation. if (identityBuilder != null) { identityBuilder!!.consume(data) } launchGitOperation(requestCode) } super.onActivityResult(requestCode, resultCode, data) } companion object { const val REQUEST_ARG_OP = "OPERATION" const val REQUEST_PULL = 101 const val REQUEST_PUSH = 102 const val REQUEST_CLONE = 103 const val REQUEST_INIT = 104 const val REQUEST_SYNC = 105 const val BREAK_OUT_OF_DETACHED = 106 const val REQUEST_RESET = 107 const val TAG = "AbstractGitActivity" } }