diff options
| author | Harsh Shandilya <msfjarvis@gmail.com> | 2020-06-12 14:58:15 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-06-12 20:28:15 +0530 |
| commit | d8231e112afb501c43041a6f839ab8285f400f77 (patch) | |
| tree | 6785a8d0c6a70a3017ce5d62fe6b8234d5564f07 /app/src/main/java/com/zeapo/pwdstore/crypto | |
| parent | bf33fb2c88a208931340201e95a524b274d70b31 (diff) | |
| download | HardenedPass-d8231e112afb501c43041a6f839ab8285f400f77.tar.gz HardenedPass-d8231e112afb501c43041a6f839ab8285f400f77.zip | |
Break down PGP Activity into focused sections (#776)
Diffstat (limited to 'app/src/main/java/com/zeapo/pwdstore/crypto')
5 files changed, 857 insertions, 788 deletions
diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt new file mode 100644 index 00000000..5206a15f --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt @@ -0,0 +1,270 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.crypto + +import android.app.PendingIntent +import android.content.ClipData +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.CallSuper +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceManager +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.google.android.material.snackbar.Snackbar +import com.zeapo.pwdstore.ClipboardService +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.UserPreference +import com.zeapo.pwdstore.utils.clipboard +import com.zeapo.pwdstore.utils.snackbar +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError +import java.io.File + +@Suppress("Registered") +open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { + + /** + * Full path to the repository + */ + val repoPath: String by lazy { intent.getStringExtra("REPO_PATH") } + + /** + * Full path to the password file being worked on + */ + val fullPath: String by lazy { intent.getStringExtra("FILE_PATH") } + + /** + * Name of the password file + * + * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org + */ + val name: String by lazy { File(fullPath).nameWithoutExtension } + + /** + * Get the timestamp for when this file was last modified. + */ + val lastChangedString: CharSequence by lazy { + getLastChangedString( + intent.getLongExtra( + "LAST_CHANGED_TIMESTAMP", + -1L + ) + ) + } + + /** + * [SharedPreferences] instance used by subclasses to persist settings + */ + val settings: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } + + /** + * Read-only field for getting the list of OpenPGP key IDs that we have access to. + */ + var keyIDs = emptySet<String>() + private set + + /** + * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. + */ + private var serviceConnection: OpenPgpServiceConnection? = null + var api: OpenPgpApi? = null + + /** + * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots + * or recent apps screen and fills in [keyIDs] from [settings] + */ + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + tag(TAG) + + keyIDs = settings.getStringSet("openpgp_key_ids_set", null) ?: emptySet() + } + + /** + * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This + * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not + * leaking things. + */ + @CallSuper + override fun onDestroy() { + super.onDestroy() + serviceConnection?.unbindFromService() + } + + /** + * Sets up [api] once the service is bound. Downstream consumers must call super this to + * initialize [api] + */ + @CallSuper + override fun onBound(service: IOpenPgpService2) { + api = OpenPgpApi(this, service) + } + + /** + * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle + * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super. + */ + override fun onError(e: Exception) { + e(e) { "Callers must handle their own exceptions" } + throw e + } + + /** + * Method for subclasses to initiate binding with [OpenPgpServiceConnection]. The design choices + * here are a bit dubious at first glance. We require passing a [ActivityResultLauncher] because + * it lets us react to having a OpenPgp provider selected without relying on the now deprecated + * [startActivityForResult]. + */ + fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound, activityResult: ActivityResultLauncher<Intent>) { + val providerPackageName = settings.getString("openpgp_provider_list", "") + if (providerPackageName.isNullOrEmpty()) { + Toast.makeText(this, resources.getString(R.string.provider_toast_text), Toast.LENGTH_LONG).show() + activityResult.launch(Intent(this, UserPreference::class.java)) + } else { + serviceConnection = OpenPgpServiceConnection(this, providerPackageName, onBoundListener) + serviceConnection?.bindToService() + } + } + + /** + * Handle the case where OpenKeychain returns that it needs to interact with the user + * + * @param result The intent returned by OpenKeychain + */ + fun getUserInteractionRequestIntent(result: Intent): IntentSender { + i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } + return (result.getParcelableExtra(OpenPgpApi.RESULT_INTENT) as PendingIntent).intentSender + } + + /** + * Gets a relative string describing when this shape was last changed + * (e.g. "one hour ago") + */ + private fun getLastChangedString(timeStamp: Long): CharSequence { + if (timeStamp < 0) { + throw RuntimeException() + } + + return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) + } + /** + * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses + * can use this when they want to default to sane error handling. + */ + fun handleError(result: Intent) { + val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + when (error.errorId) { + OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { + snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) + } + OpenPgpError.NO_USER_IDS -> { + snackbar(message = getString(R.string.openpgp_error_no_user_ids)) + } + else -> { + snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) + e { "onError getErrorId: ${error.errorId}" } + e { "onError getMessage: ${error.message}" } + } + } + } + } + + /** + * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing + * [showSnackbar] as false. + */ + fun copyTextToClipboard(text: String?, showSnackbar: Boolean = true) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clipboard.setPrimaryClip(clip) + if (showSnackbar) { + snackbar(message = resources.getString(R.string.clipboard_copied_text)) + } + } + + /** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to + * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a + * way of clearing the clipboard. + */ + fun copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password, showSnackbar = false) + + var clearAfter = 45 + try { + clearAfter = (settings.getString("general_show_time", "45") ?: "45").toInt() + } catch (_: NumberFormatException) { + } + + if (clearAfter != 0) { + val service = Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(service) + } else { + startService(service) + } + snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) + } else { + snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) + } + } + + companion object { + private const val TAG = "APS/BasePgpActivity" + const val KEY_PWGEN_TYPE_CLASSIC = "classic" + const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + + /** + * Gets the relative path to the repository + */ + fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + + /** + * Gets the Parent path, relative to the repository + */ + fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") + } + + /** + * /path/to/store/social/facebook.gpg -> social/facebook + */ + @JvmStatic + fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt new file mode 100644 index 00000000..b7d7adcd --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt @@ -0,0 +1,202 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.crypto + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.text.method.PasswordTransformationMethod +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.PasswordEntry +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.databinding.DecryptLayoutBinding +import com.zeapo.pwdstore.utils.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import java.io.ByteArrayOutputStream +import java.io.File + +class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + private val binding by viewBinding(DecryptLayoutBinding::inflate) + + private val relativeParentPath by lazy { getParentPath(fullPath, repoPath) } + private var passwordEntry: PasswordEntry? = null + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> decryptAndVerify(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + private val openKeychainResult = registerForActivityResult(StartActivityForResult()) { + decryptAndVerify() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this, openKeychainResult) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + try { + passwordLastChanged.text = resources.getString(R.string.last_changed, lastChangedString) + } catch (e: RuntimeException) { + passwordLastChanged.visibility = View.GONE + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + R.id.edit_password -> editPassword() + R.id.share_password_as_plaintext -> shareAsPlaintext() + R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) + } + return super.onOptionsItemSelected(item) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + decryptAndVerify() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Edit the current password and hide all the fields populated by encrypted data so that when + * the result triggers they can be repopulated with new data. + */ + private fun editPassword() { + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent) + intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) + startActivity(intent) + finish() + } + + private fun shareAsPlaintext() { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) + type = "text/plain" + } + // Always show a picker to give the user a chance to cancel + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) + } + + private fun decryptAndVerify(receivedIntent: Intent? = null) { + if (api == null) { + bindToOpenKeychain(this, openKeychainResult) + return + } + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + + val inputStream = File(fullPath).inputStream() + val outputStream = ByteArrayOutputStream() + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + try { + val showPassword = settings.getBoolean("show_password", true) + val showExtraContent = settings.getBoolean("show_extra_content", true) + val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") + val entry = PasswordEntry(outputStream) + + passwordEntry = entry + + with(binding) { + if (entry.password.isEmpty()) { + passwordTextContainer.visibility = View.GONE + } else { + passwordTextContainer.visibility = View.VISIBLE + passwordText.typeface = monoTypeface + passwordText.setText(entry.password) + if (!showPassword) { + passwordText.transformationMethod = PasswordTransformationMethod.getInstance() + } + passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) } + passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) } + } + + if (entry.hasExtraContent()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutUsername) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } + + if (entry.hasUsername()) { + usernameText.typeface = monoTypeface + usernameText.setText(entry.username) + usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) } + usernameTextContainer.visibility = View.VISIBLE + } else { + usernameTextContainer.visibility = View.GONE + } + } + } + + if (settings.getBoolean("copy_on_decrypt", true)) { + copyPasswordToClipboard(entry.password) + } + } catch (e: Exception) { + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt new file mode 100644 index 00000000..94d5b68c --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt @@ -0,0 +1,89 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.crypto + +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.Timber +import com.github.ajalt.timberkt.e +import com.zeapo.pwdstore.utils.snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import org.openintents.openpgp.IOpenPgpService2 + +class GetKeyIdsActivity : BasePgpActivity() { + + private val getKeyIds = registerForActivityResult(StartActivityForResult()) { getKeyIds() } + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> getKeyIds(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this, getKeyIds) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + getKeyIds() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Get the Key ids from OpenKeychain + */ + private fun getKeyIds(receivedIntent: Intent? = null) { + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_GET_KEY_IDS + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, null, null) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + try { + val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) + ?: LongArray(0) + val keys = ids.map { it.toString() }.toSet() + // use Long + settings.edit { putStringSet("openpgp_key_ids_set", keys) } + snackbar(message = "PGP keys selected") + setResult(RESULT_OK) + finish() + } catch (e: Exception) { + Timber.e(e) { "An Exception occurred" } + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt new file mode 100644 index 00000000..7216a506 --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt @@ -0,0 +1,296 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package com.zeapo.pwdstore.crypto + +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.zeapo.pwdstore.PasswordEntry +import com.zeapo.pwdstore.R +import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences +import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure +import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding +import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment +import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment +import com.zeapo.pwdstore.utils.PasswordRepository +import com.zeapo.pwdstore.utils.commitChange +import com.zeapo.pwdstore.utils.snackbar +import com.zeapo.pwdstore.utils.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.eclipse.jgit.api.Git +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException + +class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + + private val suggestedName by lazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by lazy { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by lazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by lazy { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + private val doNothing = registerForActivityResult(StartActivityForResult()) {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this, doNothing) + title = if (intent.getBooleanExtra(EXTRA_EDITING, false)) + getString(R.string.edit_password) + else + getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + + category.apply { + if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + val path = getRelativePath(fullPath, repoPath) + // Keep empty path field visible if it is editable. + if (path.isEmpty() && !isEnabled) + visibility = View.GONE + else + setText(path) + } + suggestedName?.let { filename.setText(it) } + // Allow the user to quickly switch between storing the username as the filename or + // in the encrypted extras. This only makes sense if the directory structure is + // FileBased. + if (suggestedName == null && + AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == + DirectoryStructure.FileBased + ) { + encryptUsername.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = filename.text.toString() + val extras = "username:$username\n${extraContent.text}" + + filename.setText("") + extraContent.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = PasswordEntry("PASSWORD\n${extraContent.text}") + val username = entry.username + + // username should not be null here by the logic in + // updateEncryptUsernameState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutUsername) + } + } + updateEncryptUsernameState() + } + } + listOf(filename, extraContent).forEach { + it.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } + } + } + suggestedPass?.let { + password.setText(it) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { extraContent.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler_new_password, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home, R.id.cancel_password_add -> { + setResult(RESULT_CANCELED) + finish() + } + R.id.save_password -> encrypt() + R.id.save_and_copy_password -> encrypt(copy = true) + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { + KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() + .show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() + .show(supportFragmentManager, "xkpwgenerator") + } + } + + private fun updateEncryptUsernameState() = with(binding) { + encryptUsername.apply { + if (visibility != View.VISIBLE) + return@with + val hasUsernameInFileName = filename.text.toString().isNotBlank() + // Use PasswordEntry to parse extras for username + val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") + val hasUsernameInExtras = entry.hasUsername() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + } + + /** + * Encrypts the password and the extra content + */ + private fun encrypt(copy: Boolean = false) = with(binding) { + val editName = filename.text.toString().trim() + val editPass = password.text.toString() + val editExtra = extraContent.text.toString() + + if (editName.isEmpty()) { + snackbar(message = resources.getString(R.string.file_toast_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + val data = Intent() + data.action = OpenPgpApi.ACTION_ENCRYPT + + // EXTRA_KEY_IDS requires long[] + val longKeys = keyIDs.map { it.toLong() } + data.putExtra(OpenPgpApi.EXTRA_KEY_IDS, longKeys.toLongArray()) + data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true) + + val content = "$editPass\n$editExtra" + val inputStream = ByteArrayInputStream(content.toByteArray()) + val outputStream = ByteArrayOutputStream() + + val path = when { + // If we allowed the user to edit the relative path, we have to consider it here instead + // of fullPath. + category.isEnabled -> { + val editRelativePath = category.text.toString().trim() + if (editRelativePath.isEmpty()) { + snackbar(message = resources.getString(R.string.path_toast_text)) + return + } + val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") + if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { + snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") + return + } + + "${passwordDirectory.path}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + try { + val file = File(path) + try { + file.outputStream().use { + it.write(outputStream.toByteArray()) + } + } catch (e: IOException) { + e(e) { "Failed to write password file" } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(getString(R.string.password_creation_file_write_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .show() + } + + val returnIntent = Intent() + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) + + if (shouldGeneratePassword) { + val directoryStructure = + AutofillPreferences.directoryStructure(applicationContext) + val entry = PasswordEntry(content) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = PasswordEntry(content).username + ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + val repo = PasswordRepository.getRepository(null) + if (repo != null) { + val status = Git(repo).status().call() + if (status.modified.isNotEmpty()) { + commitChange( + getString( + R.string.git_commit_edit_text, + getLongName(fullPath, repoPath, editName) + ) + ) + } + } + setResult(RESULT_OK, returnIntent) + finish() + } catch (e: Exception) { + e(e) { "An Exception occurred" } + } + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } + + companion object { + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" + const val RETURN_EXTRA_NAME = "NAME" + const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" + const val RETURN_EXTRA_USERNAME = "USERNAME" + const val RETURN_EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_FILE_NAME = "FILENAME" + const val EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" + const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" + const val EXTRA_EDITING = "EDITING" + } +} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt deleted file mode 100644 index 77d7e70c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PgpActivity.kt +++ /dev/null @@ -1,788 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.crypto - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.IntentSender -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Build -import android.os.Bundle -import android.text.InputType -import android.text.TextUtils -import android.text.format.DateUtils -import android.text.method.PasswordTransformationMethod -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.preference.PreferenceManager -import com.github.ajalt.timberkt.Timber.e -import com.github.ajalt.timberkt.Timber.i -import com.github.ajalt.timberkt.Timber.tag -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.ClipboardService -import com.zeapo.pwdstore.PasswordEntry -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment -import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment -import kotlinx.android.synthetic.main.decrypt_layout.crypto_password_category_decrypt -import kotlinx.android.synthetic.main.decrypt_layout.crypto_password_file -import kotlinx.android.synthetic.main.decrypt_layout.crypto_password_last_changed -import kotlinx.android.synthetic.main.decrypt_layout.extra_content -import kotlinx.android.synthetic.main.decrypt_layout.extra_content_container -import kotlinx.android.synthetic.main.decrypt_layout.password_text -import kotlinx.android.synthetic.main.decrypt_layout.password_text_container -import kotlinx.android.synthetic.main.decrypt_layout.username_text -import kotlinx.android.synthetic.main.decrypt_layout.username_text_container -import kotlinx.android.synthetic.main.encrypt_layout.crypto_extra_edit -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_category -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_edit -import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_file_edit -import kotlinx.android.synthetic.main.encrypt_layout.encrypt_username -import kotlinx.android.synthetic.main.encrypt_layout.generate_password -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.launch -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.ACTION_DECRYPT_VERIFY -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_CODE -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_CODE_ERROR -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_CODE_SUCCESS -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_CODE_USER_INTERACTION_REQUIRED -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_ERROR -import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.RESULT_INTENT -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 -import org.openintents.openpgp.OpenPgpError -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.nio.charset.Charset - -class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { - private val clipboard by lazy { getSystemService<ClipboardManager>() } - private var passwordEntry: PasswordEntry? = null - private var api: OpenPgpApi? = null - - private var editName: String? = null - private var editPass: String? = null - private var editExtra: String? = null - - private val suggestedName by lazy { intent.getStringExtra("SUGGESTED_NAME") } - private val suggestedPass by lazy { intent.getStringExtra("SUGGESTED_PASS") } - private val suggestedExtra by lazy { intent.getStringExtra("SUGGESTED_EXTRA") } - private val shouldGeneratePassword by lazy { intent.getBooleanExtra("GENERATE_PASSWORD", false) } - - private val operation: String by lazy { intent.getStringExtra("OPERATION") } - private val repoPath: String by lazy { intent.getStringExtra("REPO_PATH") } - - private val fullPath: String by lazy { intent.getStringExtra("FILE_PATH") } - private val name: String by lazy { File(fullPath).nameWithoutExtension } - private val lastChangedString: CharSequence by lazy { - getLastChangedString( - intent.getLongExtra( - "LAST_CHANGED_TIMESTAMP", - -1L - ) - ) - } - private val relativeParentPath: String by lazy { getParentPath(fullPath, repoPath) } - - val settings: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) } - private val keyIDs get() = _keyIDs - private var _keyIDs = emptySet<String>() - private var serviceConnection: OpenPgpServiceConnection? = null - private var delayTask: DelayShow? = null - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - delayTask?.doOnPostExecute() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - tag(TAG) - - // some persistence - _keyIDs = settings.getStringSet("openpgp_key_ids_set", null) ?: emptySet() - val providerPackageName = settings.getString("openpgp_provider_list", "") - - if (TextUtils.isEmpty(providerPackageName)) { - showSnackbar(resources.getString(R.string.provider_toast_text), Snackbar.LENGTH_LONG) - val intent = Intent(this, UserPreference::class.java) - startActivityForResult(intent, OPEN_PGP_BOUND) - } else { - // bind to service - serviceConnection = OpenPgpServiceConnection(this, providerPackageName, this) - serviceConnection?.bindToService() - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - when (operation) { - "DECRYPT", "EDIT" -> { - setContentView(R.layout.decrypt_layout) - crypto_password_category_decrypt.text = relativeParentPath - crypto_password_file.text = name - crypto_password_file.setOnLongClickListener { - val clipboard = clipboard ?: return@setOnLongClickListener false - val clip = ClipData.newPlainText("pgp_handler_result_pm", name) - clipboard.setPrimaryClip(clip) - showSnackbar(resources.getString(R.string.clipboard_copied_text)) - true - } - - crypto_password_last_changed.text = try { - resources.getString(R.string.last_changed, lastChangedString) - } catch (e: RuntimeException) { - showSnackbar(getString(R.string.get_last_changed_failed)) - "" - } - } - "ENCRYPT" -> { - setContentView(R.layout.encrypt_layout) - - generate_password?.setOnClickListener { - generatePassword() - } - - title = getString(R.string.new_password_title) - crypto_password_category.apply { - // If the activity has been provided with suggested info or is meant to generate - // a password, we allow the user to edit the path, otherwise we style the - // EditText like a TextView. - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) - visibility = View.GONE - else - setText(path) - } - suggestedName?.let { crypto_password_file_edit.setText(it) } - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if (suggestedName != null && - AutofillPreferences.directoryStructure(this) == DirectoryStructure.FileBased - ) { - encrypt_username.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we add it to the - // encrypted extras as the first line. - val username = crypto_password_file_edit.text!!.toString() - val extras = "username:$username\n${crypto_extra_edit.text!!}" - - crypto_password_file_edit.setText("") - crypto_extra_edit.setText(extras) - } else { - // User wants to disable username encryption, so we extract the - // username from the encrypted extras and use it as the filename. - val entry = PasswordEntry("PASSWORD\n${crypto_extra_edit.text!!}") - val username = entry.username - - // username should not be null here by the logic in - // updateEncryptUsernameState, but it could still happen due to - // input lag. - if (username != null) { - crypto_password_file_edit.setText(username) - crypto_extra_edit.setText(entry.extraContentWithoutUsername) - } - } - updateEncryptUsernameState() - } - } - crypto_password_file_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } - crypto_extra_edit.doOnTextChanged { _, _, _, _ -> updateEncryptUsernameState() } - updateEncryptUsernameState() - } - suggestedPass?.let { - crypto_password_edit.setText(it) - crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { crypto_extra_edit.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - crypto_password_edit.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - } - } - - private fun updateEncryptUsernameState() { - encrypt_username.apply { - if (visibility != View.VISIBLE) - return - val hasUsernameInFileName = crypto_password_file_edit.text!!.toString().isNotBlank() - // Use PasswordEntry to parse extras for username - val entry = PasswordEntry("PLACEHOLDER\n${crypto_extra_edit.text!!}") - val hasUsernameInExtras = entry.hasUsername() - isEnabled = hasUsernameInFileName xor hasUsernameInExtras - isChecked = hasUsernameInExtras - } - } - - override fun onResume() { - super.onResume() - LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_CLEAR)) - } - - private fun generatePassword() { - when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { - KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() - .show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() - .show(supportFragmentManager, "xkpwgenerator") - } - } - - override fun onStop() { - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) - super.onStop() - } - - override fun onDestroy() { - super.onDestroy() - serviceConnection?.unbindFromService() - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - // Inflate the menu; this adds items to the action bar if it is present. - // Do not use the value `operation` in this case as it is not valid when editing - val menuId = when (intent.getStringExtra("OPERATION")) { - "ENCRYPT", "EDIT" -> R.menu.pgp_handler_new_password - "DECRYPT" -> R.menu.pgp_handler - else -> R.menu.pgp_handler - } - - menuInflater.inflate(menuId, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.crypto_cancel_add, android.R.id.home -> finish() - R.id.copy_password -> copyPasswordToClipBoard() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.edit_password -> editPassword() - R.id.crypto_confirm_add -> encrypt() - R.id.crypto_confirm_add_and_copy -> encrypt(true) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Shows a simple toast message - */ - private fun showSnackbar(message: String, length: Int = Snackbar.LENGTH_SHORT) { - runOnUiThread { Snackbar.make(findViewById(android.R.id.content), message, length).show() } - } - - /** - * Handle the case where OpenKeychain returns that it needs to interact with the user - * - * @param result The intent returned by OpenKeychain - * @param requestCode The code we'd like to use to identify the behaviour - */ - private fun handleUserInteractionRequest(result: Intent, requestCode: Int) { - i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } - - val pi: PendingIntent? = result.getParcelableExtra(RESULT_INTENT) - try { - this@PgpActivity.startIntentSenderFromChild( - this@PgpActivity, pi?.intentSender, requestCode, - null, 0, 0, 0 - ) - } catch (e: IntentSender.SendIntentException) { - e(e) { "SendIntentException" } - } - } - - /** - * Handle the error returned by OpenKeychain - * - * @param result The intent returned by OpenKeychain - */ - private fun handleError(result: Intent) { - // TODO show what kind of error it is - /* For example: - * No suitable key found -> no key in OpenKeyChain - * - * Check in open-pgp-lib how their definitions and error code - */ - val error: OpenPgpError? = result.getParcelableExtra(RESULT_ERROR) - if (error != null) { - showSnackbar("Error from OpenKeyChain : " + error.message) - e { "onError getErrorId: ${error.errorId}" } - e { "onError getMessage: ${error.message}" } - } - } - - private fun initOpenPgpApi() { - api = api ?: OpenPgpApi(this, serviceConnection!!.service!!) - } - - private fun decryptAndVerify(receivedIntent: Intent? = null) { - val data = receivedIntent ?: Intent() - data.action = ACTION_DECRYPT_VERIFY - - val iStream = File(fullPath).inputStream() - val oStream = ByteArrayOutputStream() - - lifecycleScope.launch(IO) { - api?.executeApiAsync(data, iStream, oStream) { result -> - when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) { - RESULT_CODE_SUCCESS -> { - try { - val showPassword = settings.getBoolean("show_password", true) - val showExtraContent = settings.getBoolean("show_extra_content", true) - - password_text_container.visibility = View.VISIBLE - - val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") - val entry = PasswordEntry(oStream) - - passwordEntry = entry - - if (intent.getStringExtra("OPERATION") == "EDIT") { - editPassword() - return@executeApiAsync - } - - if (entry.password.isEmpty()) { - password_text_container.visibility = View.GONE - } else { - password_text_container.visibility = View.VISIBLE - password_text.setText(entry.password) - if (!showPassword) { - password_text.transformationMethod = PasswordTransformationMethod.getInstance() - } - password_text_container.setOnClickListener { copyPasswordToClipBoard() } - password_text.setOnClickListener { copyPasswordToClipBoard() } - } - - if (entry.hasExtraContent()) { - extra_content_container.visibility = View.VISIBLE - extra_content.typeface = monoTypeface - extra_content.setText(entry.extraContentWithoutUsername) - if (!showExtraContent) { - extra_content.transformationMethod = PasswordTransformationMethod.getInstance() - } - extra_content_container.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } - extra_content.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutUsername) } - - if (entry.hasUsername()) { - username_text.typeface = monoTypeface - username_text.setText(entry.username) - username_text_container.setEndIconOnClickListener { copyTextToClipboard(entry.username!!) } - username_text_container.visibility = View.VISIBLE - } else { - username_text_container.visibility = View.GONE - } - } - - if (settings.getBoolean("copy_on_decrypt", true)) { - copyPasswordToClipBoard() - } - } catch (e: Exception) { - e(e) { "An Exception occurred" } - } - } - RESULT_CODE_USER_INTERACTION_REQUIRED -> handleUserInteractionRequest(result, REQUEST_DECRYPT) - RESULT_CODE_ERROR -> handleError(result) - } - } - } - } - - /** - * Encrypts the password and the extra content - */ - private fun encrypt(copy: Boolean = false) { - editName = crypto_password_file_edit.text.toString().trim() - editPass = crypto_password_edit.text.toString() - editExtra = crypto_extra_edit.text.toString() - - if (editName?.isEmpty() == true) { - showSnackbar(resources.getString(R.string.file_toast_text)) - return - } - - if (editPass?.isEmpty() == true && editExtra?.isEmpty() == true) { - showSnackbar(resources.getString(R.string.empty_toast_text)) - return - } - - if (copy) { - copyPasswordToClipBoard() - } - - val data = Intent() - data.action = OpenPgpApi.ACTION_ENCRYPT - - // EXTRA_KEY_IDS requires long[] - val longKeys = keyIDs.map { it.toLong() } - data.putExtra(OpenPgpApi.EXTRA_KEY_IDS, longKeys.toLongArray()) - data.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true) - - // TODO Check if we could use PasswordEntry to generate the file - val content = "$editPass\n$editExtra" - val iStream = ByteArrayInputStream(content.toByteArray(Charset.forName("UTF-8"))) - val oStream = ByteArrayOutputStream() - - val path = when { - intent.getBooleanExtra("fromDecrypt", false) -> fullPath - // If we allowed the user to edit the relative path, we have to consider it here instead - // of fullPath. - crypto_password_category.isEnabled -> { - val editRelativePath = crypto_password_category.text!!.toString().trim() - if (editRelativePath.isEmpty()) { - showSnackbar(resources.getString(R.string.path_toast_text)) - return - } - "$repoPath/${editRelativePath.trim('/')}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(IO) { - api?.executeApiAsync(data, iStream, oStream) { result -> - when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) { - RESULT_CODE_SUCCESS -> { - try { - // TODO This might fail, we should check that the write is successful - val file = File(path) - val outputStream = file.outputStream() - outputStream.write(oStream.toByteArray()) - outputStream.close() - - val returnIntent = Intent() - returnIntent.putExtra("CREATED_FILE", path) - returnIntent.putExtra("NAME", editName) - returnIntent.putExtra("LONG_NAME", getLongName(fullPath, repoPath, editName!!)) - - // if coming from decrypt screen->edit button - if (intent.getBooleanExtra("fromDecrypt", false)) { - returnIntent.putExtra("OPERATION", "EDIT") - returnIntent.putExtra("needCommit", true) - } - - if (shouldGeneratePassword) { - val directoryStructure = - AutofillPreferences.directoryStructure(applicationContext) - val entry = PasswordEntry(content) - returnIntent.putExtra("PASSWORD", entry.password) - val username = PasswordEntry(content).username - ?: directoryStructure.getUsernameFor(file) - returnIntent.putExtra("USERNAME", username) - } - - setResult(RESULT_OK, returnIntent) - finish() - } catch (e: Exception) { - e(e) { "An Exception occurred" } - } - } - RESULT_CODE_ERROR -> handleError(result) - } - } - } - } - - /** - * Opens EncryptActivity with the information for this file to be edited - */ - private fun editPassword() { - setContentView(R.layout.encrypt_layout) - generate_password?.setOnClickListener { - when (settings.getString("pref_key_pwgen_type", KEY_PWGEN_TYPE_CLASSIC)) { - KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() - .show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() - .show(supportFragmentManager, "xkpwgenerator") - } - } - - title = getString(R.string.edit_password_title) - - val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") - crypto_password_edit.setText(passwordEntry?.password) - crypto_password_edit.typeface = monoTypeface - crypto_extra_edit.setText(passwordEntry?.extraContent) - crypto_extra_edit.typeface = monoTypeface - - crypto_password_category.setText(relativeParentPath) - crypto_password_file_edit.setText(name) - crypto_password_file_edit.isEnabled = false - - delayTask?.cancelAndSignal(true) - - val data = Intent(this, PgpActivity::class.java) - data.putExtra("OPERATION", "EDIT") - data.putExtra("fromDecrypt", true) - intent = data - invalidateOptionsMenu() - } - - /** - * Get the Key ids from OpenKeychain - */ - private fun getKeyIds(receivedIntent: Intent? = null) { - val data = receivedIntent ?: Intent() - data.action = OpenPgpApi.ACTION_GET_KEY_IDS - lifecycleScope.launch(IO) { - api?.executeApiAsync(data, null, null) { result -> - when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) { - RESULT_CODE_SUCCESS -> { - try { - val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS) - ?: LongArray(0) - val keys = ids.map { it.toString() }.toSet() - - // use Long - settings.edit { putStringSet("openpgp_key_ids_set", keys) } - - showSnackbar("PGP keys selected") - - setResult(RESULT_OK) - finish() - } catch (e: Exception) { - e(e) { "An Exception occurred" } - } - } - RESULT_CODE_USER_INTERACTION_REQUIRED -> handleUserInteractionRequest(result, REQUEST_KEY_ID) - RESULT_CODE_ERROR -> handleError(result) - } - } - } - } - - override fun onError(e: Exception) {} - - /** - * The action to take when the PGP service is bound - */ - override fun onBound(service: IOpenPgpService2) { - initOpenPgpApi() - when (operation) { - "EDIT", "DECRYPT" -> decryptAndVerify() - "GET_KEY_ID" -> getKeyIds() - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (data == null) { - setResult(RESULT_CANCELED, null) - finish() - return - } - - // try again after user interaction - if (resultCode == RESULT_OK) { - when (requestCode) { - REQUEST_DECRYPT -> decryptAndVerify(data) - REQUEST_KEY_ID -> getKeyIds(data) - else -> { - setResult(RESULT_OK) - finish() - } - } - } else if (resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED, data) - finish() - } - } - - private fun copyPasswordToClipBoard() { - val clipboard = clipboard ?: return - val pass = passwordEntry?.password - val clip = ClipData.newPlainText("pgp_handler_result_pm", pass) - clipboard.setPrimaryClip(clip) - - var clearAfter = 45 - try { - clearAfter = Integer.parseInt(settings.getString("general_show_time", "45") as String) - } catch (e: NumberFormatException) { - // ignore and keep default - } - - if (clearAfter != 0) { - setTimer() - showSnackbar(resources.getString(R.string.clipboard_password_toast_text, clearAfter)) - } else { - showSnackbar(resources.getString(R.string.clipboard_password_no_clear_toast_text)) - } - } - - private fun copyTextToClipboard(text: String) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clipboard.setPrimaryClip(clip) - showSnackbar(resources.getString(R.string.clipboard_copied_text)) - } - - private fun shareAsPlaintext() { - val sendIntent = Intent() - sendIntent.action = Intent.ACTION_SEND - sendIntent.putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - sendIntent.type = "text/plain" - startActivity( - Intent.createChooser( - sendIntent, - resources.getText(R.string.send_plaintext_password_to) - ) - ) // Always show a picker to give the user a chance to cancel - } - - private fun setTimer() { - - // make sure to cancel any running tasks as soon as possible - // if the previous task is still running, do not ask it to clear the password - delayTask?.cancelAndSignal(true) - - // launch a new one - delayTask = DelayShow() - delayTask?.execute() - } - - /** - * Gets a relative string describing when this shape was last changed - * (e.g. "one hour ago") - */ - private fun getLastChangedString(timeStamp: Long): CharSequence { - if (timeStamp < 0) { - throw RuntimeException() - } - - return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) - } - - @Suppress("StaticFieldLeak") - inner class DelayShow { - - private var skip = false - private var service: Intent? = null - private var showTime: Int = 0 - - // Custom cancellation that can be triggered from another thread. - // - // This signals the DelayShow task to stop and avoids it having - // to poll the AsyncTask.isCancelled() excessively. If skipClearing - // is true, the cancelled task won't clear the clipboard. - fun cancelAndSignal(skipClearing: Boolean) { - skip = skipClearing - if (service != null) { - stopService(service) - service = null - } - } - - fun execute() { - service = Intent(this@PgpActivity, ClipboardService::class.java).also { - it.action = ACTION_START - } - doOnPreExecute() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(service) - } else { - startService(service) - } - } - - private fun doOnPreExecute() { - showTime = try { - Integer.parseInt(settings.getString("general_show_time", "45") as String) - } catch (e: NumberFormatException) { - 45 - } - password_text_container?.visibility = View.VISIBLE - if (extra_content?.text?.isNotEmpty() == true) - extra_content_container?.visibility = View.VISIBLE - } - - fun doOnPostExecute() { - if (skip) return - - if (password_text != null) { - passwordEntry = null - extra_content_container.visibility = View.INVISIBLE - password_text_container.visibility = View.INVISIBLE - finish() - } - } - } - - companion object { - const val OPEN_PGP_BOUND = 101 - const val REQUEST_DECRYPT = 202 - const val REQUEST_KEY_ID = 203 - - private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" - private const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" - - const val TAG = "PgpActivity" - - const val KEY_PWGEN_TYPE_CLASSIC = "classic" - const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" - - /** - * Gets the relative path to the repository - */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** - * Gets the Parent path, relative to the repository - */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") - } - - /** - * /path/to/store/social/facebook.gpg -> social/facebook - */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} |
