Import OTP codes from QR code image

This commit is contained in:
MrLetsplay 2023-09-19 22:35:30 +02:00
parent e6db8ba5d9
commit 2d82bd5725
Signed by: mr
SSH Key Fingerprint: SHA256:92jBH80vpXyaZHjaIl47pjRq+Yt7XGTArqQg1V7hSqg
4 changed files with 218 additions and 45 deletions

View File

@ -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<Void> startQRCodeScan;
private boolean unlocked;
private ActivityResultLauncher<PickVisualMediaRequest> 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);
}
}
}

View File

@ -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;
}
}

View File

@ -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<DetectedCode> onSuccess, Consumer<String> 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;
}
}
}

View File

@ -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<ProcessCameraProvider> 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);