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; package com.cringe_studios.cringe_authenticator;
import android.content.Intent;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
@ -9,7 +11,10 @@ import android.widget.AdapterView;
import android.widget.ArrayAdapter; import android.widget.ArrayAdapter;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.PickVisualMediaRequest;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog; 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.NamedFragment;
import com.cringe_studios.cringe_authenticator.fragment.SettingsFragment; import com.cringe_studios.cringe_authenticator.fragment.SettingsFragment;
import com.cringe_studios.cringe_authenticator.model.OTPData; 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.scanner.QRScannerContract;
import com.cringe_studios.cringe_authenticator.util.DialogUtil; import com.cringe_studios.cringe_authenticator.util.DialogUtil;
import com.cringe_studios.cringe_authenticator.util.NavigationUtil; 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.StyledDialogBuilder;
import com.cringe_studios.cringe_authenticator.util.ThemeUtil; import com.cringe_studios.cringe_authenticator.util.ThemeUtil;
import com.cringe_studios.cringe_authenticator_library.OTPType; 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; import java.util.Locale;
public class MainActivity extends BaseActivity { public class MainActivity extends BaseActivity {
@ -42,9 +51,9 @@ public class MainActivity extends BaseActivity {
private ActivityResultLauncher<Void> startQRCodeScan; private ActivityResultLauncher<Void> startQRCodeScan;
private boolean unlocked; private ActivityResultLauncher<PickVisualMediaRequest> pickQRCodeImage;
private long pauseTime; private QRScanner qrScanner;
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
@ -56,6 +65,8 @@ public class MainActivity extends BaseActivity {
setLocale(SettingsUtil.getLocale(this)); setLocale(SettingsUtil.getLocale(this));
qrScanner = new QRScanner();
startQRCodeScan = registerForActivityResult(new QRScannerContract(), obj -> { startQRCodeScan = registerForActivityResult(new QRScannerContract(), obj -> {
if(obj == null) return; // Cancelled 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); OTPDatabase.promptLoadDatabase(this, this::launchApp, this::finishAffinity);
} }
@Override
protected void onDestroy() {
super.onDestroy();
qrScanner.close();
}
public void setLocale(Locale locale) { public void setLocale(Locale locale) {
Locale.setDefault(locale); Locale.setDefault(locale);
Configuration config = new Configuration(); Configuration config = new Configuration();
@ -82,8 +135,6 @@ public class MainActivity extends BaseActivity {
} }
private void launchApp() { private void launchApp() {
unlocked = true;
binding = ActivityMainBinding.inflate(getLayoutInflater()); binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot()); setContentView(binding.getRoot());
@ -91,7 +142,7 @@ public class MainActivity extends BaseActivity {
binding.fabMenu.setOnClickListener(view -> NavigationUtil.navigate(this, MenuFragment.class, null)); binding.fabMenu.setOnClickListener(view -> NavigationUtil.navigate(this, MenuFragment.class, null));
binding.fabScan.setOnClickListener(view -> scanCode()); 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()); binding.fabInput.setOnClickListener(view -> inputCode());
Fragment fragment = NavigationUtil.getCurrentFragment(this); Fragment fragment = NavigationUtil.getCurrentFragment(this);
@ -148,6 +199,13 @@ public class MainActivity extends BaseActivity {
startQRCodeScan.launch(null); 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() { public void inputCode() {
DialogInputCodeChoiceBinding binding = DialogInputCodeChoiceBinding.inflate(getLayoutInflater()); DialogInputCodeChoiceBinding binding = DialogInputCodeChoiceBinding.inflate(getLayoutInflater());
@ -208,15 +266,11 @@ public class MainActivity extends BaseActivity {
@Override @Override
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
this.pauseTime = System.currentTimeMillis();
} }
@Override @Override
protected void onSaveInstanceState(@NonNull Bundle outState) { protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(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.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.media.Image; import android.media.Image;
import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent; import android.view.MotionEvent;
import android.view.WindowManager; import android.view.WindowManager;
import android.widget.Toast; 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.databinding.ActivityQrScannerBinding;
import com.cringe_studios.cringe_authenticator.model.OTPData; import com.cringe_studios.cringe_authenticator.model.OTPData;
import com.cringe_studios.cringe_authenticator.model.OTPMigrationPart; 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.StyledDialogBuilder;
import com.cringe_studios.cringe_authenticator.util.ThemeUtil; import com.cringe_studios.cringe_authenticator.util.ThemeUtil;
import com.google.common.util.concurrent.ListenableFuture; 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 com.google.mlkit.vision.common.InputImage;
import java.util.ArrayList; import java.util.ArrayList;
@ -55,7 +49,7 @@ public class QRScannerActivity extends AppCompatActivity {
private ListenableFuture<ProcessCameraProvider> cameraProviderFuture; private ListenableFuture<ProcessCameraProvider> cameraProviderFuture;
private BarcodeScanner scanner; private QRScanner scanner;
private boolean process = true; private boolean process = true;
@ -92,7 +86,7 @@ public class QRScannerActivity extends AppCompatActivity {
} }
}, ContextCompat.getMainExecutor(this)); }, ContextCompat.getMainExecutor(this));
scanner = BarcodeScanning.getClient(); scanner = new QRScanner();
} }
void bindPreview(@NonNull ProcessCameraProvider cameraProvider) { void bindPreview(@NonNull ProcessCameraProvider cameraProvider) {
@ -136,40 +130,27 @@ public class QRScannerActivity extends AppCompatActivity {
Image mediaImage = image.getImage(); Image mediaImage = image.getImage();
if(mediaImage != null) { if(mediaImage != null) {
InputImage input = InputImage.fromMediaImage(mediaImage, image.getImageInfo().getRotationDegrees()); InputImage input = InputImage.fromMediaImage(mediaImage, image.getImageInfo().getRotationDegrees());
scanner.process(input).addOnSuccessListener(barcodes -> { scanner.scan(input, code -> {
image.close(); image.close();
if(barcodes.size() >= 1) { if(code != null) {
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());
process = false; 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) { private void importCode(DetectedCode code) {
if("otpauth-migration".equalsIgnoreCase(uri.getScheme())) { if(code.isMigrationPart()) {
OTPMigrationPart part; OTPMigrationPart part = code.getMigrationPart();
try {
part = OTPParser.parseMigration(uri);
}catch(IllegalArgumentException e) {
error(e.getMessage());
return;
}
if((lastPart != null && part.getBatchIndex() != lastPart.getBatchIndex() + 1) || (lastPart == null && part.getBatchIndex() > 0)) { 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 // Not next batch, or first batch (if nothing was scanned yet), keep looking
@ -206,7 +187,6 @@ public class QRScannerActivity extends AppCompatActivity {
} }
} }
return; return;
} }
@ -217,12 +197,18 @@ public class QRScannerActivity extends AppCompatActivity {
} }
try { try {
success(OTPParser.parse(uri)); success(code.getData());
}catch(IllegalArgumentException e) { }catch(IllegalArgumentException e) {
error(e.getMessage()); error(e.getMessage());
} }
} }
@Override
protected void onDestroy() {
super.onDestroy();
scanner.close();
}
private void success(@NonNull OTPData... data) { private void success(@NonNull OTPData... data) {
Intent result = new Intent(); Intent result = new Intent();
result.putExtra("data", data); result.putExtra("data", data);