diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java index 19a2ef5..5941245 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/MainActivity.java @@ -1,7 +1,9 @@ package com.cringe_studios.cringe_authenticator; +import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; +import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; @@ -9,7 +11,10 @@ import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.Toast; +import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.PickVisualMediaRequest; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; @@ -25,6 +30,7 @@ import com.cringe_studios.cringe_authenticator.fragment.MenuFragment; import com.cringe_studios.cringe_authenticator.fragment.NamedFragment; import com.cringe_studios.cringe_authenticator.fragment.SettingsFragment; import com.cringe_studios.cringe_authenticator.model.OTPData; +import com.cringe_studios.cringe_authenticator.scanner.QRScanner; import com.cringe_studios.cringe_authenticator.scanner.QRScannerContract; import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.NavigationUtil; @@ -33,7 +39,10 @@ import com.cringe_studios.cringe_authenticator.util.SettingsUtil; import com.cringe_studios.cringe_authenticator.util.StyledDialogBuilder; import com.cringe_studios.cringe_authenticator.util.ThemeUtil; import com.cringe_studios.cringe_authenticator_library.OTPType; +import com.google.mlkit.vision.common.InputImage; +import java.io.IOException; +import java.util.Arrays; import java.util.Locale; public class MainActivity extends BaseActivity { @@ -42,9 +51,9 @@ public class MainActivity extends BaseActivity { private ActivityResultLauncher startQRCodeScan; - private boolean unlocked; + private ActivityResultLauncher pickQRCodeImage; - private long pauseTime; + private QRScanner qrScanner; @Override protected void onCreate(Bundle savedInstanceState) { @@ -56,6 +65,8 @@ public class MainActivity extends BaseActivity { setLocale(SettingsUtil.getLocale(this)); + qrScanner = new QRScanner(); + startQRCodeScan = registerForActivityResult(new QRScannerContract(), obj -> { if(obj == null) return; // Cancelled @@ -71,9 +82,51 @@ public class MainActivity extends BaseActivity { } }); + pickQRCodeImage = registerForActivityResult(new ActivityResultContracts.PickVisualMedia(), img -> { + try { + InputImage image = InputImage.fromFilePath(this, img); + qrScanner.scan(image, code -> { + if(code == null) { + DialogUtil.showErrorDialog(this, "No codes were detected in the provided image"); + return; + } + + if(code.isMigrationPart()) { + new StyledDialogBuilder(this) // TODO: duplicated from QRScannerActivity + .setTitle(R.string.qr_scanner_migration_title) + .setMessage(R.string.qr_scanner_migration_message) + .setPositiveButton(R.string.yes, (d, which) -> { + Fragment fragment = NavigationUtil.getCurrentFragment(this); + if (fragment instanceof GroupFragment) { + GroupFragment frag = (GroupFragment) fragment; + for (OTPData dt : code.getOTPs()) frag.addOTP(dt); + } + }) + .setNegativeButton(R.string.no, (d, which) -> {}) + .show() + .setCanceledOnTouchOutside(false); + }else { + Fragment fragment = NavigationUtil.getCurrentFragment(this); + if (fragment instanceof GroupFragment) { + GroupFragment frag = (GroupFragment) fragment; + for (OTPData dt : code.getOTPs()) frag.addOTP(dt); + } + } + }, error -> DialogUtil.showErrorDialog(this, "Failed to detect code: " + error)); + } catch (IOException e) { + DialogUtil.showErrorDialog(this, "Failed to read image: " + e); + } + }); + OTPDatabase.promptLoadDatabase(this, this::launchApp, this::finishAffinity); } + @Override + protected void onDestroy() { + super.onDestroy(); + qrScanner.close(); + } + public void setLocale(Locale locale) { Locale.setDefault(locale); Configuration config = new Configuration(); @@ -82,8 +135,6 @@ public class MainActivity extends BaseActivity { } private void launchApp() { - unlocked = true; - binding = ActivityMainBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); @@ -91,7 +142,7 @@ public class MainActivity extends BaseActivity { binding.fabMenu.setOnClickListener(view -> NavigationUtil.navigate(this, MenuFragment.class, null)); binding.fabScan.setOnClickListener(view -> scanCode()); - //binding.fabScanImage.setOnClickListener(view -> scanCode()); TODO: scan image + binding.fabScanImage.setOnClickListener(view -> scanCodeFromImage()); // TODO: scan image binding.fabInput.setOnClickListener(view -> inputCode()); Fragment fragment = NavigationUtil.getCurrentFragment(this); @@ -148,6 +199,13 @@ public class MainActivity extends BaseActivity { startQRCodeScan.launch(null); } + public void scanCodeFromImage() { + Object mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly.INSTANCE; // Cursed, but needs to happen because otherwise Android Studio complains about type casting even though it works + pickQRCodeImage.launch(new PickVisualMediaRequest.Builder() + .setMediaType((ActivityResultContracts.PickVisualMedia.VisualMediaType) mediaType) + .build()); + } + public void inputCode() { DialogInputCodeChoiceBinding binding = DialogInputCodeChoiceBinding.inflate(getLayoutInflater()); @@ -208,15 +266,11 @@ public class MainActivity extends BaseActivity { @Override protected void onPause() { super.onPause(); - this.pauseTime = System.currentTimeMillis(); } @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - if(unlocked) { - outState.putLong("pauseTime", pauseTime); - } } } \ No newline at end of file diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/DetectedCode.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/DetectedCode.java new file mode 100644 index 0000000..d97f8f1 --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/DetectedCode.java @@ -0,0 +1,36 @@ +package com.cringe_studios.cringe_authenticator.scanner; + +import com.cringe_studios.cringe_authenticator.model.OTPData; +import com.cringe_studios.cringe_authenticator.model.OTPMigrationPart; +import com.cringe_studios.cringe_authenticator.proto.OTPMigration; + +public class DetectedCode { + + private OTPData data; + private OTPMigrationPart migrationPart; + + public DetectedCode(OTPData data) { + this.data = data; + } + + public DetectedCode(OTPMigrationPart migrationPart) { + this.migrationPart = migrationPart; + } + + public boolean isMigrationPart() { + return migrationPart != null; + } + + public OTPData getData() { + return data; + } + + public OTPData[] getOTPs() { + return data != null ? new OTPData[] { data } : migrationPart.getOTPs(); + } + + public OTPMigrationPart getMigrationPart() { + return migrationPart; + } + +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScanner.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScanner.java new file mode 100644 index 0000000..85e55fa --- /dev/null +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScanner.java @@ -0,0 +1,97 @@ +package com.cringe_studios.cringe_authenticator.scanner; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +import com.cringe_studios.cringe_authenticator.model.OTPMigrationPart; +import com.cringe_studios.cringe_authenticator.util.OTPParser; +import com.google.mlkit.vision.barcode.BarcodeScanner; +import com.google.mlkit.vision.barcode.BarcodeScanning; +import com.google.mlkit.vision.barcode.common.Barcode; +import com.google.mlkit.vision.common.InputImage; + +public class QRScanner { + + private BarcodeScanner scanner; + + public QRScanner() { + scanner = BarcodeScanning.getClient(); + } + + public void scan(InputImage image, Consumer onSuccess, Consumer onFailure) { + scanner.process(image).addOnSuccessListener(barcodes -> { + if(barcodes.isEmpty()) { + onSuccess.accept(null); + return; + } + + Barcode code = null; + + for(Barcode c : barcodes) { + if(c.getValueType() == Barcode.TYPE_TEXT) { + code = c; // TODO: maybe consider whether this code is actually valid (for cases with multiple text QR codes in one image) + break; + } + } + + if(code == null) { + onSuccess.accept(null); + return; + } + + Uri uri = Uri.parse(code.getRawValue()); + Result r = parse(uri); + if(r.code != null) { + onSuccess.accept(r.code); + }else { + onFailure.accept(r.error); + } + }) + .addOnFailureListener(e -> onFailure.accept(e.toString())); + } + + private Result parse(Uri uri) { + if("otpauth-migration".equalsIgnoreCase(uri.getScheme())) { + OTPMigrationPart part; + + try { + part = OTPParser.parseMigration(uri); + }catch(IllegalArgumentException e) { + return new Result(e.getMessage()); + } + + return new Result(new DetectedCode(part)); + } + + try { + return new Result(new DetectedCode(OTPParser.parse(uri))); + }catch(IllegalArgumentException e) { + return new Result(e.getMessage()); + } + } + + public void close() { + scanner.close(); + } + + private static class Result { + + public final DetectedCode code; + public final String error; + + private Result(@NonNull DetectedCode code) { + this.code = code; + this.error = null; + } + + private Result(String error) { + this.error = error; + this.code = null; + } + + } + +} diff --git a/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScannerActivity.java b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScannerActivity.java index 268bbd9..3f00022 100644 --- a/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScannerActivity.java +++ b/app/src/main/java/com/cringe_studios/cringe_authenticator/scanner/QRScannerActivity.java @@ -5,10 +5,8 @@ import android.app.Activity; import android.content.Intent; import android.content.pm.PackageManager; import android.media.Image; -import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.util.Log; import android.view.MotionEvent; import android.view.WindowManager; import android.widget.Toast; @@ -33,13 +31,9 @@ import com.cringe_studios.cringe_authenticator.R; import com.cringe_studios.cringe_authenticator.databinding.ActivityQrScannerBinding; import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator.model.OTPMigrationPart; -import com.cringe_studios.cringe_authenticator.util.OTPParser; import com.cringe_studios.cringe_authenticator.util.StyledDialogBuilder; import com.cringe_studios.cringe_authenticator.util.ThemeUtil; import com.google.common.util.concurrent.ListenableFuture; -import com.google.mlkit.vision.barcode.BarcodeScanner; -import com.google.mlkit.vision.barcode.BarcodeScanning; -import com.google.mlkit.vision.barcode.common.Barcode; import com.google.mlkit.vision.common.InputImage; import java.util.ArrayList; @@ -55,7 +49,7 @@ public class QRScannerActivity extends AppCompatActivity { private ListenableFuture cameraProviderFuture; - private BarcodeScanner scanner; + private QRScanner scanner; private boolean process = true; @@ -92,7 +86,7 @@ public class QRScannerActivity extends AppCompatActivity { } }, ContextCompat.getMainExecutor(this)); - scanner = BarcodeScanning.getClient(); + scanner = new QRScanner(); } void bindPreview(@NonNull ProcessCameraProvider cameraProvider) { @@ -136,40 +130,27 @@ public class QRScannerActivity extends AppCompatActivity { Image mediaImage = image.getImage(); if(mediaImage != null) { InputImage input = InputImage.fromMediaImage(mediaImage, image.getImageInfo().getRotationDegrees()); - scanner.process(input).addOnSuccessListener(barcodes -> { + scanner.scan(input, code -> { image.close(); - if(barcodes.size() >= 1) { - Barcode code = null; - - for(Barcode c : barcodes) { - if(c.getValueType() == Barcode.TYPE_TEXT) { - code = c; - break; - } - } - - if(code == null) return; - - Uri uri = Uri.parse(code.getRawValue()); + if(code != null) { process = false; - importUri(uri); + importCode(code); } - }).addOnFailureListener(e -> {}); + }, error -> { + image.close(); + error(error); + }); + /*.addOnSuccessListener(barcodes -> { + + }).addOnFailureListener(e -> error("Failed to scan code"));*/ } } } - private void importUri(Uri uri) { - if("otpauth-migration".equalsIgnoreCase(uri.getScheme())) { - OTPMigrationPart part; - - try { - part = OTPParser.parseMigration(uri); - }catch(IllegalArgumentException e) { - error(e.getMessage()); - return; - } + private void importCode(DetectedCode code) { + if(code.isMigrationPart()) { + OTPMigrationPart part = code.getMigrationPart(); if((lastPart != null && part.getBatchIndex() != lastPart.getBatchIndex() + 1) || (lastPart == null && part.getBatchIndex() > 0)) { // Not next batch, or first batch (if nothing was scanned yet), keep looking @@ -206,7 +187,6 @@ public class QRScannerActivity extends AppCompatActivity { } } - return; } @@ -217,12 +197,18 @@ public class QRScannerActivity extends AppCompatActivity { } try { - success(OTPParser.parse(uri)); + success(code.getData()); }catch(IllegalArgumentException e) { error(e.getMessage()); } } + @Override + protected void onDestroy() { + super.onDestroy(); + scanner.close(); + } + private void success(@NonNull OTPData... data) { Intent result = new Intent(); result.putExtra("data", data);