From 571ab4e78e022ba7c9f0b8e20d3dd81f371d1b3c Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Tue, 14 Sep 2021 22:37:55 +0530 Subject: Gopenpgp related fixes (#1503) * app: rename new crypto activities (cherry picked from commit 89be012f995b878affb7e7a592750e130c7f0f2c) * app: allow alt backends to work without OpenKeychain (cherry picked from commit 7bf9f01e5ef7bb24700ce3f242e5aabbabbff09e) * app: rename ENABLE_GOPENPGP to ENABLE_PGP_V2_BACKEND --- app/src/main/AndroidManifest.xml | 6 +- .../aps/ui/autofill/AutofillDecryptActivityV2.kt | 155 ++++++++ .../aps/ui/autofill/AutofillFilterActivity.kt | 4 +- .../ui/autofill/GopenpgpAutofillDecryptActivity.kt | 155 -------- .../dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt | 2 + .../msfjarvis/aps/ui/crypto/DecryptActivityV2.kt | 178 +++++++++ .../aps/ui/crypto/GopenpgpDecryptActivity.kt | 175 --------- .../ui/crypto/GopenpgpPasswordCreationActivity.kt | 432 --------------------- .../aps/ui/crypto/PasswordCreationActivityV2.kt | 432 +++++++++++++++++++++ .../msfjarvis/aps/ui/passwords/PasswordStore.kt | 6 +- .../java/dev/msfjarvis/aps/util/FeatureFlags.kt | 2 +- .../util/autofill/Api30AutofillResponseBuilder.kt | 6 +- .../aps/util/autofill/AutofillResponseBuilder.kt | 6 +- 13 files changed, 782 insertions(+), 777 deletions(-) create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivityV2.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt (limited to 'app/src/main') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98d28f0a..94ad96fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,7 +45,7 @@ android:windowSoftInputMode="adjustResize" /> - @@ -137,7 +137,7 @@ android:name=".ui.autofill.AutofillDecryptActivity" android:theme="@style/NoBackgroundTheme" /> + e(e) { "File to decrypt not found" } + return null + } + .onSuccess { encryptedInput -> + runCatching { + val crypto = cryptos.first { it.canHandle(file.absolutePath) } + withContext(Dispatchers.IO) { + crypto.decrypt( + DecryptActivityV2.PRIV_KEY, + DecryptActivityV2.PASS.toByteArray(charset = Charsets.UTF_8), + encryptedInput.readBytes() + ) + } + } + .onFailure { e -> + e(e) { "Decryption failed" } + return null + } + .onSuccess { result -> + return runCatching { + val entry = passwordEntryFactory.create(lifecycleScope, result) + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) + } + .getOrElse { e -> + e(e) { "Failed to parse password entry" } + return null + } + } + } + return null + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt index 1d5160e2..04fb83d5 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt @@ -221,8 +221,8 @@ class AutofillFilterView : AppCompatActivity() { AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) // intent?.extras? is checked to be non-null in onCreate decryptAction.launch( - if (FeatureFlags.ENABLE_GOPENPGP) { - GopenpgpAutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) + if (FeatureFlags.ENABLE_PGP_V2_BACKEND) { + AutofillDecryptActivityV2.makeDecryptFileIntent(item.file, intent!!.extras!!, this) } else { AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this) } diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt deleted file mode 100644 index 1c9f19d7..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/GopenpgpAutofillDecryptActivity.kt +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package dev.msfjarvis.aps.ui.autofill - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.autofill.AutofillManager -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.injection.crypto.CryptoSet -import dev.msfjarvis.aps.injection.password.PasswordEntryFactory -import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import java.io.File -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@RequiresApi(Build.VERSION_CODES.O) -@AndroidEntryPoint -class GopenpgpAutofillDecryptActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" - private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION" - - private var decryptFileRequestCode = 1 - - fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { - return Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply { - putExtras(forwardedExtras) - putExtra(EXTRA_SEARCH_ACTION, true) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - } - - fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { - val intent = - Intent(context, GopenpgpAutofillDecryptActivity::class.java).apply { - putExtra(EXTRA_SEARCH_ACTION, false) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - return PendingIntent.getActivity( - context, - decryptFileRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT - ) - .intentSender - } - } - - @Inject lateinit var passwordEntryFactory: PasswordEntryFactory - @Inject lateinit var cryptos: CryptoSet - - private lateinit var directoryStructure: DirectoryStructure - - override fun onStart() { - super.onStart() - val filePath = - intent?.getStringExtra(EXTRA_FILE_PATH) - ?: run { - e { "GopenpgpAutofillDecryptActivity started without EXTRA_FILE_PATH" } - finish() - return - } - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) - ?: run { - e { "GopenpgpAutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match - directoryStructure = AutofillPreferences.directoryStructure(this) - d { action.toString() } - lifecycleScope.launch { - val credentials = decryptCredential(File(filePath)) - if (credentials == null) { - setResult(RESULT_CANCELED) - } else { - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@GopenpgpAutofillDecryptActivity, - credentials, - clientState, - action - ) - withContext(Dispatchers.Main) { - setResult( - RESULT_OK, - Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) } - ) - } - } - withContext(Dispatchers.Main) { finish() } - } - } - - private suspend fun decryptCredential(file: File): Credentials? { - runCatching { file.inputStream() } - .onFailure { e -> - e(e) { "File to decrypt not found" } - return null - } - .onSuccess { encryptedInput -> - runCatching { - val crypto = cryptos.first { it.canHandle(file.absolutePath) } - withContext(Dispatchers.IO) { - crypto.decrypt( - GopenpgpDecryptActivity.PRIV_KEY, - GopenpgpDecryptActivity.PASS.toByteArray(charset = Charsets.UTF_8), - encryptedInput.readBytes() - ) - } - } - .onFailure { e -> - e(e) { "Decryption with Gopenpgp failed" } - return null - } - .onSuccess { result -> - return runCatching { - val entry = passwordEntryFactory.create(lifecycleScope, result) - AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) - } - .getOrElse { e -> - e(e) { "Failed to parse password entry" } - return null - } - } - } - return null - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt index ee753fe5..0a114827 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt @@ -27,6 +27,7 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.aps.R import dev.msfjarvis.aps.injection.prefs.SettingsPreferences +import dev.msfjarvis.aps.util.FeatureFlags import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER import dev.msfjarvis.aps.util.extensions.clipboard import dev.msfjarvis.aps.util.extensions.getString @@ -126,6 +127,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou /** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */ fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { + if (FeatureFlags.ENABLE_PGP_V2_BACKEND) return val installed = runCatching { packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt new file mode 100644 index 00000000..b000d21d --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivityV2.kt @@ -0,0 +1,178 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.passfile.PasswordEntry +import dev.msfjarvis.aps.data.password.FieldItem +import dev.msfjarvis.aps.databinding.DecryptLayoutBinding +import dev.msfjarvis.aps.injection.crypto.CryptoSet +import dev.msfjarvis.aps.injection.password.PasswordEntryFactory +import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter +import dev.msfjarvis.aps.util.extensions.unsafeLazy +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class DecryptActivityV2 : BasePgpActivity() { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntryFactory + @Inject lateinit var cryptos: CryptoSet + private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } + + private var passwordEntry: PasswordEntry? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + } + decrypt() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + passwordEntry?.let { entry -> + menu.findItem(R.id.edit_password).isVisible = true + if (!entry.password.isNullOrBlank()) { + menu.findItem(R.id.share_password_as_plaintext).isVisible = true + menu.findItem(R.id.copy_password).isVisible = true + } + } + 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) + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /** + * Automatically finishes the activity 60 seconds after decryption succeeded to prevent + * information leaks from stale activities. + */ + @OptIn(ExperimentalTime::class) + private fun startAutoDismissTimer() { + lifecycleScope.launch { + delay(Duration.seconds(60)) + finish() + } + } + + /** + * 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, PasswordCreationActivityV2::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivityV2.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivityV2.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra( + PasswordCreationActivityV2.EXTRA_EXTRA_CONTENT, + passwordEntry?.extraContentString + ) + intent.putExtra(PasswordCreationActivityV2.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 decrypt() { + lifecycleScope.launch { + // TODO(msfjarvis): native methods are fallible, add error handling once out of testing + val message = withContext(Dispatchers.IO) { File(fullPath).readBytes() } + val result = + withContext(Dispatchers.IO) { + val crypto = cryptos.first { it.canHandle(fullPath) } + crypto.decrypt( + PRIV_KEY, + PASS.toByteArray(charset = Charsets.UTF_8), + message, + ) + } + startAutoDismissTimer() + + val entry = passwordEntryFactory.create(lifecycleScope, result) + passwordEntry = entry + invalidateOptionsMenu() + + val items = arrayListOf() + val adapter = FieldItemAdapter(emptyList(), true) { text -> copyTextToClipboard(text) } + if (!entry.password.isNullOrBlank()) { + items.add(FieldItem.createPasswordField(entry.password!!)) + } + + if (entry.hasTotp()) { + lifecycleScope.launch { + items.add(FieldItem.createOtpField(entry.totp.value)) + entry.totp.collect { code -> + withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } + } + } + } + + if (!entry.username.isNullOrBlank()) { + items.add(FieldItem.createUsernameField(entry.username!!)) + } + + entry.extraContent.forEach { (key, value) -> + items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) + } + + binding.recyclerView.adapter = adapter + adapter.updateItems(items) + } + } + + companion object { + // TODO(msfjarvis): source these from storage and user input + const val PRIV_KEY = "" + const val PASS = "" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt deleted file mode 100644 index 159354d2..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpDecryptActivity.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Intent -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.lifecycle.lifecycleScope -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.data.passfile.PasswordEntry -import dev.msfjarvis.aps.data.password.FieldItem -import dev.msfjarvis.aps.databinding.DecryptLayoutBinding -import dev.msfjarvis.aps.injection.crypto.CryptoSet -import dev.msfjarvis.aps.injection.password.PasswordEntryFactory -import dev.msfjarvis.aps.ui.adapters.FieldItemAdapter -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import java.io.File -import javax.inject.Inject -import kotlin.time.Duration -import kotlin.time.ExperimentalTime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@AndroidEntryPoint -class GopenpgpDecryptActivity : BasePgpActivity() { - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntryFactory - @Inject lateinit var cryptos: CryptoSet - private val relativeParentPath by unsafeLazy { getParentPath(fullPath, repoPath) } - - private var passwordEntry: PasswordEntry? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - } - decrypt() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - menu.findItem(R.id.edit_password).isVisible = true - if (!entry.password.isNullOrBlank()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - 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) - else -> return super.onOptionsItemSelected(item) - } - return true - } - - /** - * Automatically finishes the activity 60 seconds after decryption succeeded to prevent - * information leaks from stale activities. - */ - @OptIn(ExperimentalTime::class) - private fun startAutoDismissTimer() { - lifecycleScope.launch { - delay(Duration.seconds(60)) - finish() - } - } - - /** - * 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?.extraContentString) - 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 decrypt() { - lifecycleScope.launch { - // TODO(msfjarvis): native methods are fallible, add error handling once out of testing - val message = withContext(Dispatchers.IO) { File(fullPath).readBytes() } - val result = - withContext(Dispatchers.IO) { - val crypto = cryptos.first { it.canHandle(fullPath) } - crypto.decrypt( - PRIV_KEY, - PASS.toByteArray(charset = Charsets.UTF_8), - message, - ) - } - startAutoDismissTimer() - - val entry = passwordEntryFactory.create(lifecycleScope, result) - passwordEntry = entry - invalidateOptionsMenu() - - val items = arrayListOf() - val adapter = FieldItemAdapter(emptyList(), true) { text -> copyTextToClipboard(text) } - if (!entry.password.isNullOrBlank()) { - items.add(FieldItem.createPasswordField(entry.password!!)) - } - - if (entry.hasTotp()) { - lifecycleScope.launch { - items.add(FieldItem.createOtpField(entry.totp.value)) - entry.totp.collect { code -> - withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } - } - } - } - - if (!entry.username.isNullOrBlank()) { - items.add(FieldItem.createUsernameField(entry.username!!)) - } - - entry.extraContent.forEach { (key, value) -> - items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) - } - - binding.recyclerView.adapter = adapter - adapter.updateItems(items) - } - } - - companion object { - // TODO(msfjarvis): source these from storage and user input - const val PRIV_KEY = "" - const val PASS = "" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt deleted file mode 100644 index bbce67f3..00000000 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GopenpgpPasswordCreationActivity.kt +++ /dev/null @@ -1,432 +0,0 @@ -/* - * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package dev.msfjarvis.aps.ui.crypto - -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -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.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import dagger.hilt.android.AndroidEntryPoint -import dev.msfjarvis.aps.R -import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding -import dev.msfjarvis.aps.injection.crypto.CryptoSet -import dev.msfjarvis.aps.injection.password.PasswordEntryFactory -import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment -import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment -import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment -import dev.msfjarvis.aps.util.autofill.AutofillPreferences -import dev.msfjarvis.aps.util.autofill.DirectoryStructure -import dev.msfjarvis.aps.util.extensions.base64 -import dev.msfjarvis.aps.util.extensions.commitChange -import dev.msfjarvis.aps.util.extensions.getString -import dev.msfjarvis.aps.util.extensions.isInsideRepository -import dev.msfjarvis.aps.util.extensions.snackbar -import dev.msfjarvis.aps.util.extensions.unsafeLazy -import dev.msfjarvis.aps.util.extensions.viewBinding -import dev.msfjarvis.aps.util.settings.PreferenceKeys -import java.io.File -import java.io.IOException -import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@AndroidEntryPoint -class GopenpgpPasswordCreationActivity : BasePgpActivity() { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - @Inject lateinit var passwordEntryFactory: PasswordEntryFactory - @Inject lateinit var cryptos: CryptoSet - - private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by unsafeLazy { - intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - } - private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } - private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } - private var oldCategory: String? = null - private var copy: Boolean = false - - private val otpImportAction = - registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure)) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - bindToOpenKeychain(this) - title = - if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - supportFragmentManager.setFragmentResultListener( - OTP_RESULT_REQUEST_KEY, - this@GopenpgpPasswordCreationActivity - ) { requestKey, bundle -> - if (requestKey == OTP_RESULT_REQUEST_KEY) { - val contents = bundle.getString(RESULT) - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else binding.extraContent.append(contents) - } - } - val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true - if (hasCamera) { - val items = - arrayOf( - getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_manual_entry) - ) - MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity) - .setItems(items) { _, index -> - when (index) { - 0 -> - otpImportAction.launch( - IntentIntegrator(this@GopenpgpPasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - 1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - .show() - } else { - OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") - } - } - - directoryInputLayout.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 { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - // 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@GopenpgpPasswordCreationActivity) == - 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.text?.clear() - 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 = - passwordEntryFactory.create( - lifecycleScope, - "PASSWORD\n${extraContent.text}".encodeToByteArray() - ) - val username = entry.username - - // username should not be null here by the logic in - // updateViewState, but it could still happen due to - // input lag. - if (username != null) { - filename.setText(username) - extraContent.setText(entry.extraContentWithoutAuthData) - } - } - } - } - listOf(filename, extraContent).forEach { - it.doOnTextChanged { _, _, _, _ -> updateViewState() } - } - } - 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 - } - } - updateViewState() - } - - 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 -> { - setResult(RESULT_CANCELED) - onBackPressed() - } - R.id.save_password -> { - copy = false - encrypt() - } - R.id.save_and_copy_password -> { - copy = true - encrypt() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { - requestKey, - bundle -> - if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { - binding.password.setText(bundle.getString(RESULT)) - } - } - when (settings.getString(PreferenceKeys.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 updateViewState() = - with(binding) { - // Use PasswordEntry to parse extras for username - val entry = - passwordEntryFactory.create( - lifecycleScope, - "PLACEHOLDER\n${extraContent.text}".encodeToByteArray() - ) - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val hasUsernameInExtras = !entry.username.isNullOrBlank() - isEnabled = hasUsernameInFileName xor hasUsernameInExtras - isChecked = hasUsernameInExtras - } - otpImportButton.isVisible = !entry.hasTotp() - } - - /** Encrypts the password and the extra content */ - private fun encrypt() { - 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 - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - val content = "$editPass\n$editExtra" - val path = - when { - // If we allowed the user to edit the relative path, we have to consider it here - // instead - // of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.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.Main) { - runCatching { - val crypto = cryptos.first { it.canHandle(path) } - val result = - withContext(Dispatchers.IO) { crypto.encrypt(PUB_KEY, content.encodeToByteArray()) } - val file = File(path) - // If we're not editing, this file should not already exist! - // Additionally, if we were editing and the incoming and outgoing - // filenames differ, it means we renamed. Ensure that the target - // doesn't already exist to prevent an accidental overwrite. - if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists() - ) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@runCatching - } - - if (!file.isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@runCatching - } - - withContext(Dispatchers.IO) { file.outputStream().use { it.write(result) } } - - // associate the new password name with the last name's timestamp in - // history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(file.absolutePath.base64(), timestamp) - } - } - - 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 = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray()) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = entry.username ?: directoryStructure.getUsernameFor(file) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if (directoryInputLayout.isVisible && - directoryInputLayout.isEnabled && - oldFileName != null - ) { - val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") - if (oldFile.path != file.path && !oldFile.delete()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage( - getString(R.string.password_creation_file_delete_fail_message, oldFileName) - ) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - return@runCatching - } - } - - val commitMessageRes = - if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange( - resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) - ) - .onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - } - .onFailure { e -> - if (e is IOException) { - e(e) { "Failed to write password file" } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@GopenpgpPasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } else { - e(e) - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" - const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" - const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" - const val RESULT = "RESULT" - 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" - // TODO(msfjarvis): source this from storage - const val PUB_KEY = "" - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt new file mode 100644 index 00000000..645d6c47 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt @@ -0,0 +1,432 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +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.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import dagger.hilt.android.AndroidEntryPoint +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding +import dev.msfjarvis.aps.injection.crypto.CryptoSet +import dev.msfjarvis.aps.injection.password.PasswordEntryFactory +import dev.msfjarvis.aps.ui.dialogs.OtpImportDialogFragment +import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment +import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isInsideRepository +import dev.msfjarvis.aps.util.extensions.snackbar +import dev.msfjarvis.aps.util.extensions.unsafeLazy +import dev.msfjarvis.aps.util.extensions.viewBinding +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import java.io.File +import java.io.IOException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@AndroidEntryPoint +class PasswordCreationActivityV2 : BasePgpActivity() { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + @Inject lateinit var passwordEntryFactory: PasswordEntryFactory + @Inject lateinit var cryptos: CryptoSet + + private val suggestedName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by unsafeLazy { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by unsafeLazy { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by unsafeLazy { + intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + } + private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by unsafeLazy { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + + private val otpImportAction = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + binding.otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + bindToOpenKeychain(this) + title = + if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + supportFragmentManager.setFragmentResultListener( + OTP_RESULT_REQUEST_KEY, + this@PasswordCreationActivityV2 + ) { requestKey, bundle -> + if (requestKey == OTP_RESULT_REQUEST_KEY) { + val contents = bundle.getString(RESULT) + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else binding.extraContent.append(contents) + } + } + val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true + if (hasCamera) { + val items = + arrayOf( + getString(R.string.otp_import_qr_code), + getString(R.string.otp_import_manual_entry) + ) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setItems(items) { _, index -> + when (index) { + 0 -> + otpImportAction.launch( + IntentIntegrator(this@PasswordCreationActivityV2) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + 1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + .show() + } else { + OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + } + } + + directoryInputLayout.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 { + directory.setText(path) + oldCategory = path + } + } + if (suggestedName != null) { + filename.setText(suggestedName) + } else { + filename.requestFocus() + } + // 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@PasswordCreationActivityV2) == + 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.text?.clear() + 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 = + passwordEntryFactory.create( + lifecycleScope, + "PASSWORD\n${extraContent.text}".encodeToByteArray() + ) + val username = entry.username + + // username should not be null here by the logic in + // updateViewState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutAuthData) + } + } + } + } + listOf(filename, extraContent).forEach { + it.doOnTextChanged { _, _, _, _ -> updateViewState() } + } + } + 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 + } + } + updateViewState() + } + + 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 -> { + setResult(RESULT_CANCELED) + onBackPressed() + } + R.id.save_password -> { + copy = false + encrypt() + } + R.id.save_and_copy_password -> { + copy = true + encrypt() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { + requestKey, + bundle -> + if (requestKey == PASSWORD_RESULT_REQUEST_KEY) { + binding.password.setText(bundle.getString(RESULT)) + } + } + when (settings.getString(PreferenceKeys.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 updateViewState() = + with(binding) { + // Use PasswordEntry to parse extras for username + val entry = + passwordEntryFactory.create( + lifecycleScope, + "PLACEHOLDER\n${extraContent.text}".encodeToByteArray() + ) + encryptUsername.apply { + if (visibility != View.VISIBLE) return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = !entry.username.isNullOrBlank() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + /** Encrypts the password and the extra content */ + private fun encrypt() { + 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 + } else if (editName.contains('/')) { + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + val content = "$editPass\n$editExtra" + val path = + when { + // If we allowed the user to edit the relative path, we have to consider it here + // instead + // of fullPath. + directoryInputLayout.isEnabled -> { + val editRelativePath = directory.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.Main) { + runCatching { + val crypto = cryptos.first { it.canHandle(path) } + val result = + withContext(Dispatchers.IO) { crypto.encrypt(PUB_KEY, content.encodeToByteArray()) } + val file = File(path) + // If we're not editing, this file should not already exist! + // Additionally, if we were editing and the incoming and outgoing + // filenames differ, it means we renamed. Ensure that the target + // doesn't already exist to prevent an accidental overwrite. + if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists() + ) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@runCatching + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@runCatching + } + + withContext(Dispatchers.IO) { file.outputStream().use { it.write(result) } } + + // associate the new password name with the last name's timestamp in + // history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + + 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 = passwordEntryFactory.create(lifecycleScope, content.encodeToByteArray()) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = entry.username ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + if (directoryInputLayout.isVisible && + directoryInputLayout.isEnabled && + oldFileName != null + ) { + val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") + if (oldFile.path != file.path && !oldFile.delete()) { + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setTitle(R.string.password_creation_file_fail_title) + .setMessage( + getString(R.string.password_creation_file_delete_fail_message, oldFileName) + ) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + return@runCatching + } + } + + val commitMessageRes = + if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + lifecycleScope.launch { + commitChange( + resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)) + ) + .onSuccess { + setResult(RESULT_OK, returnIntent) + finish() + } + } + } + .onFailure { e -> + if (e is IOException) { + e(e) { "Failed to write password file" } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) + .setTitle(getString(R.string.password_creation_file_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> finish() } + .show() + } else { + e(e) + } + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" + const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT" + const val RESULT = "RESULT" + 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" + // TODO(msfjarvis): source this from storage + const val PUB_KEY = "" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt index cd720d3c..a44d4ed0 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt @@ -39,7 +39,7 @@ import dev.msfjarvis.aps.data.password.PasswordItem import dev.msfjarvis.aps.data.repo.PasswordRepository import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName import dev.msfjarvis.aps.ui.crypto.DecryptActivity -import dev.msfjarvis.aps.ui.crypto.GopenpgpDecryptActivity +import dev.msfjarvis.aps.ui.crypto.DecryptActivityV2 import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment @@ -426,8 +426,8 @@ class PasswordStore : BaseGitActivity() { (authDecryptIntent.clone() as Intent).setComponent( ComponentName( this, - if (FeatureFlags.ENABLE_GOPENPGP) { - GopenpgpDecryptActivity::class.java + if (FeatureFlags.ENABLE_PGP_V2_BACKEND) { + DecryptActivityV2::class.java } else { DecryptActivity::class.java } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt b/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt index 09544267..59fe361d 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/FeatureFlags.kt @@ -2,5 +2,5 @@ package dev.msfjarvis.aps.util /** Naive feature flagging functionality to allow merging incomplete features */ object FeatureFlags { - const val ENABLE_GOPENPGP = false + const val ENABLE_PGP_V2_BACKEND = false } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt index 80891f3a..14cc634c 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt @@ -22,10 +22,10 @@ import com.github.androidpasswordstore.autofillparser.fillWith import com.github.michaelbull.result.fold import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2 import dev.msfjarvis.aps.ui.autofill.AutofillFilterView import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity -import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity import dev.msfjarvis.aps.util.FeatureFlags import java.io.File @@ -71,8 +71,8 @@ class Api30AutofillResponseBuilder(form: FillableForm) { if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null val metadata = makeFillMatchMetadata(context, file) val intentSender = - if (FeatureFlags.ENABLE_GOPENPGP) { - GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + if (FeatureFlags.ENABLE_PGP_V2_BACKEND) { + AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) } else { AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt index 2f3568c4..1a825de8 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt @@ -22,10 +22,10 @@ import com.github.androidpasswordstore.autofillparser.fillWith import com.github.michaelbull.result.fold import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2 import dev.msfjarvis.aps.ui.autofill.AutofillFilterView import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity -import dev.msfjarvis.aps.ui.autofill.GopenpgpAutofillDecryptActivity import dev.msfjarvis.aps.util.FeatureFlags import java.io.File @@ -59,8 +59,8 @@ class AutofillResponseBuilder(form: FillableForm) { if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null val metadata = makeFillMatchMetadata(context, file) val intentSender = - if (FeatureFlags.ENABLE_GOPENPGP) { - GopenpgpAutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + if (FeatureFlags.ENABLE_PGP_V2_BACKEND) { + AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context) } else { AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) } -- cgit v1.2.3