From bd3697659f1509a2c22910da4805fc9b3185a33d Mon Sep 17 00:00:00 2001 From: Alexander Bakker Date: Sun, 7 Aug 2022 17:05:08 +0200 Subject: [PATCH] Try harder to find QR codes in image files And refactor a bit by moving some of the QR scanning related logic to a separate helper class. --- .../aegis/helpers/QrCodeAnalyzer.java | 16 +--- .../aegis/helpers/QrCodeHelper.java | 96 +++++++++++++++++++ .../aegis/ui/MainActivity.java | 61 ++++-------- .../aegis/ui/TransferEntriesActivity.java | 30 ++---- .../aegis/ui/tasks/QrDecodeTask.java | 62 ++++++++++++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 184 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java create mode 100644 app/src/main/java/com/beemdevelopment/aegis/ui/tasks/QrDecodeTask.java diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java index c35861fa..2fd1b21f 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeAnalyzer.java @@ -9,19 +9,11 @@ import androidx.annotation.NonNull; import androidx.camera.core.ImageAnalysis; import androidx.camera.core.ImageProxy; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.BinaryBitmap; -import com.google.zxing.DecodeHintType; -import com.google.zxing.MultiFormatReader; import com.google.zxing.NotFoundException; import com.google.zxing.PlanarYUVLuminanceSource; import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; public class QrCodeAnalyzer implements ImageAnalysis.Analyzer { private static final String TAG = QrCodeAnalyzer.class.getSimpleName(); @@ -59,14 +51,8 @@ public class QrCodeAnalyzer implements ImageAnalysis.Analyzer { false ); - MultiFormatReader reader = new MultiFormatReader(); - BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); try { - Map hints = new HashMap<>(); - hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); - hints.put(DecodeHintType.ALSO_INVERTED, true); - - Result result = reader.decode(bitmap, hints); + Result result = QrCodeHelper.decodeFromSource(source); if (_listener != null) { _listener.onQrCodeDetected(result); } diff --git a/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java new file mode 100644 index 00000000..37291e9c --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/helpers/QrCodeHelper.java @@ -0,0 +1,96 @@ +package com.beemdevelopment.aegis.helpers; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; + +import androidx.annotation.ColorInt; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.BinaryBitmap; +import com.google.zxing.DecodeHintType; +import com.google.zxing.LuminanceSource; +import com.google.zxing.MultiFormatReader; +import com.google.zxing.NotFoundException; +import com.google.zxing.RGBLuminanceSource; +import com.google.zxing.Result; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.common.HybridBinarizer; +import com.google.zxing.qrcode.QRCodeWriter; + +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class QrCodeHelper { + private QrCodeHelper() { + + } + + public static Result decodeFromSource(LuminanceSource source) throws NotFoundException { + Map hints = new HashMap<>(); + hints.put(DecodeHintType.POSSIBLE_FORMATS, Collections.singletonList(BarcodeFormat.QR_CODE)); + hints.put(DecodeHintType.ALSO_INVERTED, true); + + BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); + MultiFormatReader reader = new MultiFormatReader(); + return reader.decode(bitmap, hints); + } + + public static Result decodeFromStream(InputStream inStream) throws DecodeError { + BitmapFactory.Options bmOptions = new BitmapFactory.Options(); + Bitmap bitmap = BitmapFactory.decodeStream(inStream, null, bmOptions); + if (bitmap == null) { + throw new DecodeError("Unable to decode stream to bitmap"); + } + + // If ZXing is not able to decode the image on the first try, we try a couple of + // more times with smaller versions of the same image. + for (int i = 0; i <= 2; i++) { + if (i != 0) { + bitmap = BitmapHelper.resize(bitmap, bitmap.getWidth() / (i * 2), bitmap.getHeight() / (i * 2)); + } + + try { + int[] pixels = new int[bitmap.getWidth() * bitmap.getHeight()]; + bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); + + LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), pixels); + return decodeFromSource(source); + } catch (NotFoundException ignored) { + + } + } + + throw new DecodeError(NotFoundException.getNotFoundInstance()); + } + + public static Bitmap encodeToBitmap(String data, int width, int height, @ColorInt int backgroundColor) throws WriterException { + QRCodeWriter writer = new QRCodeWriter(); + BitMatrix bitMatrix = writer.encode(data, BarcodeFormat.QR_CODE, width, height); + + int[] pixels = new int[width * height]; + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + public static class DecodeError extends Exception { + public DecodeError(String message) { + super(message); + } + + public DecodeError(Throwable cause) { + super(cause); + } + } +} diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java index 024b67f3..c03decd2 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/MainActivity.java @@ -5,8 +5,6 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Bundle; import android.provider.Settings; @@ -26,32 +24,19 @@ import com.beemdevelopment.aegis.Preferences; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.SortCategory; import com.beemdevelopment.aegis.ViewMode; -import com.beemdevelopment.aegis.helpers.BitmapHelper; import com.beemdevelopment.aegis.helpers.FabScrollHelper; import com.beemdevelopment.aegis.helpers.PermissionHelper; -import com.beemdevelopment.aegis.helpers.QrCodeAnalyzer; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.otp.GoogleAuthInfoException; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; import com.beemdevelopment.aegis.ui.fragments.preferences.BackupsPreferencesFragment; import com.beemdevelopment.aegis.ui.fragments.preferences.PreferencesFragment; +import com.beemdevelopment.aegis.ui.tasks.QrDecodeTask; import com.beemdevelopment.aegis.ui.views.EntryListView; import com.beemdevelopment.aegis.vault.VaultEntry; import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.zxing.BinaryBitmap; -import com.google.zxing.ChecksumException; -import com.google.zxing.FormatException; -import com.google.zxing.LuminanceSource; -import com.google.zxing.NotFoundException; -import com.google.zxing.RGBLuminanceSource; -import com.google.zxing.Reader; -import com.google.zxing.Result; -import com.google.zxing.common.HybridBinarizer; -import com.google.zxing.qrcode.QRCodeReader; -import java.io.IOException; -import java.io.InputStream; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -326,37 +311,25 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene } private void onScanImageResult(Intent intent) { - decodeQrCodeImage(intent.getData()); + startDecodeQrCodeImage(intent.getData()); } - private void decodeQrCodeImage(Uri inputFile) { - Bitmap bitmap; - - try { - BitmapFactory.Options bmOptions = new BitmapFactory.Options(); - - try (InputStream inputStream = getContentResolver().openInputStream(inputFile)) { - bitmap = BitmapFactory.decodeStream(inputStream, null, bmOptions); - bitmap = BitmapHelper.resize(bitmap, QrCodeAnalyzer.RESOLUTION.getWidth(), QrCodeAnalyzer.RESOLUTION.getHeight()); + private void startDecodeQrCodeImage(Uri uri) { + QrDecodeTask task = new QrDecodeTask(this, (result) -> { + if (result.getException() != null) { + Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, result.getException()); + return; } - int[] intArray = new int[bitmap.getWidth() * bitmap.getHeight()]; - bitmap.getPixels(intArray, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); - - LuminanceSource source = new RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray); - BinaryBitmap binaryBitmap = new BinaryBitmap(new HybridBinarizer(source)); - - Reader reader = new QRCodeReader(); - Result result = reader.decode(binaryBitmap); - - GoogleAuthInfo info = GoogleAuthInfo.parseUri(result.getText()); - VaultEntry entry = new VaultEntry(info); - - startEditEntryActivityForNew(CODE_ADD_ENTRY, entry); - } catch (NotFoundException | IOException | ChecksumException | FormatException | GoogleAuthInfoException e) { - e.printStackTrace(); - Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, e); - } + try { + GoogleAuthInfo info = GoogleAuthInfo.parseUri(result.getResult().getText()); + VaultEntry entry = new VaultEntry(info); + startEditEntryActivityForNew(CODE_ADD_ENTRY, entry); + } catch (GoogleAuthInfoException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, e); + } + }); + task.execute(getLifecycle(), uri); } private void updateSortCategoryMenu() { @@ -471,7 +444,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene intent.setAction(null); intent.removeExtra(Intent.EXTRA_STREAM); - decodeQrCodeImage(uri); + startDecodeQrCodeImage(uri); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java index 21af2021..f4350153 100644 --- a/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/TransferEntriesActivity.java @@ -15,12 +15,10 @@ import androidx.annotation.ColorInt; import com.beemdevelopment.aegis.R; import com.beemdevelopment.aegis.Theme; +import com.beemdevelopment.aegis.helpers.QrCodeHelper; import com.beemdevelopment.aegis.otp.GoogleAuthInfo; import com.beemdevelopment.aegis.ui.dialogs.Dialogs; -import com.google.zxing.BarcodeFormat; import com.google.zxing.WriterException; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; import java.util.ArrayList; import java.util.List; @@ -104,22 +102,12 @@ public class TransferEntriesActivity extends AegisActivity { return true; } - private void generateQR() { GoogleAuthInfo selectedEntry = _authInfos.get(_currentEntryCount - 1); _issuer.setText(selectedEntry.getIssuer()); _accountName.setText(selectedEntry.getAccountName()); _entriesCount.setText(getResources().getQuantityString(R.plurals.entries_count, _authInfos.size(), _currentEntryCount, _authInfos.size())); - QRCodeWriter writer = new QRCodeWriter(); - BitMatrix bitMatrix; - try { - bitMatrix = writer.encode(selectedEntry.getUri().toString(), BarcodeFormat.QR_CODE, 512, 512); - } catch (WriterException e) { - Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e); - return; - } - @ColorInt int backgroundColor = Color.WHITE; if (getConfiguredTheme() == Theme.LIGHT) { TypedValue typedValue = new TypedValue(); @@ -127,18 +115,14 @@ public class TransferEntriesActivity extends AegisActivity { backgroundColor = typedValue.data; } - int width = bitMatrix.getWidth(); - int height = bitMatrix.getHeight(); - int[] pixels = new int[width * height]; - for (int y = 0; y < height; y++) { - int offset = y * width; - for (int x = 0; x < width; x++) { - pixels[offset + x] = bitMatrix.get(x, y) ? Color.BLACK : backgroundColor; - } + Bitmap bitmap; + try { + bitmap = QrCodeHelper.encodeToBitmap(selectedEntry.getUri().toString(), 512, 512, backgroundColor); + } catch (WriterException e) { + Dialogs.showErrorDialog(this, R.string.unable_to_generate_qrcode, e); + return; } - Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - bitmap.setPixels(pixels, 0, width, 0, 0, width, height); _qrImage.setImageBitmap(bitmap); } } diff --git a/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/QrDecodeTask.java b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/QrDecodeTask.java new file mode 100644 index 00000000..ea23ffa1 --- /dev/null +++ b/app/src/main/java/com/beemdevelopment/aegis/ui/tasks/QrDecodeTask.java @@ -0,0 +1,62 @@ +package com.beemdevelopment.aegis.ui.tasks; + +import android.content.Context; +import android.net.Uri; + +import com.beemdevelopment.aegis.R; +import com.beemdevelopment.aegis.helpers.QrCodeHelper; +import com.google.zxing.Result; + +import java.io.IOException; +import java.io.InputStream; + +public class QrDecodeTask extends ProgressDialogTask { + private final Callback _cb; + + public QrDecodeTask(Context context, Callback cb) { + super(context, context.getString(R.string.analyzing_qr)); + _cb = cb; + } + + @Override + protected Response doInBackground(Uri... params) { + Context context = getDialog().getContext(); + + Uri uri = params[0]; + try (InputStream inStream = context.getContentResolver().openInputStream(uri)) { + Result result = QrCodeHelper.decodeFromStream(inStream); + return new Response(result, null); + } catch (QrCodeHelper.DecodeError | IOException e) { + e.printStackTrace(); + return new Response(null, e); + } + } + + @Override + protected void onPostExecute(Response result) { + super.onPostExecute(result); + _cb.onTaskFinished(result); + } + + public interface Callback { + void onTaskFinished(Response result); + } + + public static class Response { + private final Result _result; + private final Exception _e; + + public Response(Result result, Exception e) { + _result = result; + _e = e; + } + + public Result getResult() { + return _result; + } + + public Exception getException() { + return _e; + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5fc2d4f..495bdf56 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -156,6 +156,7 @@ Encrypting the vault Exporting the vault Reading file + Analyzing QR code Importing icon pack Delete entry Are you sure you want to delete this entry?