Add support for importing multiple QR code images in one go

This is also part of the preparation needed for scanning Google
Authenticator Export QR codes from images.
This commit is contained in:
Alexander Bakker 2022-08-07 20:18:21 +02:00
parent b875baacef
commit e46857a26e
36 changed files with 232 additions and 110 deletions

View file

@ -0,0 +1,27 @@
package com.beemdevelopment.aegis.helpers;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
public class SafHelper {
private SafHelper() {
}
public static String getFileName(Context context, Uri uri) {
if (uri.getScheme() != null && uri.getScheme().equals("content")) {
try (Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
int i = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (i != -1) {
return cursor.getString(i);
}
}
}
}
return uri.getLastPathSegment();
}
}

View file

@ -1,11 +1,7 @@
package com.beemdevelopment.aegis.ui;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Toast;
@ -36,7 +32,6 @@ import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class ImportEntriesActivity extends AegisActivity {
@ -190,39 +185,11 @@ public class ImportEntriesActivity extends AegisActivity {
List<DatabaseImporterEntryException> errors = result.getErrors();
if (errors.size() > 0) {
showErrorDialog(errors);
String message = getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size());
Dialogs.showMultiErrorDialog(this, R.string.import_error_title, message, errors, null);
}
}
private void showErrorDialog(List<DatabaseImporterEntryException> errors) {
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
.setTitle(R.string.import_error_title)
.setMessage(getResources().getQuantityString(R.plurals.import_error_dialog, errors.size(), errors.size()))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(getString(R.string.details), (dialog, which) -> showDetailedErrorDialog(errors))
.create());
}
private void showDetailedErrorDialog(List<DatabaseImporterEntryException> errors) {
List<String> messages = new ArrayList<>();
for (DatabaseImporterEntryException e : errors) {
messages.add(e.getMessage());
}
String message = TextUtils.join("\n\n", messages);
Dialogs.showSecureDialog(new AlertDialog.Builder(this)
.setTitle(R.string.import_error_title)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(android.R.string.copy, (dialog2, which2) -> {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("text/plain", message);
clipboard.setPrimaryClip(clip);
Toast.makeText(this, R.string.errors_copied, Toast.LENGTH_SHORT).show();
})
.create());
}
private void showWipeEntriesDialog() {
Dialogs.showCheckboxDialog(this, R.string.dialog_wipe_entries_title,
R.string.dialog_wipe_entries_message,

View file

@ -4,10 +4,15 @@ import android.Manifest;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.StyleSpan;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
@ -38,6 +43,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
@ -275,17 +281,8 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private void onScanResult(Intent data) {
List<VaultEntry> entries = (ArrayList<VaultEntry>) data.getSerializableExtra("entries");
if (entries.size() == 1) {
startEditEntryActivityForNew(CODE_ADD_ENTRY, entries.get(0));
} else {
for (VaultEntry entry : entries) {
_vaultManager.getVault().addEntry(entry);
if (_loaded) {
_entryListView.addEntry(entry);
}
}
saveAndBackupVault();
if (entries != null) {
importScannedEntries(entries);
}
}
@ -311,25 +308,78 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
}
private void onScanImageResult(Intent intent) {
startDecodeQrCodeImage(intent.getData());
if (intent.getData() != null) {
startDecodeQrCodeImages(Collections.singletonList(intent.getData()));
return;
}
if (intent.getClipData() != null) {
ClipData data = intent.getClipData();
List<Uri> uris = new ArrayList<>();
for (int i = 0; i < data.getItemCount(); i++) {
ClipData.Item item = data.getItemAt(i);
if (item.getUri() != null) {
uris.add(item.getUri());
}
}
if (uris.size() > 0) {
startDecodeQrCodeImages(uris);
}
}
}
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;
private static CharSequence buildImportError(String fileName, Throwable e) {
SpannableStringBuilder builder = new SpannableStringBuilder(String.format("%s:\n%s", fileName, e));
builder.setSpan(new StyleSpan(Typeface.BOLD), 0, fileName.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return builder;
}
private void startDecodeQrCodeImages(List<Uri> uris) {
QrDecodeTask task = new QrDecodeTask(this, (results) -> {
List<CharSequence> errors = new ArrayList<>();
List<VaultEntry> entries = new ArrayList<>();
for (QrDecodeTask.Result res : results) {
if (res.getException() != null) {
errors.add(buildImportError(res.getFileName(), res.getException()));
continue;
}
try {
GoogleAuthInfo info = GoogleAuthInfo.parseUri(res.getResult().getText());
VaultEntry entry = new VaultEntry(info);
entries.add(entry);
} catch (GoogleAuthInfoException e) {
errors.add(buildImportError(res.getFileName(), 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);
final DialogInterface.OnClickListener dialogDismissHandler = (dialog, which) -> importScannedEntries(entries);
if ((errors.size() > 0 && results.size() > 1) || errors.size() > 1) {
Dialogs.showMultiMessageDialog(this, R.string.import_error_title, getString(R.string.unable_to_read_qrcode_files, uris.size() - errors.size(), uris.size()), errors, dialogDismissHandler);
} else if (errors.size() > 0) {
Dialogs.showErrorDialog(this, getString(R.string.unable_to_read_qrcode_file, results.get(0).getFileName()), errors.get(0), dialogDismissHandler);
} else {
importScannedEntries(entries);
}
});
task.execute(getLifecycle(), uri);
task.execute(getLifecycle(), uris);
}
private void importScannedEntries(List<VaultEntry> entries) {
if (entries.size() == 1) {
startEditEntryActivityForNew(CODE_ADD_ENTRY, entries.get(0));
} else if (entries.size() > 1) {
for (VaultEntry entry: entries) {
_vaultManager.getVault().addEntry(entry);
_entryListView.addEntry(entry);
}
if (saveAndBackupVault()) {
Toast.makeText(this, getResources().getQuantityString(R.plurals.added_new_entries, entries.size(), entries.size()), Toast.LENGTH_LONG).show();
}
}
}
private void updateSortCategoryMenu() {
@ -368,9 +418,11 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private void startScanImageActivity() {
Intent galleryIntent = new Intent(Intent.ACTION_PICK);
galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
galleryIntent.setDataAndType(android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*");
Intent fileIntent = new Intent(Intent.ACTION_GET_CONTENT);
fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
fileIntent.setType("image/*");
Intent chooserIntent = Intent.createChooser(galleryIntent, getString(R.string.select_picture));
@ -421,7 +473,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
info = GoogleAuthInfo.parseUri(uri);
} catch (GoogleAuthInfoException e) {
e.printStackTrace();
Dialogs.showErrorDialog(this, R.string.unable_to_read_qrcode, e);
Dialogs.showErrorDialog(this, R.string.unable_to_process_deeplink, e);
}
if (info != null) {
@ -444,7 +496,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
intent.setAction(null);
intent.removeExtra(Intent.EXTRA_STREAM);
startDecodeQrCodeImage(uri);
startDecodeQrCodeImages(Collections.singletonList(uri));
}
}

View file

@ -10,6 +10,7 @@ import android.content.res.ColorStateList;
import android.graphics.Color;
import android.text.Editable;
import android.text.InputType;
import android.text.SpannableStringBuilder;
import android.text.TextWatcher;
import android.text.method.PasswordTransformationMethod;
import android.view.LayoutInflater;
@ -24,6 +25,7 @@ import android.widget.ListView;
import android.widget.NumberPicker;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.ComponentActivity;
import androidx.annotation.StringRes;
@ -44,6 +46,7 @@ import com.google.android.material.textfield.TextInputLayout;
import com.nulabinc.zxcvbn.Strength;
import com.nulabinc.zxcvbn.Zxcvbn;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
@ -340,6 +343,10 @@ public class Dialogs {
showErrorDialog(context, message, e, null);
}
public static void showErrorDialog(Context context, String message, Exception e) {
showErrorDialog(context, message, e, null);
}
public static void showErrorDialog(Context context, @StringRes int message, CharSequence error) {
showErrorDialog(context, message, error, null);
}
@ -348,7 +355,15 @@ public class Dialogs {
showErrorDialog(context, message, e.toString(), listener);
}
public static void showErrorDialog(Context context, String message, Exception e, DialogInterface.OnClickListener listener) {
showErrorDialog(context, message, e.toString(), listener);
}
public static void showErrorDialog(Context context, @StringRes int message, CharSequence error, DialogInterface.OnClickListener listener) {
showErrorDialog(context, context.getString(message), error, listener);
}
public static void showErrorDialog(Context context, String message, CharSequence error, DialogInterface.OnClickListener listener) {
View view = LayoutInflater.from(context).inflate(R.layout.dialog_error, null);
TextView textDetails = view.findViewById(R.id.error_details);
textDetails.setText(error);
@ -388,6 +403,66 @@ public class Dialogs {
Dialogs.showSecureDialog(dialog);
}
public static void showMultiMessageDialog(
Context context, @StringRes int title, String message, List<CharSequence> messages, DialogInterface.OnClickListener listener) {
Dialogs.showSecureDialog(new AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
if (listener != null) {
listener.onClick(dialog, which);
}
})
.setNeutralButton(context.getString(R.string.details), (dialog, which) -> {
showDetailedMultiMessageDialog(context, title, messages, listener);
})
.create());
}
public static <T extends Throwable> void showMultiErrorDialog(
Context context, @StringRes int title, String message, List<T> errors, DialogInterface.OnClickListener listener) {
List<CharSequence> messages = new ArrayList<>();
for (Throwable e : errors) {
messages.add(e.toString());
}
showMultiMessageDialog(context, title, message, messages, listener);
}
private static void showDetailedMultiMessageDialog(
Context context, @StringRes int title, List<CharSequence> messages, DialogInterface.OnClickListener listener) {
SpannableStringBuilder builder = new SpannableStringBuilder();
for (CharSequence message : messages) {
builder.append(message);
builder.append("\n\n");
}
AlertDialog dialog = new AlertDialog.Builder(context)
.setTitle(title)
.setMessage(builder)
.setCancelable(false)
.setPositiveButton(android.R.string.ok, (dialog1, which) -> {
if (listener != null) {
listener.onClick(dialog1, which);
}
})
.setNeutralButton(android.R.string.copy, null)
.create();
dialog.setOnShowListener(d -> {
Button button = dialog.getButton(AlertDialog.BUTTON_NEUTRAL);
button.setOnClickListener(v -> {
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText("text/plain", builder.toString());
clipboard.setPrimaryClip(clip);
Toast.makeText(context, R.string.errors_copied, Toast.LENGTH_SHORT).show();
});
});
Dialogs.showSecureDialog(dialog);
}
public static void showTimeSyncWarningDialog(Context context, Dialog.OnClickListener listener) {
Preferences prefs = new Preferences(context);
View view = LayoutInflater.from(context).inflate(R.layout.dialog_time_sync, null);

View file

@ -5,12 +5,14 @@ import android.net.Uri;
import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.helpers.QrCodeHelper;
import com.google.zxing.Result;
import com.beemdevelopment.aegis.helpers.SafHelper;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
public class QrDecodeTask extends ProgressDialogTask<Uri, QrDecodeTask.Response> {
public class QrDecodeTask extends ProgressDialogTask<List<Uri>, List<QrDecodeTask.Result>> {
private final Callback _cb;
public QrDecodeTask(Context context, Callback cb) {
@ -19,39 +21,61 @@ public class QrDecodeTask extends ProgressDialogTask<Uri, QrDecodeTask.Response>
}
@Override
protected Response doInBackground(Uri... params) {
protected List<Result> doInBackground(List<Uri>... params) {
List<Result> res = new ArrayList<>();
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);
List<Uri> uris = params[0];
for (Uri uri : uris) {
String fileName = SafHelper.getFileName(context, uri);
if (uris.size() > 1) {
publishProgress(context.getString(R.string.analyzing_qr_multiple, uris.indexOf(uri) + 1, uris.size(), fileName));
}
try (InputStream inStream = context.getContentResolver().openInputStream(uri)) {
com.google.zxing.Result result = QrCodeHelper.decodeFromStream(inStream);
res.add(new Result(uri, fileName, result, null));
} catch (QrCodeHelper.DecodeError | IOException e) {
e.printStackTrace();
res.add(new Result(uri, fileName, null, e));
}
}
return res;
}
@Override
protected void onPostExecute(Response result) {
super.onPostExecute(result);
_cb.onTaskFinished(result);
protected void onPostExecute(List<Result> results) {
super.onPostExecute(results);
_cb.onTaskFinished(results);
}
public interface Callback {
void onTaskFinished(Response result);
void onTaskFinished(List<Result> results);
}
public static class Response {
private final Result _result;
public static class Result {
private final Uri _uri;
private final String _fileName;
private final com.google.zxing.Result _result;
private final Exception _e;
public Response(Result result, Exception e) {
public Result(Uri uri, String fileName, com.google.zxing.Result result, Exception e) {
_uri = uri;
_fileName = fileName;
_result = result;
_e = e;
}
public Result getResult() {
public Uri getUri() {
return _uri;
}
public String getFileName() {
return _fileName;
}
public com.google.zxing.Result getResult() {
return _result;
}