diff options
author | Harsh Shandilya <msfjarvis@gmail.com> | 2020-04-17 18:36:07 +0530 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-04-17 18:36:07 +0530 |
commit | b94b52a42ddc2325b539d0956fd0adcf35308b52 (patch) | |
tree | 21f35526cfc444db82e1b4b08d083e49a3c1843b /app/src/main/java/com | |
parent | 4ffd7ed9bffa5139277ffb91de5a69f2b714222c (diff) |
Refactor Git related activities (#685)
* Refactor git logic into separate parts
* Extract hardcoded strings
* Add KDoc to updateHostname, remove unused field
* Cleanups
* Fix dialog message
* Wire in repository clone flow
* spotless
* Remove unused method
* Cleanup GitActivity
- Rename to GitOperationActivity.
- Ensure identityBuilder is always closed regardless of what fragment uses it.
- Remove hardcoded "Operation" strings and replace with REQUEST_ARG_OP.
- Apply a transparent theme to GitOperationActivity make the UI less jarring.
* Tweak some stupidly worded dialog messages
As pointed out in #629, these strings are shoddily worded and do not express any clear intent to the
user, leaving them confused and angry.
* GitOperationActivity: wrap Context to ensure right theme is used
* spotless
* undo build.gradle change
* Use correct parent theme, remove now useless wrapping
* GitServerConfigActivity: fix repository clone flow
* temp: disable leakcanary
framework leaks on Samsung are pissing me off
* Make system bars transparent in git activity
* Tweak HTTPS password layout
* Unhardcode wrong passphrase string
* Store SSH passphrase in EncryptedSharedPreferences
Also revamp the dialog to look a bit better
* Implement support for remembering HTTPS password
Fixes #521
* Try to patch HTTPS remote creation logic
* Update security-crypto
* Clear saved passphrase/password on auth failure
* Revert "Update security-crypto"
Broken on R DP2.1
This reverts commit 4b20371dd42c512a3dd3b759859abb6c1ffd2961.
* Revert "temp: disable leakcanary"
This reverts commit 2db7d41bd67b79c6dc8c5b359a7b27100379f45f.
* Update CHANGELOG
* Remove spacer
* Remove useless override
* Wrap git server activity in a ScrollView
* GitOperation: always finish calling activity when dialogs are dismissed
* Wipe saved password/passphrase when hostname changes
* Don't commit prefs updates
* Don't call listFiles excessively
* Finish activity after saving configuration
* Make ConnectionMode and Protocol enum classes
* Change SSH key passphrase key, don't wipe on host change
* Reimplement BaseGitActivity.updateUrl (was updateHostname)
* Use SharedPreferences.edit KTX extension
* Disable inapplicable connection modes depending on scheme
* BaseGitActivity: annotate onDestroy with CallSuper
We'll leak the identityBuilder connection otherwise
* Move input hack for AlertDialog into an extension function
We re-use this in many places
* Fix protocol/mode toggle issue and consistenly name options
* Fix a crash when opening GitServerConfigActivity without a repo
* Fix OpenKeychain callbacks by moving onActivityResult to BaseGitActivity
* Run spotlessApply
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Fabian Henneke <fabian@henneke.me>
Diffstat (limited to 'app/src/main/java/com')
15 files changed, 750 insertions, 806 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt index e4551f1c..fd7bdf83 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt @@ -23,7 +23,8 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.databinding.PasswordRecyclerViewBinding -import com.zeapo.pwdstore.git.GitActivity +import com.zeapo.pwdstore.git.BaseGitActivity +import com.zeapo.pwdstore.git.GitOperationActivity import com.zeapo.pwdstore.ui.OnOffItemAnimator import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter import com.zeapo.pwdstore.utils.PasswordItem @@ -77,9 +78,9 @@ class PasswordFragment : Fragment() { .show() swipeRefresher.isRefreshing = false } else { - val intent = Intent(context, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_SYNC) - startActivityForResult(intent, GitActivity.REQUEST_SYNC) + val intent = Intent(context, GitOperationActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_SYNC) + startActivityForResult(intent, BaseGitActivity.REQUEST_SYNC) } } diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt index dc09eefe..90b2e631 100644 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt @@ -38,9 +38,11 @@ import com.google.android.material.snackbar.Snackbar import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.crypto.PgpActivity import com.zeapo.pwdstore.crypto.PgpActivity.Companion.getLongName -import com.zeapo.pwdstore.git.GitActivity +import com.zeapo.pwdstore.git.BaseGitActivity import com.zeapo.pwdstore.git.GitAsyncTask import com.zeapo.pwdstore.git.GitOperation +import com.zeapo.pwdstore.git.GitOperationActivity +import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment import com.zeapo.pwdstore.utils.PasswordItem import com.zeapo.pwdstore.utils.PasswordRepository @@ -249,9 +251,9 @@ class PasswordStore : AppCompatActivity() { initBefore.show() return false } - intent = Intent(this, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_PUSH) - startActivityForResult(intent, GitActivity.REQUEST_PUSH) + intent = Intent(this, GitOperationActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_PUSH) + startActivityForResult(intent, BaseGitActivity.REQUEST_PUSH) return true } R.id.git_pull -> { @@ -259,9 +261,9 @@ class PasswordStore : AppCompatActivity() { initBefore.show() return false } - intent = Intent(this, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_PULL) - startActivityForResult(intent, GitActivity.REQUEST_PULL) + intent = Intent(this, GitOperationActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_PULL) + startActivityForResult(intent, BaseGitActivity.REQUEST_PULL) return true } R.id.git_sync -> { @@ -269,9 +271,9 @@ class PasswordStore : AppCompatActivity() { initBefore.show() return false } - intent = Intent(this, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_SYNC) - startActivityForResult(intent, GitActivity.REQUEST_SYNC) + intent = Intent(this, GitOperationActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_SYNC) + startActivityForResult(intent, BaseGitActivity.REQUEST_SYNC) return true } R.id.refresh -> { @@ -353,7 +355,7 @@ class PasswordStore : AppCompatActivity() { .setMessage(this.resources.getString(R.string.key_dialog_text)) .setPositiveButton(this.resources.getString(R.string.dialog_positive)) { _, _ -> val intent = Intent(activity, UserPreference::class.java) - startActivityForResult(intent, GitActivity.REQUEST_INIT) + startActivityForResult(intent, BaseGitActivity.REQUEST_INIT) } .setNegativeButton(this.resources.getString(R.string.dialog_negative), null) .show() @@ -550,7 +552,7 @@ class PasswordStore : AppCompatActivity() { fileLocations.add(file.absolutePath) } intent.putExtra("Files", fileLocations) - intent.putExtra("Operation", "SELECTFOLDER") + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, "SELECTFOLDER") startActivityForResult(intent, REQUEST_CODE_SELECT_FOLDER) } @@ -586,7 +588,7 @@ class PasswordStore : AppCompatActivity() { if (resultCode == Activity.RESULT_OK) { when (requestCode) { // if we get here with a RESULT_OK then it's probably OK :) - GitActivity.REQUEST_CLONE -> settings.edit().putBoolean("repository_initialized", true).apply() + BaseGitActivity.REQUEST_CLONE -> settings.edit().putBoolean("repository_initialized", true).apply() // if went from decrypt->edit and user saved changes or HOTP counter was // incremented, we need to commitChange REQUEST_CODE_DECRYPT_AND_VERIFY -> { @@ -620,8 +622,8 @@ class PasswordStore : AppCompatActivity() { data!!.extras!!.getString("LONG_NAME"))) refreshPasswordList() } - GitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo() - GitActivity.REQUEST_SYNC, GitActivity.REQUEST_PULL -> resetPasswordList() + BaseGitActivity.REQUEST_INIT, NEW_REPO_BUTTON -> initializeRepositoryInfo() + BaseGitActivity.REQUEST_SYNC, BaseGitActivity.REQUEST_PULL -> resetPasswordList() HOME -> checkLocalRepository() // duplicate code CLONE_REPO_BUTTON -> { @@ -639,9 +641,9 @@ class PasswordStore : AppCompatActivity() { return // if not empty, just show me the passwords! } } - val intent = Intent(activity, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_CLONE) - startActivityForResult(intent, GitActivity.REQUEST_CLONE) + val intent = Intent(activity, GitOperationActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_CLONE) + startActivityForResult(intent, BaseGitActivity.REQUEST_CLONE) } REQUEST_CODE_SELECT_FOLDER -> { Timber.tag(TAG) @@ -722,10 +724,9 @@ class PasswordStore : AppCompatActivity() { when (operation) { NEW_REPO_BUTTON -> initializeRepositoryInfo() CLONE_REPO_BUTTON -> { - initialize(this@PasswordStore) - val intent = Intent(activity, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_CLONE) - startActivityForResult(intent, GitActivity.REQUEST_CLONE) + val intent = Intent(activity, GitServerConfigActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_CLONE) + startActivityForResult(intent, BaseGitActivity.REQUEST_CLONE) } } } @@ -744,10 +745,9 @@ class PasswordStore : AppCompatActivity() { when (operation) { NEW_REPO_BUTTON -> initializeRepositoryInfo() CLONE_REPO_BUTTON -> { - initialize(this@PasswordStore) - val intent = Intent(activity, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.REQUEST_CLONE) - startActivityForResult(intent, GitActivity.REQUEST_CLONE) + val intent = Intent(activity, GitServerConfigActivity::class.java) + intent.putExtra(BaseGitActivity.REQUEST_ARG_OP, BaseGitActivity.REQUEST_CLONE) + startActivityForResult(intent, BaseGitActivity.REQUEST_CLONE) } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt index 716a9192..eabc35f4 100644 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt @@ -37,7 +37,8 @@ import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel import com.zeapo.pwdstore.crypto.PgpActivity -import com.zeapo.pwdstore.git.GitActivity +import com.zeapo.pwdstore.git.GitConfigActivity +import com.zeapo.pwdstore.git.GitServerConfigActivity import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity @@ -45,6 +46,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository import com.zeapo.pwdstore.utils.auth.AuthenticationResult import com.zeapo.pwdstore.utils.auth.Authenticator import com.zeapo.pwdstore.utils.autofillManager +import com.zeapo.pwdstore.utils.getEncryptedPrefs import java.io.File import java.io.IOException import java.time.LocalDateTime @@ -73,6 +75,7 @@ class UserPreference : AppCompatActivity() { callingActivity = requireActivity() as UserPreference val context = requireContext() val sharedPreferences = preferenceManager.sharedPreferences + val encryptedPreferences = requireActivity().applicationContext.getEncryptedPrefs("git_operation") addPreferencesFromResource(R.xml.preference) @@ -121,7 +124,7 @@ class UserPreference : AppCompatActivity() { selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString("git_external_repo", getString(R.string.no_repo_selected)) viewSshKeyPreference?.isVisible = sharedPreferences.getBoolean("use_generated_key", false) deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean("git_external", false) - sshClearPassphrasePreference?.isVisible = sharedPreferences.getString("ssh_key_passphrase", null)?.isNotEmpty() + sshClearPassphrasePreference?.isVisible = encryptedPreferences.getString("ssh_key_local_passphrase", null)?.isNotEmpty() ?: false clearHotpIncrementPreference?.isVisible = sharedPreferences.getBoolean("hotp_remember_check", false) clearAfterCopyPreference?.isVisible = sharedPreferences.getString("general_show_time", "45")?.toInt() != 0 @@ -172,7 +175,7 @@ class UserPreference : AppCompatActivity() { } sshClearPassphrasePreference?.onPreferenceClickListener = ClickListener { - sharedPreferences.edit().putString("ssh_key_passphrase", null).apply() + encryptedPreferences.edit().putString("ssh_key_local_passphrase", null).apply() it.isVisible = false true } @@ -190,16 +193,12 @@ class UserPreference : AppCompatActivity() { } gitServerPreference?.onPreferenceClickListener = ClickListener { - val intent = Intent(callingActivity, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.EDIT_SERVER) - startActivityForResult(intent, EDIT_GIT_INFO) + startActivity(Intent(callingActivity, GitServerConfigActivity::class.java)) true } gitConfigPreference?.onPreferenceClickListener = ClickListener { - val intent = Intent(callingActivity, GitActivity::class.java) - intent.putExtra("Operation", GitActivity.EDIT_GIT_CONFIG) - startActivityForResult(intent, EDIT_GIT_CONFIG) + startActivity(Intent(callingActivity, GitConfigActivity::class.java)) true } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt new file mode 100644 index 00000000..36dd95c4 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt @@ -0,0 +1,212 @@ +/* + * 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" + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt deleted file mode 100644 index 6341fbe1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.kt +++ /dev/null @@ -1,670 +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.Context -import android.content.Intent -import android.content.SharedPreferences -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Spinner -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.AppCompatTextView -import androidx.preference.PreferenceManager -import com.google.android.material.button.MaterialButton -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.git.config.SshApiSessionFactory -import com.zeapo.pwdstore.utils.PasswordRepository -import java.io.File -import java.io.IOException -import java.util.regex.Pattern -import org.apache.commons.io.FileUtils -import org.eclipse.jgit.lib.Constants -import timber.log.Timber - -open class GitActivity : AppCompatActivity() { - private lateinit var context: Context - private lateinit var settings: SharedPreferences - private lateinit var protocol: String - private lateinit var connectionMode: String - private lateinit var hostname: String - private var identityBuilder: SshApiSessionFactory.IdentityBuilder? = null - private var identity: SshApiSessionFactory.ApiIdentity? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - context = requireNotNull(this) - - settings = PreferenceManager.getDefaultSharedPreferences(this) - - protocol = settings.getString("git_remote_protocol", null) ?: "ssh://" - connectionMode = settings.getString("git_remote_auth", null) ?: "ssh-key" - hostname = settings.getString("git_remote_location", null) ?: "" - val operationCode = intent.extras!!.getInt("Operation") - - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - - when (operationCode) { - REQUEST_CLONE, EDIT_SERVER -> { - setContentView(R.layout.activity_git_clone) - setTitle(R.string.title_activity_git_clone) - - val protcolSpinner = findViewById<Spinner>(R.id.clone_protocol) - val connectionModeSpinner = findViewById<Spinner>(R.id.connection_mode) - - // init the spinner for connection modes - val connectionModeAdapter = ArrayAdapter.createFromResource(this, - R.array.connection_modes, android.R.layout.simple_spinner_item) - connectionModeAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - connectionModeSpinner.adapter = connectionModeAdapter - connectionModeSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { - val selection = (findViewById<View>(R.id.connection_mode) as Spinner).selectedItem.toString() - connectionMode = selection - settings.edit().putString("git_remote_auth", selection).apply() - } - - override fun onNothingSelected(adapterView: AdapterView<*>) { - } - } - - // init the spinner for protocols - val protocolAdapter = ArrayAdapter.createFromResource(this, - R.array.clone_protocols, android.R.layout.simple_spinner_item) - protocolAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - protcolSpinner.adapter = protocolAdapter - protcolSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(adapterView: AdapterView<*>, view: View, i: Int, l: Long) { - protocol = (findViewById<View>(R.id.clone_protocol) as Spinner).selectedItem.toString() - if (protocol == "ssh://") { - - // select ssh-key auth mode as default and enable the spinner in case it was disabled - connectionModeSpinner.setSelection(0) - connectionModeSpinner.isEnabled = true - - // however, if we have some saved that, that's more important! - when { - connectionMode.equals("ssh-key", ignoreCase = true) -> connectionModeSpinner.setSelection(0) - connectionMode.equals("OpenKeychain", ignoreCase = true) -> connectionModeSpinner.setSelection(2) - else -> connectionModeSpinner.setSelection(1) - } - } else { - // select user/pwd auth-mode and disable the spinner - connectionModeSpinner.setSelection(1) - connectionModeSpinner.isEnabled = false - } - - updateURI() - } - - override fun onNothingSelected(adapterView: AdapterView<*>) { - } - } - - if (protocol == "ssh://") { - protcolSpinner.setSelection(0) - } else { - protcolSpinner.setSelection(1) - } - - // init the server information - val serverUrl = findViewById<TextInputEditText>(R.id.server_url) - val serverPort = findViewById<TextInputEditText>(R.id.server_port) - val serverPath = findViewById<TextInputEditText>(R.id.server_path) - val serverUser = findViewById<TextInputEditText>(R.id.server_user) - val serverUri = findViewById<TextInputEditText>(R.id.clone_uri) - - serverUrl.setText(settings.getString("git_remote_server", "")) - serverPort.setText(settings.getString("git_remote_port", "")) - serverUser.setText(settings.getString("git_remote_username", "")) - serverPath.setText(settings.getString("git_remote_location", "")) - - serverUrl.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { - if (serverUrl.isFocused) - updateURI() - } - - override fun afterTextChanged(editable: Editable) {} - }) - serverPort.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { - if (serverPort.isFocused) - updateURI() - } - - override fun afterTextChanged(editable: Editable) {} - }) - serverUser.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { - if (serverUser.isFocused) - updateURI() - } - - override fun afterTextChanged(editable: Editable) {} - }) - serverPath.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { - if (serverPath.isFocused) - updateURI() - } - - override fun afterTextChanged(editable: Editable) {} - }) - - serverUri.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) {} - - override fun onTextChanged(charSequence: CharSequence, i: Int, i2: Int, i3: Int) { - if (serverUri.isFocused) - splitURI() - } - - override fun afterTextChanged(editable: Editable) {} - }) - - if (operationCode == EDIT_SERVER) { - findViewById<View>(R.id.clone_button).visibility = View.INVISIBLE - findViewById<View>(R.id.save_button).visibility = View.VISIBLE - } else { - findViewById<View>(R.id.clone_button).visibility = View.VISIBLE - findViewById<View>(R.id.save_button).visibility = View.INVISIBLE - } - - updateURI() - } - EDIT_GIT_CONFIG -> { - setContentView(R.layout.activity_git_config) - setTitle(R.string.title_activity_git_config) - - showGitConfig() - } - REQUEST_PULL -> syncRepository(REQUEST_PULL) - - REQUEST_PUSH -> syncRepository(REQUEST_PUSH) - - REQUEST_SYNC -> syncRepository(REQUEST_SYNC) - } - } - - /** - * Fills in the server_uri field with the information coming from other fields - */ - private fun updateURI() { - val uri = findViewById<TextInputEditText>(R.id.clone_uri) - val serverUrl = findViewById<TextInputEditText>(R.id.server_url) - val serverPort = findViewById<TextInputEditText>(R.id.server_port) - val serverPath = findViewById<TextInputEditText>(R.id.server_path) - val serverUser = findViewById<TextInputEditText>(R.id.server_user) - - if (uri != null) { - when (protocol) { - "ssh://" -> { - var hostname = (serverUser.text.toString() + - "@" + - serverUrl.text.toString().trim { it <= ' ' } + - ":") - if (serverPort.text.toString() == "22") { - hostname += serverPath.text.toString() - - findViewById<View>(R.id.warn_url).visibility = View.GONE - } else { - val warnUrl = findViewById<AppCompatTextView>(R.id.warn_url) - if (!serverPath.text.toString().matches("/.*".toRegex()) && serverPort.text.toString().isNotEmpty()) { - warnUrl.setText(R.string.warn_malformed_url_port) - warnUrl.visibility = View.VISIBLE - } else { - warnUrl.visibility = View.GONE - } - hostname += serverPort.text.toString() + serverPath.text.toString() - } - - if (hostname != "@:") uri.setText(hostname) - } - "https://" -> { - val hostname = StringBuilder() - hostname.append(serverUrl.text.toString().trim { it <= ' ' }) - - if (serverPort.text.toString() == "443") { - hostname.append(serverPath.text.toString()) - - findViewById<View>(R.id.warn_url).visibility = View.GONE - } else { - hostname.append("/") - hostname.append(serverPort.text.toString()) - .append(serverPath.text.toString()) - } - - if (hostname.toString() != "@/") uri.setText(hostname) - } - else -> { - } - } - } - } - - /** - * Splits the information in server_uri into the other fields - */ - private fun splitURI() { - val serverUri = findViewById<TextInputEditText>(R.id.clone_uri) - val serverUrl = findViewById<TextInputEditText>(R.id.server_url) - val serverPort = findViewById<TextInputEditText>(R.id.server_port) - val serverPath = findViewById<TextInputEditText>(R.id.server_path) - val serverUser = findViewById<TextInputEditText>(R.id.server_user) - - val uri = serverUri.text.toString() - val pattern = Pattern.compile("(.+)@([\\w\\d.]+):([\\d]+)*(.*)") - val matcher = pattern.matcher(uri) - if (matcher.find()) { - val count = matcher.groupCount() - if (count > 1) { - serverUser.setText(matcher.group(1)) - serverUrl.setText(matcher.group(2)) - } - if (count == 4) { - serverPort.setText(matcher.group(3)) - serverPath.setText(matcher.group(4)) - - val warnUrl = findViewById<AppCompatTextView>(R.id.warn_url) - if (!serverPath.text.toString().matches("/.*".toRegex()) && serverPort.text.toString().isNotEmpty()) { - warnUrl.setText(R.string.warn_malformed_url_port) - warnUrl.visibility = View.VISIBLE - } else { - warnUrl.visibility = View.GONE - } - } - } - } - - public override fun onResume() { - super.onResume() - updateURI() - } - - override fun onDestroy() { - // Do not leak the service connection - if (identityBuilder != null) { - identityBuilder!!.close() - identityBuilder = null - } - super.onDestroy() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. - menuInflater.inflate(R.menu.git_clone, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.user_pref -> try { - val intent = Intent(this, UserPreference::class.java) - startActivity(intent) - return true - } catch (e: Exception) { - println("Exception caught :(") - e.printStackTrace() - } - - android.R.id.home -> { - finish() - return true - } - } - return super.onOptionsItemSelected(item) - } - - /** - * Saves the configuration found in the form - */ - private fun saveConfiguration(): Boolean { - // remember the settings - val editor = settings.edit() - - editor.putString("git_remote_server", (findViewById<View>(R.id.server_url) as TextInputEditText).text.toString()) - editor.putString("git_remote_location", (findViewById<View>(R.id.server_path) as TextInputEditText).text.toString()) - editor.putString("git_remote_username", (findViewById<View>(R.id.server_user) as TextInputEditText).text.toString()) - editor.putString("git_remote_protocol", protocol) - editor.putString("git_remote_auth", connectionMode) - editor.putString("git_remote_port", (findViewById<View>(R.id.server_port) as TextInputEditText).text.toString()) - editor.putString("git_remote_uri", (findViewById<View>(R.id.clone_uri) as TextInputEditText).text.toString()) - - // 'save' hostname variable for use by addRemote() either here or later - // in syncRepository() - hostname = (findViewById<View>(R.id.clone_uri) as TextInputEditText).text.toString() - val port = (findViewById<View>(R.id.server_port) as TextInputEditText).text.toString() - // don't ask the user, take off the protocol that he puts in - hostname = hostname.replaceFirst("^.+://".toRegex(), "") - (findViewById<View>(R.id.clone_uri) as TextInputEditText).setText(hostname) - - if (protocol != "ssh://") { - hostname = protocol + hostname - } else { - // if the port is explicitly given, jgit requires the ssh:// - if (port.isNotEmpty() && port != "22") - hostname = protocol + hostname - - // did he forget the username? - if (!hostname.matches("^.+@.+".toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(context.getString(R.string.forget_username_dialog_text)) - .setPositiveButton(context.getString(R.string.dialog_oops), null) - .show() - return false - } - } - if (PasswordRepository.isInitialized && settings.getBoolean("repository_initialized", false)) { - // don't just use the clone_uri text, need to use hostname which has - // had the proper protocol prepended - PasswordRepository.addRemote("origin", hostname, true) - } - - editor.apply() - return true - } - - /** - * Save the repository information to the shared preferences settings - */ - @Suppress("UNUSED_PARAMETER") - fun saveConfiguration(view: View) { - if (!saveConfiguration()) - return - finish() - } - - private fun showGitConfig() { - // init the server information - val username = findViewById<TextInputEditText>(R.id.git_user_name) - val email = findViewById<TextInputEditText>(R.id.git_user_email) - val abort = findViewById<MaterialButton>(R.id.git_abort_rebase) - - username.setText(settings.getString("git_config_user_name", "")) - email.setText(settings.getString("git_config_user_email", "")) - - // git status - val repo = PasswordRepository.getRepository(PasswordRepository.getRepositoryDirectory(context)) - if (repo != null) { - val commitHash = findViewById<AppCompatTextView>(R.id.git_commit_hash) - try { - val objectId = repo.resolve(Constants.HEAD) - val ref = repo.getRef("refs/heads/master") - val head = if (ref.objectId.equals(objectId)) ref.name else "DETACHED" - commitHash.text = String.format("%s (%s)", objectId.abbreviate(8).name(), head) - - // enable the abort button only if we're rebasing - val isRebasing = repo.repositoryState.isRebasing - abort.isEnabled = isRebasing - abort.alpha = if (isRebasing) 1.0f else 0.5f - } catch (e: Exception) { - // ignore - } - } - } - - private fun saveGitConfigs(): Boolean { - // remember the settings - val editor = settings.edit() - - val email = (findViewById<View>(R.id.git_user_email) as TextInputEditText).text!!.toString() - editor.putString("git_config_user_email", email) - editor.putString("git_config_user_name", (findViewById<View>(R.id.git_user_name) as TextInputEditText).text.toString()) - - if (!email.matches(emailPattern.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(context.getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(context.getString(R.string.dialog_oops), null) - .show() - return false - } - - editor.apply() - return true - } - - @Suppress("UNUSED_PARAMETER") - fun applyGitConfigs(view: View) { - if (!saveGitConfigs()) - return - PasswordRepository.setUserName(settings.getString("git_config_user_name", null) ?: "") - PasswordRepository.setUserEmail(settings.getString("git_config_user_email", null) ?: "") - finish() - } - - @Suppress("UNUSED_PARAMETER") - fun abortRebase(view: View) { - launchGitOperation(BREAK_OUT_OF_DETACHED) - } - - @Suppress("UNUSED_PARAMETER") - fun resetToRemote(view: View) { - launchGitOperation(REQUEST_RESET) - } - - /** - * Clones the repository, the directory exists, deletes it - */ - @Suppress("UNUSED_PARAMETER") - fun cloneRepository(view: View) { - if (PasswordRepository.getRepository(null) == null) { - PasswordRepository.initialize(this) - } - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(context)) - - if (!saveConfiguration()) - return - - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if (localDir.exists() && localDir.listFiles()!!.isNotEmpty() && - !(localDir.listFiles()!!.size == 1 && localDir.listFiles()!![0].name == ".git")) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg) + " " + localDir.toString()) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete - ) { dialog, _ -> - try { - FileUtils.deleteDirectory(localDir) - launchGitOperation(REQUEST_CLONE) - } catch (e: IOException) { - // TODO Handle the exception correctly if we are unable to delete the directory... - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete - ) { dialog, _ -> dialog.cancel() } - .show() - } else { - try { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDir.listFiles()!!.size == 1 && localDir.listFiles()!![0].name == ".git") { - try { - FileUtils.deleteDirectory(localDir) - } catch (e: IOException) { - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - } - } catch (e: Exception) { - // This is what happens when jgit fails :( - // TODO Handle the diffent cases of exceptions - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - - launchGitOperation(REQUEST_CLONE) - } - } - - /** - * Syncs the local repository with the remote one (either pull or push) - * - * @param operation the operation to execute can be REQUEST_PULL or REQUEST_PUSH - */ - private fun syncRepository(operation: Int) { - if (settings.getString("git_remote_username", "")!!.isEmpty() || - settings.getString("git_remote_server", "")!!.isEmpty() || - settings.getString("git_remote_location", "")!!.isEmpty()) - MaterialAlertDialogBuilder(this) - .setMessage(context.getString(R.string.set_information_dialog_text)) - .setPositiveButton(context.getString(R.string.dialog_positive)) { _, _ -> - val intent = Intent(context, UserPreference::class.java) - startActivityForResult(intent, REQUEST_PULL) - } - .setNegativeButton(context.getString(R.string.dialog_negative)) { _, _ -> - // do nothing :( - setResult(AppCompatActivity.RESULT_OK) - finish() - } - .show() - else { - // check that the remote origin is here, else add it - PasswordRepository.addRemote("origin", hostname, false) - launchGitOperation(operation) - } - } - - /** - * 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 - */ - private fun launchGitOperation(operation: Int) { - val op: GitOperation - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(context)) - - 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.equals("OpenKeychain", ignoreCase = true) && 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 - } - - when (operation) { - REQUEST_CLONE, GitOperation.GET_SSH_KEY_FROM_CLONE -> op = CloneOperation(localDir, this).setCommand(hostname) - - REQUEST_PULL -> op = PullOperation(localDir, this).setCommand() - - REQUEST_PUSH -> op = PushOperation(localDir, this).setCommand() - - REQUEST_SYNC -> op = SyncOperation(localDir, this).setCommands() - - BREAK_OUT_OF_DETACHED -> op = BreakOutOfDetached(localDir, this).setCommands() - - REQUEST_RESET -> op = ResetToRemoteOperation(localDir, this).setCommands() - - SshApiSessionFactory.POST_SIGNATURE -> return - - else -> { - Timber.tag(TAG).e("Operation not recognized : $operation") - setResult(AppCompatActivity.RESULT_CANCELED) - finish() - return - } - } - - op.executeAfterAuthentication(connectionMode, - settings.getString("git_remote_username", "git")!!, - 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 == AppCompatActivity.RESULT_CANCELED) { - setResult(AppCompatActivity.RESULT_CANCELED) - finish() - } else if (resultCode == AppCompatActivity.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_PULL = 101 - const val REQUEST_PUSH = 102 - const val REQUEST_CLONE = 103 - const val REQUEST_INIT = 104 - const val EDIT_SERVER = 105 - const val REQUEST_SYNC = 106 - - @Suppress("Unused") - const val REQUEST_CREATE = 107 - const val EDIT_GIT_CONFIG = 108 - const val BREAK_OUT_OF_DETACHED = 109 - const val REQUEST_RESET = 110 - private const val TAG = "GitAct" - private const val emailPattern = "^[^@]+@[^@]+$" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt new file mode 100644 index 00000000..8a05ca40 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt @@ -0,0 +1,62 @@ +/* + * 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.Bundle +import android.util.Patterns +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding +import com.zeapo.pwdstore.utils.PasswordRepository +import org.eclipse.jgit.lib.Constants + +class GitConfigActivity : BaseGitActivity() { + + private lateinit var binding: ActivityGitConfigBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityGitConfigBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + binding.gitUserName.setText(username) + binding.gitUserEmail.setText(email) + val repo = PasswordRepository.getRepository(PasswordRepository.getRepositoryDirectory(this)) + if (repo != null) { + try { + val objectId = repo.resolve(Constants.HEAD) + val ref = repo.getRef("refs/heads/master") + val head = if (ref.objectId.equals(objectId)) ref.name else "DETACHED" + binding.gitCommitHash.text = String.format("%s (%s)", objectId.abbreviate(8).name(), head) + + // enable the abort button only if we're rebasing + val isRebasing = repo.repositoryState.isRebasing + binding.gitAbortRebase.isEnabled = isRebasing + binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f + } catch (ignored: Exception) { + } + } + binding.gitAbortRebase.setOnClickListener { launchGitOperation(BREAK_OUT_OF_DETACHED) } + binding.gitResetToRemote.setOnClickListener { launchGitOperation(REQUEST_RESET) } + binding.saveButton.setOnClickListener { + val email = binding.gitUserEmail.text.toString().trim() + val name = binding.gitUserName.text.toString().trim() + if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.invalid_email_dialog_text)) + .setPositiveButton(getString(R.string.dialog_ok), null) + .show() + } else { + val editor = settings.edit() + editor.putString("git_config_user_email", email) + editor.putString("git_config_user_name", name) + PasswordRepository.setUserName(name) + PasswordRepository.setUserEmail(email) + editor.apply() + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt index ea9ee654..78a9ca69 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.kt @@ -7,23 +7,24 @@ package com.zeapo.pwdstore.git import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.text.InputType import android.view.LayoutInflater -import android.view.View -import android.widget.CheckBox -import android.widget.EditText -import android.widget.LinearLayout +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.jcraft.jsch.JSch import com.jcraft.jsch.JSchException import com.jcraft.jsch.KeyPair import com.zeapo.pwdstore.R import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.git.config.ConnectionMode import com.zeapo.pwdstore.git.config.GitConfigSessionFactory import com.zeapo.pwdstore.git.config.SshApiSessionFactory import com.zeapo.pwdstore.git.config.SshConfigSessionFactory import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.getEncryptedPrefs +import com.zeapo.pwdstore.utils.requestInputFocusOnView import java.io.File import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.lib.Repository @@ -96,7 +97,7 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit * @param identity the api identity to use for auth in OpenKeychain connection mode */ fun executeAfterAuthentication( - connectionMode: String, + connectionMode: ConnectionMode, username: String, sshKey: File?, identity: SshApiSessionFactory.ApiIdentity? @@ -114,15 +115,17 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit * @param showError show the passphrase edit text in red */ private fun executeAfterAuthentication( - connectionMode: String, + connectionMode: ConnectionMode, username: String, sshKey: File?, identity: SshApiSessionFactory.ApiIdentity?, showError: Boolean ) { - if (connectionMode.equals("ssh-key", ignoreCase = true)) { - if (sshKey == null || !sshKey.exists()) { - MaterialAlertDialogBuilder(callingActivity) + val encryptedSettings = callingActivity.applicationContext.getEncryptedPrefs("git_operation") + when (connectionMode) { + ConnectionMode.SshKey -> { + if (sshKey == null || !sshKey.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)) { _, _ -> @@ -152,82 +155,100 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit // Finish the blank GitActivity so user doesn't have to press back callingActivity.finish() }.show() - } else { - val layoutInflater = LayoutInflater.from(callingActivity.applicationContext) - @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_passphrase_layout, null) - val passphrase = dialogView.findViewById<EditText>(R.id.sshkey_passphrase) - val settings = PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - val sshKeyPassphrase = settings.getString("ssh_key_passphrase", null) - if (showError) { - passphrase.error = "Wrong passphrase" - } - val jsch = JSch() - try { - val keyPair = KeyPair.load(jsch, callingActivity.filesDir.toString() + "/.ssh_key") + } else { + val layoutInflater = LayoutInflater.from(callingActivity) + @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_passphrase_layout, null) + val passphrase = dialogView.findViewById<TextInputEditText>(R.id.git_auth_passphrase) + val sshKeyPassphrase = encryptedSettings.getString("ssh_key_local_passphrase", null) + if (showError) { + passphrase.error = callingActivity.resources.getString(R.string.git_operation_wrong_passphrase) + } + val jsch = JSch() + try { + val keyPair = KeyPair.load(jsch, callingActivity.filesDir.toString() + "/.ssh_key") - if (keyPair.isEncrypted) { - if (sshKeyPassphrase != null && sshKeyPassphrase.isNotEmpty()) { - if (keyPair.decrypt(sshKeyPassphrase)) { - // Authenticate using the ssh-key and then execute the command - setAuthentication(sshKey, username, sshKeyPassphrase).execute() + if (keyPair.isEncrypted) { + if (sshKeyPassphrase != null && sshKeyPassphrase.isNotEmpty()) { + if (keyPair.decrypt(sshKeyPassphrase)) { + // Authenticate using the ssh-key and then execute the command + setAuthentication(sshKey, username, sshKeyPassphrase).execute() + } else { + // call back the method + executeAfterAuthentication(connectionMode, username, sshKey, identity, true) + } } else { - // call back the method - executeAfterAuthentication(connectionMode, username, sshKey, identity, true) - } - } else { - MaterialAlertDialogBuilder(callingActivity) + val dialog = MaterialAlertDialogBuilder(callingActivity) .setTitle(callingActivity.resources.getString(R.string.passphrase_dialog_title)) .setMessage(callingActivity.resources.getString(R.string.passphrase_dialog_text)) .setView(dialogView) .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> if (keyPair.decrypt(passphrase.text.toString())) { - val rememberPassphrase = (dialogView.findViewById<View>(R.id.sshkey_remember_passphrase) as CheckBox).isChecked + val rememberPassphrase = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_passphrase).isChecked if (rememberPassphrase) { - settings.edit().putString("ssh_key_passphrase", passphrase.text.toString()).apply() + encryptedSettings.edit().putString("ssh_key_local_passphrase", passphrase.text.toString()).apply() } // Authenticate using the ssh-key and then execute the command setAuthentication(sshKey, username, passphrase.text.toString()).execute() } else { - settings.edit().putString("ssh_key_passphrase", null).apply() + encryptedSettings.edit().putString("ssh_key_local_passphrase", null).apply() // call back the method executeAfterAuthentication(connectionMode, username, sshKey, identity, true) } - }.setNegativeButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - // Do nothing. - }.show() + } + .setNegativeButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + callingActivity.finish() + } + .setOnCancelListener { callingActivity.finish() } + .create() + dialog.requestInputFocusOnView<TextInputEditText>(R.id.git_auth_passphrase) + dialog.show() + } + } else { + setAuthentication(sshKey, username, "").execute() } - } else { - setAuthentication(sshKey, username, "").execute() - } - } catch (e: JSchException) { - e.printStackTrace() - MaterialAlertDialogBuilder(callingActivity) - .setTitle("Unable to open the ssh-key") - .setMessage("Please check that it was imported.") - .setPositiveButton("Ok") { _, _ -> callingActivity.finish() } + } catch (e: JSchException) { + e.printStackTrace() + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_operation_unable_to_open_ssh_key_title)) + .setMessage(callingActivity.resources.getString(R.string.git_operation_unable_to_open_ssh_key_message)) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + } .show() + } + } + } + ConnectionMode.OpenKeychain -> { + setAuthentication(username, identity).execute() + } + ConnectionMode.Password -> { + @SuppressLint("InflateParams") val dialogView = callingActivity.layoutInflater.inflate(R.layout.git_passphrase_layout, null) + val passwordView = dialogView.findViewById<TextInputEditText>(R.id.git_auth_passphrase) + val password = encryptedSettings.getString("https_password", null) + if (password != null && password.isNotEmpty()) { + setAuthentication(username, password).execute() + } else { + val dialog = MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.passphrase_dialog_title)) + .setMessage(callingActivity.resources.getString(R.string.password_dialog_text)) + .setView(dialogView) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + val rememberPassphrase = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_passphrase).isChecked + if (rememberPassphrase) { + encryptedSettings.edit().putString("https_password", passwordView.text.toString()).apply() + } + // authenticate using the user/pwd and then execute the command + setAuthentication(username, passwordView.text.toString()).execute() + } + .setNegativeButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + callingActivity.finish() + } + .setOnCancelListener { callingActivity.finish() } + .create() + dialog.requestInputFocusOnView<TextInputEditText>(R.id.git_auth_passphrase) + dialog.show() } } - } else if (connectionMode.equals("OpenKeychain", ignoreCase = true)) { - setAuthentication(username, identity).execute() - } else { - val password = EditText(callingActivity) - password.hint = "Password" - password.width = LinearLayout.LayoutParams.MATCH_PARENT - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.passphrase_dialog_title)) - .setMessage(callingActivity.resources.getString(R.string.password_dialog_text)) - .setView(password) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - // authenticate using the user/pwd and then execute the command - setAuthentication(username, password.text.toString()).execute() - } - .setNegativeButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> - callingActivity.finish() - } - .show() } } @@ -235,10 +256,17 @@ abstract class GitOperation(fileDir: File, internal val callingActivity: Activit * Action to execute on error */ open fun onError(errorMessage: String) { + // Clear various auth related fields on failure if (SshSessionFactory.getInstance() is SshApiSessionFactory) { - // Clear stored key id from settings on auth failure PreferenceManager.getDefaultSharedPreferences(callingActivity.applicationContext) - .edit().putString("ssh_openkeystore_keyid", null).apply() + .edit { putString("ssh_openkeystore_keyid", null) } + callingActivity.applicationContext + .getEncryptedPrefs("git_operation") + .edit { remove("ssh_key_local_passphrase") } + } else if (SshSessionFactory.getInstance() is GitConfigSessionFactory) { + callingActivity.applicationContext + .getEncryptedPrefs("git_operation") + .edit { remove("https_password") } } } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt new file mode 100644 index 00000000..bf7d5acc --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperationActivity.kt @@ -0,0 +1,72 @@ +/* + * 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.os.Bundle +import android.view.Menu +import android.view.MenuItem +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.utils.PasswordRepository + +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) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu; this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.git_clone, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.user_pref -> try { + val intent = Intent(this, UserPreference::class.java) + startActivity(intent) + true + } catch (e: Exception) { + println("Exception caught :(") + e.printStackTrace() + false + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Syncs the local repository with the remote one (either pull or push) + * + * @param operation the operation to execute can be REQUEST_PULL or REQUEST_PUSH + */ + private fun syncRepository(operation: Int) { + if (serverUser.isEmpty() || serverHostname.isEmpty() || url.isEmpty()) + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.set_information_dialog_text)) + .setPositiveButton(getString(R.string.dialog_positive)) { _, _ -> + val intent = Intent(this, UserPreference::class.java) + startActivityForResult(intent, REQUEST_PULL) + } + .setNegativeButton(getString(R.string.dialog_negative)) { _, _ -> + // do nothing :( + setResult(RESULT_OK) + finish() + } + .show() + else { + // check that the remote origin is here, else add it + PasswordRepository.addRemote("origin", url, true) + launchGitOperation(operation) + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt new file mode 100644 index 00000000..a10f3460 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt @@ -0,0 +1,185 @@ +/* + * 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.Bundle +import android.os.Handler +import androidx.core.content.edit +import androidx.core.os.postDelayed +import androidx.core.widget.doOnTextChanged +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding +import com.zeapo.pwdstore.git.config.ConnectionMode +import com.zeapo.pwdstore.git.config.Protocol +import com.zeapo.pwdstore.utils.PasswordRepository +import java.io.IOException + +/** + * Activity that encompasses both the initial clone as well as editing the server config for future + * changes. + */ +class GitServerConfigActivity : BaseGitActivity() { + + lateinit var binding: ActivityGitCloneBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityGitCloneBinding.inflate(layoutInflater) + val isClone = intent?.extras?.getInt(REQUEST_ARG_OP) ?: -1 == REQUEST_CLONE + if (isClone) { + binding.saveButton.text = getString(R.string.clone_button) + } + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val protocolIdToCheck = when (protocol) { + Protocol.Ssh -> R.id.clone_protocol_ssh + Protocol.Https -> R.id.clone_protocol_https + } + binding.cloneProtocolGroup.check(protocolIdToCheck) + binding.cloneProtocolGroup.addOnButtonCheckedListener { _, checkedId, checked -> + if (checked) { + when (checkedId) { + R.id.clone_protocol_https -> protocol = Protocol.Https + R.id.clone_protocol_ssh -> protocol = Protocol.Ssh + } + updateConnectionModeToggleGroup() + } + } + + val connectionModeIdToCheck = when (connectionMode) { + ConnectionMode.SshKey -> R.id.connection_mode_ssh_key + ConnectionMode.Password -> R.id.connection_mode_password + ConnectionMode.OpenKeychain -> R.id.connection_mode_open_keychain + } + binding.connectionModeGroup.check(connectionModeIdToCheck) + binding.connectionModeGroup.addOnButtonCheckedListener { _, checkedId, checked -> + if (checked) { + when (checkedId) { + R.id.connection_mode_ssh_key -> connectionMode = ConnectionMode.SshKey + R.id.connection_mode_open_keychain -> connectionMode = ConnectionMode.OpenKeychain + R.id.connection_mode_password -> connectionMode = ConnectionMode.Password + } + } + } + updateConnectionModeToggleGroup() + + binding.serverUrl.apply { + setText(serverHostname) + doOnTextChanged { text, _, _, _ -> + serverHostname = text.toString().trim() + } + } + + binding.serverPort.apply { + setText(serverPort) + doOnTextChanged { text, _, _, _ -> + serverPort = text.toString().trim() + } + } + + binding.serverUser.apply { + setText(serverUser) + doOnTextChanged { text, _, _, _ -> + serverUser = text.toString().trim() + } + } + + binding.serverPath.apply { + setText(serverPath) + doOnTextChanged { text, _, _, _ -> + serverPath = text.toString().trim() + } + } + + binding.saveButton.setOnClickListener { + if (isClone && PasswordRepository.getRepository(null) == null) + PasswordRepository.initialize(this) + if (updateUrl()) { + settings.edit { + putString("git_remote_protocol", protocol.pref) + putString("git_remote_auth", connectionMode.pref) + putString("git_remote_server", serverHostname) + putString("git_remote_port", serverPort) + putString("git_remote_username", serverUser) + putString("git_remote_location", serverPath) + } + if (!isClone) { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler().postDelayed(500) { finish() } + } else + cloneRepository() + } else { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_failure), Snackbar.LENGTH_LONG).show() + } + } + } + + private fun updateConnectionModeToggleGroup() { + if (protocol == Protocol.Ssh) { + binding.connectionModeSshKey.isEnabled = true + binding.connectionModeOpenKeychain.isEnabled = true + } else { + // Reset connection mode to the only one possible via HTTPS: password. + // Important note: This has to happen before disabling the other toggle buttons or they + // won't uncheck. + binding.connectionModeGroup.check(R.id.connection_mode_password) + binding.connectionModeSshKey.isEnabled = false + binding.connectionModeOpenKeychain.isEnabled = false + } + } + + /** + * Clones the repository, the directory exists, deletes it + */ + private fun cloneRepository() { + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory(this)) + val localDirFiles = localDir.listFiles() ?: emptyArray() + // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder + if (localDir.exists() && localDirFiles.isNotEmpty() && + !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg) + " " + localDir.toString()) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialog, _ -> + try { + localDir.deleteRecursively() + launchGitOperation(REQUEST_CLONE) + } catch (e: IOException) { + // TODO Handle the exception correctly if we are unable to delete the directory... + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } finally { + dialog.cancel() + } + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> + dialog.cancel() + } + .show() + } else { + try { + // Silently delete & replace the lone .git folder if it exists + if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { + try { + localDir.deleteRecursively() + } catch (e: IOException) { + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + } + } catch (e: Exception) { + // This is what happens when JGit fails :( + // TODO Handle the different cases of exceptions + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + launchGitOperation(REQUEST_CLONE) + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/ConnectionMode.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/ConnectionMode.kt new file mode 100644 index 00000000..9316e89f --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/ConnectionMode.kt @@ -0,0 +1,19 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.config + +enum class ConnectionMode(val pref: String) { + SshKey("ssh-key"), + Password("username/password"), + OpenKeychain("OpenKeychain"); + + companion object { + private val map = values().associateBy(ConnectionMode::pref) + fun fromString(type: String?): ConnectionMode { + return map[type ?: return SshKey] + ?: throw IllegalArgumentException("$type is not a valid ConnectionMode") + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/Protocol.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/Protocol.kt new file mode 100644 index 00000000..1909fe85 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/Protocol.kt @@ -0,0 +1,18 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package com.zeapo.pwdstore.git.config + +enum class Protocol(val pref: String) { + Ssh("ssh://"), + Https("https://"); + + companion object { + private val map = values().associateBy(Protocol::pref) + fun fromString(type: String?): Protocol { + return map[type ?: return Ssh] + ?: throw IllegalArgumentException("$type is not a valid Protocol") + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java index a75ddef8..6b82cac9 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java @@ -18,7 +18,7 @@ import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; import com.jcraft.jsch.UserInfo; import com.zeapo.pwdstore.R; -import com.zeapo.pwdstore.git.GitActivity; +import com.zeapo.pwdstore.git.BaseGitActivity; import java.util.List; import java.util.concurrent.CountDownLatch; import org.eclipse.jgit.errors.UnsupportedCredentialItem; @@ -107,7 +107,7 @@ public class SshApiSessionFactory extends GitConfigSessionFactory { private SshAuthenticationApi api; private String keyId, description, alg; private byte[] publicKey; - private GitActivity callingActivity; + private BaseGitActivity callingActivity; private SharedPreferences settings; /** @@ -116,7 +116,7 @@ public class SshApiSessionFactory extends GitConfigSessionFactory { * @param callingActivity Activity that will be used to launch pending intents and that will * receive and handle the results. */ - public IdentityBuilder(GitActivity callingActivity) { + public IdentityBuilder(BaseGitActivity callingActivity) { this.callingActivity = callingActivity; List<String> providers = diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt index 1426ef1e..a5ff0bc1 100644 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt +++ b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt @@ -6,13 +6,13 @@ package com.zeapo.pwdstore.ui.dialogs import android.app.Dialog import android.os.Bundle -import android.view.inputmethod.InputMethodManager import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.zeapo.pwdstore.PasswordStore import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.utils.requestInputFocusOnView import java.io.File class FolderCreationDialogFragment : DialogFragment() { @@ -28,18 +28,7 @@ class FolderCreationDialogFragment : DialogFragment() { dismiss() } val dialog = alertDialogBuilder.create() - dialog.setOnShowListener { - // https://stackoverflow.com/a/13056259/297261 - dialog.findViewById<TextInputEditText>(R.id.folder_name_text)!!.apply { - setOnFocusChangeListener { v, _ -> - v.post { - val imm = activity!!.getSystemService(InputMethodManager::class.java) - imm?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) - } - } - requestFocus() - } - } + dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text) return dialog } 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 f7a637c3..6f4ebab1 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt @@ -5,10 +5,18 @@ package com.zeapo.pwdstore.utils import android.content.Context +import android.content.SharedPreferences import android.os.Build import android.util.TypedValue +import android.view.View import android.view.autofill.AutofillManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.IdRes import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.core.content.getSystemService +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys infix fun Int.hasFlag(flag: Int): Boolean { return this and flag == flag @@ -24,6 +32,37 @@ fun Context.resolveAttribute(attr: Int): Int { return typedValue.data } +fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { + val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC + val masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec) + return EncryptedSharedPreferences.create( + fileName, + masterKeyAlias, + this, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} + +/** + * Extension function for [AlertDialog] that requests focus for the + * view whose id is [id]. Solution based on a StackOverflow + * answer: https://stackoverflow.com/a/13056259/297261 + */ +fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) { + setOnShowListener { + findViewById<T>(id)?.apply { + setOnFocusChangeListener { v, _ -> + v.post { + context.getSystemService<InputMethodManager>() + ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) + } + } + requestFocus() + } + } +} + val Context.autofillManager: AutofillManager? @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService(AutofillManager::class.java) diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt index 1cf8fea0..2cc19ae3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt +++ b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt @@ -95,7 +95,7 @@ open class PasswordRepository protected constructor() { // TODO add multiple remotes support for pull/push @JvmStatic - fun addRemote(name: String, url: String, replace: Boolean?) { + fun addRemote(name: String, url: String, replace: Boolean = false) { val storedConfig = repository!!.config val remotes = storedConfig.getSubsections("remote") @@ -116,7 +116,7 @@ open class PasswordRepository protected constructor() { } catch (e: Exception) { e.printStackTrace() } - } else if (replace!!) { + } else if (replace) { try { val uri = URIish(url) @@ -181,16 +181,6 @@ open class PasswordRepository protected constructor() { } /** - * Gets the password items in the root directory - * - * @return a list of passwords in the root directory - */ - @JvmStatic - fun getPasswords(rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> { - return getPasswords(rootDir, rootDir, sortOrder) - } - - /** * Gets the .gpg files in a directory * * @param path the directory path |