Allow importing TOTP from images (#1580)

* feat(aps): allow importing TOTP code from an image containing a QR code

Signed-off-by: Aditya <adityawasan55@gmail.com>

* 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 <me@msfjarvis.dev>
This commit is contained in:
Aditya Wasan
2021-12-07 21:59:03 +05:30
committed by GitHub
parent 1df01a2f54
commit 17f640bf46
4 changed files with 104 additions and 12 deletions

View File

@@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.
- Improve search result filtering logic - Improve search result filtering logic
- Allow pinning shortcuts directly to the launcher home screen - Allow pinning shortcuts directly to the launcher home screen
- Another workaround for SteamGuard's non-standard OTP format - Another workaround for SteamGuard's non-standard OTP format
- Allow importing QR code from images
### Fixed ### Fixed

View File

@@ -8,25 +8,35 @@ package dev.msfjarvis.aps.ui.crypto
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.text.InputType import android.text.InputType
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar 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
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
import com.google.zxing.qrcode.QRCodeReader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.aps.R import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.passfile.PasswordEntry 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 = private val gpgKeySelectAction =
registerForActivityResult(StartActivityForResult()) { result -> registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
@@ -185,7 +228,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val items = val items =
arrayOf( arrayOf(
getString(R.string.otp_import_qr_code), 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) MaterialAlertDialogBuilder(this@PasswordCreationActivity)
.setItems(items) { _, index -> .setItems(items) { _, index ->
@@ -198,7 +242,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
.setDesiredBarcodeFormats(QR_CODE) .setDesiredBarcodeFormats(QR_CODE)
.createScanIntent() .createScanIntent()
) )
1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") 1 -> imageImportAction.launch("image/*")
2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
} }
} }
.show() .show()
@@ -264,9 +309,6 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
} }
} }
} }
listOf(filename, extraContent).forEach {
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
} }
suggestedPass?.let { suggestedPass?.let {
password.setText(it) password.setText(it)
@@ -278,6 +320,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} }
} }
listOf(binding.filename, binding.extraContent).forEach {
it.doAfterTextChanged { updateViewState() }
}
updateViewState() updateViewState()
} }

View File

@@ -8,22 +8,32 @@ package dev.msfjarvis.aps.ui.crypto
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore
import android.text.InputType import android.text.InputType
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.doOnTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess import com.github.michaelbull.result.onSuccess
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.google.android.material.dialog.MaterialAlertDialogBuilder 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
import com.google.zxing.integration.android.IntentIntegrator.QR_CODE import com.google.zxing.integration.android.IntentIntegrator.QR_CODE
import com.google.zxing.qrcode.QRCodeReader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.msfjarvis.aps.R import dev.msfjarvis.aps.R
import dev.msfjarvis.aps.data.passfile.PasswordEntry 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
@@ -115,7 +158,8 @@ class PasswordCreationActivityV2 : BasePgpActivity() {
val items = val items =
arrayOf( arrayOf(
getString(R.string.otp_import_qr_code), 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) MaterialAlertDialogBuilder(this@PasswordCreationActivityV2)
.setItems(items) { _, index -> .setItems(items) { _, index ->
@@ -128,7 +172,8 @@ class PasswordCreationActivityV2 : BasePgpActivity() {
.setDesiredBarcodeFormats(QR_CODE) .setDesiredBarcodeFormats(QR_CODE)
.createScanIntent() .createScanIntent()
) )
1 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") 1 -> imageImportAction.launch("image/*")
2 -> OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
} }
} }
.show() .show()
@@ -194,9 +239,6 @@ class PasswordCreationActivityV2 : BasePgpActivity() {
} }
} }
} }
listOf(filename, extraContent).forEach {
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
} }
suggestedPass?.let { suggestedPass?.let {
password.setText(it) password.setText(it)
@@ -208,6 +250,9 @@ class PasswordCreationActivityV2 : BasePgpActivity() {
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
} }
} }
listOf(binding.filename, binding.extraContent).forEach {
it.doAfterTextChanged { updateViewState() }
}
updateViewState() updateViewState()
} }

View File

@@ -394,6 +394,7 @@
<string name="clear_saved_host_key">Clear saved host key</string> <string name="clear_saved_host_key">Clear saved host key</string>
<string name="clear_saved_host_key_success">Successfully cleared saved host key!</string> <string name="clear_saved_host_key_success">Successfully cleared saved host key!</string>
<string name="otp_import_qr_code">Scan QR code</string> <string name="otp_import_qr_code">Scan QR code</string>
<string name="otp_import_from_file">Choose an image</string>
<string name="otp_import_manual_entry">Enter manually</string> <string name="otp_import_manual_entry">Enter manually</string>
<string name="otp_import_manual_hint_secret">Secret</string> <string name="otp_import_manual_hint_secret">Secret</string>
<string name="otp_import_manual_hint_account">Account</string> <string name="otp_import_manual_hint_account">Account</string>