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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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
- Allow pinning shortcuts directly to the launcher home screen
- Another workaround for SteamGuard's non-standard OTP format
- Allow importing QR code from images
### Fixed

View file

@ -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()
}

View file

@ -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()
}

View file

@ -394,6 +394,7 @@
<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="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_hint_secret">Secret</string>
<string name="otp_import_manual_hint_account">Account</string>