From 17f640bf468383c791bd4f8e7934e84d9d262079 Mon Sep 17 00:00:00 2001 From: Aditya Wasan Date: Tue, 7 Dec 2021 21:59:03 +0530 Subject: Allow importing TOTP from images (#1580) * feat(aps): allow importing TOTP code from an image containing a QR code Signed-off-by: Aditya * Reorder OTP import options and implement it for V2 * Replace try-catch with runCatching * Use the correct TextWatcher extension at the right place Co-authored-by: Harsh Shandilya --- .../aps/ui/crypto/PasswordCreationActivity.kt | 57 +++++++++++++++++++--- .../aps/ui/crypto/PasswordCreationActivityV2.kt | 57 +++++++++++++++++++--- app/src/main/res/values/strings.xml | 1 + 3 files changed, 103 insertions(+), 12 deletions(-) (limited to 'app') diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt index d10f4ef6..b22ee706 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt @@ -8,25 +8,35 @@ package dev.msfjarvis.aps.ui.crypto import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build import android.os.Bundle +import android.provider.MediaStore import android.text.InputType import android.view.Menu import android.view.MenuItem import android.view.View import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.core.content.edit import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged +import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope 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.android.material.snackbar.Snackbar +import com.google.zxing.BinaryBitmap +import com.google.zxing.LuminanceSource +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import com.google.zxing.qrcode.QRCodeReader import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.aps.R import dev.msfjarvis.aps.data.passfile.PasswordEntry @@ -112,6 +122,39 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB } } + private val imageImportAction = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + if (imageUri == null) { + snackbar(message = getString(R.string.otp_import_failure)) + return@registerForActivityResult + } + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) + .copy(Bitmap.Config.ARGB_8888, true) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) + } + val intArray = IntArray(bitmap.width * bitmap.height) + // copy pixel data from the Bitmap into the 'intArray' array + bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = QRCodeReader() + runCatching { + val result = reader.decode(binaryBitmap) + val text = result.text + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$text") + else binding.extraContent.append(text) + snackbar(message = getString(R.string.otp_import_success)) + binding.otpImportButton.isVisible = false + } + .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } + } + private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { @@ -185,7 +228,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB val items = arrayOf( getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_manual_entry) + getString(R.string.otp_import_from_file), + getString(R.string.otp_import_manual_entry), ) MaterialAlertDialogBuilder(this@PasswordCreationActivity) .setItems(items) { _, index -> @@ -198,7 +242,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB .setDesiredBarcodeFormats(QR_CODE) .createScanIntent() ) - 1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + 1 -> imageImportAction.launch("image/*") + 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") } } .show() @@ -264,9 +309,6 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB } } } - listOf(filename, extraContent).forEach { - it.doOnTextChanged { _, _, _, _ -> updateViewState() } - } } suggestedPass?.let { password.setText(it) @@ -278,6 +320,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } } + listOf(binding.filename, binding.extraContent).forEach { + it.doAfterTextChanged { updateViewState() } + } updateViewState() } 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 index 71793a40..b99d5f6d 100644 --- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivityV2.kt @@ -8,22 +8,32 @@ package dev.msfjarvis.aps.ui.crypto import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.os.Build import android.os.Bundle +import android.provider.MediaStore import android.text.InputType import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.core.content.edit import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged +import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope 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.BinaryBitmap +import com.google.zxing.LuminanceSource +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer import com.google.zxing.integration.android.IntentIntegrator import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import com.google.zxing.qrcode.QRCodeReader import dagger.hilt.android.AndroidEntryPoint import dev.msfjarvis.aps.R import dev.msfjarvis.aps.data.passfile.PasswordEntry @@ -88,6 +98,39 @@ class PasswordCreationActivityV2 : BasePgpActivity() { } } + private val imageImportAction = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + if (imageUri == null) { + snackbar(message = getString(R.string.otp_import_failure)) + return@registerForActivityResult + } + val bitmap = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(contentResolver, imageUri)) + .copy(Bitmap.Config.ARGB_8888, true) + } else { + @Suppress("DEPRECATION") MediaStore.Images.Media.getBitmap(contentResolver, imageUri) + } + val intArray = IntArray(bitmap.width * bitmap.height) + // copy pixel data from the Bitmap into the 'intArray' array + bitmap.getPixels(intArray, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + val source: LuminanceSource = RGBLuminanceSource(bitmap.width, bitmap.height, intArray) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val reader = QRCodeReader() + runCatching { + val result = reader.decode(binaryBitmap) + val text = result.text + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$text") + else binding.extraContent.append(text) + snackbar(message = getString(R.string.otp_import_success)) + binding.otpImportButton.isVisible = false + } + .onFailure { snackbar(message = getString(R.string.otp_import_failure)) } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) supportActionBar?.setDisplayHomeAsUpEnabled(true) @@ -115,7 +158,8 @@ class PasswordCreationActivityV2 : BasePgpActivity() { val items = arrayOf( getString(R.string.otp_import_qr_code), - getString(R.string.otp_import_manual_entry) + getString(R.string.otp_import_from_file), + getString(R.string.otp_import_manual_entry), ) MaterialAlertDialogBuilder(this@PasswordCreationActivityV2) .setItems(items) { _, index -> @@ -128,7 +172,8 @@ class PasswordCreationActivityV2 : BasePgpActivity() { .setDesiredBarcodeFormats(QR_CODE) .createScanIntent() ) - 1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") + 1 -> imageImportAction.launch("image/*") + 2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") } } .show() @@ -194,9 +239,6 @@ class PasswordCreationActivityV2 : BasePgpActivity() { } } } - listOf(filename, extraContent).forEach { - it.doOnTextChanged { _, _, _, _ -> updateViewState() } - } } suggestedPass?.let { password.setText(it) @@ -208,6 +250,9 @@ class PasswordCreationActivityV2 : BasePgpActivity() { password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } } + listOf(binding.filename, binding.extraContent).forEach { + it.doAfterTextChanged { updateViewState() } + } updateViewState() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16269719..489a5a2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -394,6 +394,7 @@ Clear saved host key Successfully cleared saved host key! Scan QR code + Choose an image Enter manually Secret Account -- cgit v1.2.3