From cd5ef267e8f1e5054beea0d21a3aeebc00b96124 Mon Sep 17 00:00:00 2001 From: Sylvia van Os Date: Tue, 26 Mar 2024 20:43:39 +0100 Subject: [PATCH] WIP --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 3 + .../card_locker/ImportExportActivity.java | 235 +++++++----------- .../protect/card_locker/ImportExportTask.java | 143 ----------- .../protect/card_locker/MainActivity.java | 74 +++++- .../card_locker/NotificationHelper.java | 63 +++++ .../protect/card_locker/PermissionUtils.java | 43 ++++ .../importexport/ImportExportWorker.java | 212 ++++++++++++++++ .../importexport/MultiFormatImporter.java | 2 +- app/src/main/res/values/strings.xml | 3 + .../protect/card_locker/ImportExportTest.java | 66 ----- 11 files changed, 490 insertions(+), 355 deletions(-) delete mode 100644 app/src/main/java/protect/card_locker/ImportExportTask.java create mode 100644 app/src/main/java/protect/card_locker/NotificationHelper.java create mode 100644 app/src/main/java/protect/card_locker/importexport/ImportExportWorker.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8599d5c08b..cc7337e76e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,7 @@ dependencies { implementation("androidx.preference:preference:1.2.1") implementation("com.google.android.material:material:1.12.0") implementation("com.github.yalantis:ucrop:2.2.9") + implementation("androidx.work:work-runtime:2.9.0") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") // Splash Screen diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 53861ab0ee..f297209789 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + + diff --git a/app/src/main/java/protect/card_locker/ImportExportActivity.java b/app/src/main/java/protect/card_locker/ImportExportActivity.java index 6335902dfd..fbbc2cb022 100644 --- a/app/src/main/java/protect/card_locker/ImportExportActivity.java +++ b/app/src/main/java/protect/card_locker/ImportExportActivity.java @@ -1,8 +1,9 @@ package protect.card_locker; import android.content.ActivityNotFoundException; -import android.content.DialogInterface; +import android.content.Context; import android.content.Intent; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.text.InputType; @@ -17,31 +18,31 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; +import androidx.work.Data; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.OutOfQuotaPolicy; +import androidx.work.WorkManager; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.textfield.TextInputLayout; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import protect.card_locker.async.TaskHandler; import protect.card_locker.databinding.ImportExportActivityBinding; import protect.card_locker.importexport.DataFormat; -import protect.card_locker.importexport.ImportExportResult; -import protect.card_locker.importexport.ImportExportResultType; +import protect.card_locker.importexport.ImportExportWorker; public class ImportExportActivity extends CatimaAppCompatActivity { private ImportExportActivityBinding binding; private static final String TAG = "Catima"; - private ImportExportTask importExporter; - private String importAlertTitle; private String importAlertMessage; private DataFormat importDataFormat; @@ -51,7 +52,10 @@ public class ImportExportActivity extends CatimaAppCompatActivity { private ActivityResultLauncher fileOpenLauncher; private ActivityResultLauncher filePickerLauncher; - final private TaskHandler mTasks = new TaskHandler(); + private static final int PERMISSION_REQUEST_EXPORT = 100; + private static final int PERMISSION_REQUEST_IMPORT = 101; + + private OneTimeWorkRequest mRequestedWorkRequest; @Override protected void onCreate(Bundle savedInstanceState) { @@ -80,15 +84,20 @@ protected void onCreate(Bundle savedInstanceState) { Log.e(TAG, "Activity returned NULL uri"); return; } - try { - OutputStream writer = getContentResolver().openOutputStream(uri); - Log.e(TAG, "Starting file export with: " + result.toString()); - startExport(writer, uri, exportPassword.toCharArray(), true); - } catch (IOException e) { - Log.e(TAG, "Failed to export file: " + result.toString(), e); - onExportComplete(new ImportExportResult(ImportExportResultType.GenericFailure, result.toString()), uri); - } + Data exportRequestData = new Data.Builder() + .putString(ImportExportWorker.INPUT_URI, uri.toString()) + .putString(ImportExportWorker.INPUT_ACTION, ImportExportWorker.ACTION_EXPORT) + .putString(ImportExportWorker.INPUT_FORMAT, DataFormat.Catima.name()) + .putString(ImportExportWorker.INPUT_PASSWORD, exportPassword) + .build(); + + mRequestedWorkRequest = new OneTimeWorkRequest.Builder(ImportExportWorker.class) + .setInputData(exportRequestData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + + PermissionUtils.requestPostNotificationsPermission(this, PERMISSION_REQUEST_EXPORT); }); fileOpenLauncher = registerForActivityResult(new ActivityResultContracts.GetContent(), result -> { if (result == null) { @@ -159,15 +168,24 @@ protected void onCreate(Bundle savedInstanceState) { importApplication.setOnClickListener(v -> chooseImportType(true, null)); } + public static OneTimeWorkRequest buildImportRequest(DataFormat dataFormat, Uri uri, char[] password) { + Data importRequestData = new Data.Builder() + .putString(ImportExportWorker.INPUT_URI, uri.toString()) + .putString(ImportExportWorker.INPUT_ACTION, ImportExportWorker.ACTION_IMPORT) + .putString(ImportExportWorker.INPUT_FORMAT, dataFormat.name()) + .putString(ImportExportWorker.INPUT_PASSWORD, Arrays.toString(password)) + .build(); + + return new OneTimeWorkRequest.Builder(ImportExportWorker.class) + .setInputData(importRequestData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + } + private void openFileForImport(Uri uri, char[] password) { - try { - InputStream reader = getContentResolver().openInputStream(uri); - Log.e(TAG, "Starting file import with: " + uri.toString()); - startImport(reader, uri, importDataFormat, password, true); - } catch (IOException e) { - Log.e(TAG, "Failed to import file: " + uri.toString(), e); - onImportComplete(new ImportExportResult(ImportExportResultType.GenericFailure, e.toString()), uri, importDataFormat); - } + mRequestedWorkRequest = buildImportRequest(importDataFormat, uri, password); + + PermissionUtils.requestPostNotificationsPermission(this, PERMISSION_REQUEST_IMPORT); } private void chooseImportType(boolean choosePicker, @@ -232,20 +250,17 @@ private void chooseImportType(boolean choosePicker, new MaterialAlertDialogBuilder(this) .setTitle(importAlertTitle) .setMessage(importAlertMessage) - .setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - try { - if (choosePicker) { - final Intent intentPickAction = new Intent(Intent.ACTION_PICK); - filePickerLauncher.launch(intentPickAction); - } else { - fileOpenLauncher.launch("*/*"); - } - } catch (ActivityNotFoundException e) { - Toast.makeText(getApplicationContext(), R.string.failedOpeningFileManager, Toast.LENGTH_LONG).show(); - Log.e(TAG, "No activity found to handle intent", e); + .setPositiveButton(R.string.ok, (dialog1, which1) -> { + try { + if (choosePicker) { + final Intent intentPickAction = new Intent(Intent.ACTION_PICK); + filePickerLauncher.launch(intentPickAction); + } else { + fileOpenLauncher.launch("*/*"); } + } catch (ActivityNotFoundException e) { + Toast.makeText(getApplicationContext(), R.string.failedOpeningFileManager, Toast.LENGTH_LONG).show(); + Log.e(TAG, "No activity found to handle intent", e); } }) .setNegativeButton(R.string.cancel, null) @@ -254,60 +269,12 @@ public void onClick(DialogInterface dialog, int which) { builder.show(); } - private void startImport(final InputStream target, final Uri targetUri, final DataFormat dataFormat, final char[] password, final boolean closeWhenDone) { - mTasks.flushTaskList(TaskHandler.TYPE.IMPORT, true, false, false); - ImportExportTask.TaskCompleteListener listener = new ImportExportTask.TaskCompleteListener() { - @Override - public void onTaskComplete(ImportExportResult result, DataFormat dataFormat) { - onImportComplete(result, targetUri, dataFormat); - if (closeWhenDone) { - try { - target.close(); - } catch (IOException ioException) { - ioException.printStackTrace(); - } - } - } - }; - - importExporter = new ImportExportTask(ImportExportActivity.this, - dataFormat, target, password, listener); - mTasks.executeTask(TaskHandler.TYPE.IMPORT, importExporter); - } - - private void startExport(final OutputStream target, final Uri targetUri, char[] password, final boolean closeWhenDone) { - mTasks.flushTaskList(TaskHandler.TYPE.EXPORT, true, false, false); - ImportExportTask.TaskCompleteListener listener = new ImportExportTask.TaskCompleteListener() { - @Override - public void onTaskComplete(ImportExportResult result, DataFormat dataFormat) { - onExportComplete(result, targetUri); - if (closeWhenDone) { - try { - target.close(); - } catch (IOException ioException) { - ioException.printStackTrace(); - } - } - } - }; - - importExporter = new ImportExportTask(ImportExportActivity.this, - DataFormat.Catima, target, password, listener); - mTasks.executeTask(TaskHandler.TYPE.EXPORT, importExporter); - } - - @Override - protected void onDestroy() { - mTasks.flushTaskList(TaskHandler.TYPE.IMPORT, true, false, false); - mTasks.flushTaskList(TaskHandler.TYPE.EXPORT, true, false, false); - super.onDestroy(); - } - @Override public boolean onOptionsItemSelected(MenuItem item) { int id = item.getItemId(); if (id == android.R.id.home) { + setResult(RESULT_CANCELED); finish(); return true; } @@ -315,19 +282,19 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } - private void retryWithPassword(DataFormat dataFormat, Uri uri) { - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); + public static void retryWithPassword(Context context, DataFormat dataFormat, Uri uri) { + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context); builder.setTitle(R.string.passwordRequired); - FrameLayout container = new FrameLayout(ImportExportActivity.this); + FrameLayout container = new FrameLayout(context); - final TextInputLayout textInputLayout = new TextInputLayout(ImportExportActivity.this); + final TextInputLayout textInputLayout = new TextInputLayout(context); textInputLayout.setEndIconMode(TextInputLayout.END_ICON_PASSWORD_TOGGLE); LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.setMargins(50, 10, 50, 0); textInputLayout.setLayoutParams(params); - final EditText input = new EditText(ImportExportActivity.this); + final EditText input = new EditText(context); input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); input.setHint(R.string.exportPasswordHint); @@ -336,75 +303,55 @@ private void retryWithPassword(DataFormat dataFormat, Uri uri) { builder.setView(container); builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> { - openFileForImport(uri, input.getText().toString().toCharArray()); + OneTimeWorkRequest importRequest = ImportExportActivity.buildImportRequest(dataFormat, uri, input.getText().toString().toCharArray()); + WorkManager.getInstance(context).enqueueUniqueWork(ImportExportWorker.ACTION_IMPORT, ExistingWorkPolicy.REPLACE, importRequest); }); builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> dialogInterface.cancel()); builder.show(); } - private String buildResultDialogMessage(ImportExportResult result, boolean isImport) { - int messageId; - - if (result.resultType() == ImportExportResultType.Success) { - messageId = isImport ? R.string.importSuccessful : R.string.exportSuccessful; - } else { - messageId = isImport ? R.string.importFailed : R.string.exportFailed; - } - - StringBuilder messageBuilder = new StringBuilder(getResources().getString(messageId)); - if (result.developerDetails() != null) { - messageBuilder.append("\n\n"); - messageBuilder.append(getResources().getString(R.string.include_if_asking_support)); - messageBuilder.append("\n\n"); - messageBuilder.append(result.developerDetails()); - } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); - return messageBuilder.toString(); + onMockedRequestPermissionsResult(requestCode, permissions, grantResults); } - private void onImportComplete(ImportExportResult result, Uri path, DataFormat dataFormat) { - ImportExportResultType resultType = result.resultType(); + public void onMockedRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; + Integer failureReason = null; - if (resultType == ImportExportResultType.BadPassword) { - retryWithPassword(dataFormat, path); - return; - } + if (requestCode == PERMISSION_REQUEST_EXPORT) { + if (granted) { + WorkManager.getInstance(this).enqueueUniqueWork(ImportExportWorker.ACTION_EXPORT, ExistingWorkPolicy.REPLACE, mRequestedWorkRequest); - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); - builder.setTitle(resultType == ImportExportResultType.Success ? R.string.importSuccessfulTitle : R.string.importFailedTitle); - builder.setMessage(buildResultDialogMessage(result, true)); - builder.setNeutralButton(R.string.ok, (dialog, which) -> dialog.dismiss()); + Toast.makeText(this, R.string.exportStartedCheckNotifications, Toast.LENGTH_LONG).show(); - builder.create().show(); - } - - private void onExportComplete(ImportExportResult result, final Uri path) { - ImportExportResultType resultType = result.resultType(); + // Import/export started + setResult(RESULT_OK); + finish(); - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); - builder.setTitle(resultType == ImportExportResultType.Success ? R.string.exportSuccessfulTitle : R.string.exportFailedTitle); - builder.setMessage(buildResultDialogMessage(result, false)); - builder.setNeutralButton(R.string.ok, (dialog, which) -> dialog.dismiss()); - - if (resultType == ImportExportResultType.Success) { - final CharSequence sendLabel = ImportExportActivity.this.getResources().getText(R.string.sendLabel); + return; + } - builder.setPositiveButton(sendLabel, (dialog, which) -> { - Intent sendIntent = new Intent(Intent.ACTION_SEND); - sendIntent.putExtra(Intent.EXTRA_STREAM, path); - sendIntent.setType("text/csv"); + failureReason = R.string.postNotificationsPermissionRequired; + } else if (requestCode == PERMISSION_REQUEST_IMPORT) { + if (granted) { + WorkManager.getInstance(this).enqueueUniqueWork(ImportExportWorker.ACTION_IMPORT, ExistingWorkPolicy.REPLACE, mRequestedWorkRequest); - // set flag to give temporary permission to external app to use the FileProvider - sendIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Import/export started + setResult(RESULT_OK); + finish(); - ImportExportActivity.this.startActivity(Intent.createChooser(sendIntent, - sendLabel)); + return; + } - dialog.dismiss(); - }); + failureReason = R.string.postNotificationsPermissionRequired; } - builder.create().show(); + if (failureReason != null) { + Toast.makeText(this, failureReason, Toast.LENGTH_LONG).show(); + } } } \ No newline at end of file diff --git a/app/src/main/java/protect/card_locker/ImportExportTask.java b/app/src/main/java/protect/card_locker/ImportExportTask.java deleted file mode 100644 index 8284edff72..0000000000 --- a/app/src/main/java/protect/card_locker/ImportExportTask.java +++ /dev/null @@ -1,143 +0,0 @@ -package protect.card_locker; - -import android.app.Activity; -import android.app.ProgressDialog; -import android.content.Context; -import android.content.DialogInterface; -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.nio.charset.StandardCharsets; - -import protect.card_locker.async.CompatCallable; -import protect.card_locker.importexport.DataFormat; -import protect.card_locker.importexport.ImportExportResult; -import protect.card_locker.importexport.ImportExportResultType; -import protect.card_locker.importexport.MultiFormatExporter; -import protect.card_locker.importexport.MultiFormatImporter; - -public class ImportExportTask implements CompatCallable { - private static final String TAG = "Catima"; - - private Activity activity; - private boolean doImport; - private DataFormat format; - private OutputStream outputStream; - private InputStream inputStream; - private char[] password; - private TaskCompleteListener listener; - - private ProgressDialog progress; - - /** - * Constructor which will setup a task for exporting to the given file - */ - ImportExportTask(Activity activity, DataFormat format, OutputStream output, char[] password, - TaskCompleteListener listener) { - super(); - this.activity = activity; - this.doImport = false; - this.format = format; - this.outputStream = output; - this.password = password; - this.listener = listener; - } - - /** - * Constructor which will setup a task for importing from the given InputStream. - */ - ImportExportTask(Activity activity, DataFormat format, InputStream input, char[] password, - TaskCompleteListener listener) { - super(); - this.activity = activity; - this.doImport = true; - this.format = format; - this.inputStream = input; - this.password = password; - this.listener = listener; - } - - private ImportExportResult performImport(Context context, InputStream stream, SQLiteDatabase database, char[] password) { - ImportExportResult importResult = MultiFormatImporter.importData(context, database, stream, format, password); - - Log.i(TAG, "Import result: " + importResult); - - return importResult; - } - - private ImportExportResult performExport(Context context, OutputStream stream, SQLiteDatabase database, char[] password) { - ImportExportResult result; - - try { - OutputStreamWriter writer = new OutputStreamWriter(stream, StandardCharsets.UTF_8); - result = MultiFormatExporter.exportData(context, database, stream, format, password); - writer.close(); - } catch (IOException e) { - result = new ImportExportResult(ImportExportResultType.GenericFailure, e.toString()); - Log.e(TAG, "Unable to export file", e); - } - - Log.i(TAG, "Export result: " + result); - - return result; - } - - public void onPreExecute() { - progress = new ProgressDialog(activity); - progress.setTitle(doImport ? R.string.importing : R.string.exporting); - - progress.setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialog) { - ImportExportTask.this.stop(); - } - }); - - progress.show(); - } - - protected ImportExportResult doInBackground(Void... nothing) { - final SQLiteDatabase database = new DBHelper(activity).getWritableDatabase(); - ImportExportResult result; - - if (doImport) { - result = performImport(activity.getApplicationContext(), inputStream, database, password); - } else { - result = performExport(activity.getApplicationContext(), outputStream, database, password); - } - - database.close(); - - return result; - } - - public void onPostExecute(Object castResult) { - listener.onTaskComplete((ImportExportResult) castResult, format); - - progress.dismiss(); - Log.i(TAG, (doImport ? "Import" : "Export") + " Complete"); - } - - protected void onCancelled() { - progress.dismiss(); - Log.i(TAG, (doImport ? "Import" : "Export") + " Cancelled"); - } - - protected void stop() { - // Whelp - } - - @Override - public ImportExportResult call() { - return doInBackground(); - } - - interface TaskCompleteListener { - void onTaskComplete(ImportExportResult result, DataFormat format); - } - -} diff --git a/app/src/main/java/protect/card_locker/MainActivity.java b/app/src/main/java/protect/card_locker/MainActivity.java index 80c72f288c..8701570583 100644 --- a/app/src/main/java/protect/card_locker/MainActivity.java +++ b/app/src/main/java/protect/card_locker/MainActivity.java @@ -26,9 +26,13 @@ import androidx.appcompat.widget.SearchView; import androidx.core.splashscreen.SplashScreen; import androidx.recyclerview.widget.RecyclerView; +import androidx.work.Data; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayout; import java.io.UnsupportedEncodingException; @@ -36,11 +40,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicInteger; import protect.card_locker.databinding.ContentMainBinding; import protect.card_locker.databinding.MainActivityBinding; import protect.card_locker.databinding.SortingOptionBinding; +import protect.card_locker.importexport.DataFormat; +import protect.card_locker.importexport.ImportExportWorker; import protect.card_locker.preferences.SettingsActivity; public class MainActivity extends CatimaAppCompatActivity implements LoyaltyCardCursorAdapter.CardAdapterListener { @@ -71,6 +79,7 @@ public class MainActivity extends CatimaAppCompatActivity implements LoyaltyCard private ActivityResultLauncher mBarcodeScannerLauncher; private ActivityResultLauncher mSettingsLauncher; + private ActivityResultLauncher mImportExportLauncher; private ActionMode.Callback mCurrentActionModeCallback = new ActionMode.Callback() { @Override @@ -304,6 +313,69 @@ public void onClick(DialogInterface dialog, int whichButton) { } }); + mImportExportLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + // User didn't ask for import or export + if (result.getResultCode() != RESULT_OK) { + return; + } + + // Watch for active imports/exports + new Thread(() -> { + WorkManager workManager = WorkManager.getInstance(MainActivity.this); + + Snackbar importRunning = Snackbar.make(binding.getRoot(), R.string.importing, Snackbar.LENGTH_INDEFINITE); + + while (true) { + try { + List activeImports = workManager.getWorkInfosForUniqueWork(ImportExportWorker.ACTION_IMPORT).get(); + + // We should only have one import running at a time, so it should be safe to always grab the latest + WorkInfo activeImport = activeImports.get(activeImports.size() - 1); + WorkInfo.State importState = activeImport.getState(); + + if (importState == WorkInfo.State.RUNNING || importState == WorkInfo.State.ENQUEUED || importState == WorkInfo.State.BLOCKED) { + importRunning.show(); + } else if (importState == WorkInfo.State.SUCCEEDED) { + importRunning.dismiss(); + runOnUiThread(() -> { + Toast.makeText(getApplicationContext(), getString(R.string.importSuccessful), Toast.LENGTH_LONG).show(); + updateLoyaltyCardList(true); + }); + + break; + } else { + importRunning.dismiss(); + + Data outputData = activeImport.getOutputData(); + + // FIXME: This dialog will asynchronously be accepted or declined and we don't know the status of it so we can't show the import state + // We want to get back into this function + // A cheap fix would be to keep looping but if the user dismissed the dialog that could mean we're looping forever... + if (Objects.equals(outputData.getString(ImportExportWorker.OUTPUT_ERROR_REASON), ImportExportWorker.ERROR_PASSWORD_REQUIRED)) { + runOnUiThread(() -> ImportExportActivity.retryWithPassword( + MainActivity.this, + DataFormat.valueOf(outputData.getString(ImportExportWorker.INPUT_FORMAT)), + Uri.parse(outputData.getString(ImportExportWorker.INPUT_URI)) + )); + } else { + runOnUiThread(() -> { + Toast.makeText(getApplicationContext(), getString(R.string.importFailed), Toast.LENGTH_LONG).show(); + Toast.makeText(getApplicationContext(), activeImport.getOutputData().getString(ImportExportWorker.OUTPUT_ERROR_REASON), Toast.LENGTH_LONG).show(); + Toast.makeText(getApplicationContext(), activeImport.getOutputData().getString(ImportExportWorker.OUTPUT_ERROR_DETAILS), Toast.LENGTH_LONG).show(); + }); + } + + break; + } + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + }).start(); + }); + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { @@ -641,7 +713,7 @@ public boolean onOptionsItemSelected(MenuItem inputItem) { if (id == R.id.action_import_export) { Intent i = new Intent(getApplicationContext(), ImportExportActivity.class); - startActivity(i); + mImportExportLauncher.launch(i); return true; } diff --git a/app/src/main/java/protect/card_locker/NotificationHelper.java b/app/src/main/java/protect/card_locker/NotificationHelper.java new file mode 100644 index 0000000000..fe957d73d0 --- /dev/null +++ b/app/src/main/java/protect/card_locker/NotificationHelper.java @@ -0,0 +1,63 @@ +package protect.card_locker; + +import static android.content.Context.NOTIFICATION_SERVICE; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class NotificationHelper { + + // Do not change these IDs! + public static final String CHANNEL_IMPORT = "import"; + + public static final String CHANNEL_EXPORT = "export"; + + public static final int IMPORT_ID = 100; + public static final int IMPORT_PROGRESS_ID = 101; + public static final int EXPORT_ID = 103; + public static final int EXPORT_PROGRESS_ID = 104; + + + public static Notification.Builder createNotificationBuilder(@NonNull Context context, @NonNull String channel, @NonNull int icon, @NonNull String title, @Nullable String message) { + Notification.Builder notificationBuilder = new Notification.Builder(context) + .setSmallIcon(icon) + .setTicker(title) + .setContentTitle(title); + + if (message != null) { + notificationBuilder.setContentText(message); + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); + NotificationChannel notificationChannel = new NotificationChannel(channel, getChannelName(channel), NotificationManager.IMPORTANCE_DEFAULT); + notificationManager.createNotificationChannel(notificationChannel); + + notificationBuilder.setChannelId(channel); + } + + return notificationBuilder; + } + + public static void sendNotification(@NonNull Context context, @NonNull int notificationId, @NonNull Notification notification) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE); + + notificationManager.notify(notificationId, notification); + } + + private static String getChannelName(@NonNull String channel) { + switch(channel) { + case CHANNEL_IMPORT: + return "Import"; + case CHANNEL_EXPORT: + return "Export"; + default: + throw new IllegalArgumentException("Unknown notification channel"); + } + } +} diff --git a/app/src/main/java/protect/card_locker/PermissionUtils.java b/app/src/main/java/protect/card_locker/PermissionUtils.java index 71cf75f523..057de6d1eb 100644 --- a/app/src/main/java/protect/card_locker/PermissionUtils.java +++ b/app/src/main/java/protect/card_locker/PermissionUtils.java @@ -42,6 +42,16 @@ public static boolean needsCameraPermission(Activity activity) { return ContextCompat.checkSelfPermission(activity, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED; } + /** + * Check if post notifications permission is needed + * + * @param activity + * @return + */ + public static boolean needsPostNotificationsPermission(Activity activity) { + return ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED; + } + /** * Call onRequestPermissionsResult after storage read permission was granted. * Mocks a successful grant if a grant is not necessary. @@ -91,4 +101,37 @@ public static void requestCameraPermission(CatimaAppCompatActivity activity, int activity.onMockedRequestPermissionsResult(requestCode, permissions, mockedResults); } } + + + /** + * Call onRequestPermissionsResult after notification permission was granted. + * Mocks a successful grant if a grant is not necessary. + * + * @param activity + * @param requestCode + */ + public static void requestPostNotificationsPermission(CatimaAppCompatActivity activity, int requestCode) { + int[] mockedResults = new int[]{ PackageManager.PERMISSION_GRANTED }; + + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) { + String[] permissions = new String[0]; + activity.onMockedRequestPermissionsResult(requestCode, permissions, mockedResults); + return; + } + + String[] permissions = new String[]{ Manifest.permission.POST_NOTIFICATIONS}; + + if (needsPostNotificationsPermission(activity)) { + ActivityCompat.requestPermissions(activity, permissions, requestCode); + } else { + // FIXME: This points to onMockedRequestPermissionResult instead of to + // onRequestPermissionResult because onRequestPermissionResult was only introduced in + // Android 6.0 (SDK 23) and we and to support Android 5.0 (SDK 21) too. + // + // When minSdk becomes 23, this should point to onRequestPermissionResult directly and + // the activity input variable should be changed from CatimaAppCompatActivity to + // Activity. + activity.onMockedRequestPermissionsResult(requestCode, permissions, mockedResults); + } + } } \ No newline at end of file diff --git a/app/src/main/java/protect/card_locker/importexport/ImportExportWorker.java b/app/src/main/java/protect/card_locker/importexport/ImportExportWorker.java new file mode 100644 index 0000000000..f56dad408d --- /dev/null +++ b/app/src/main/java/protect/card_locker/importexport/ImportExportWorker.java @@ -0,0 +1,212 @@ +package protect.card_locker.importexport; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.ForegroundInfo; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; + +import protect.card_locker.DBHelper; +import protect.card_locker.NotificationHelper; +import protect.card_locker.R; + +public class ImportExportWorker extends Worker { + private final String TAG = "Catima"; + + public static final String INPUT_URI = "uri"; + public static final String INPUT_ACTION = "action"; + public static final String INPUT_FORMAT = "format"; + public static final String INPUT_PASSWORD = "password"; + + public static final String ACTION_IMPORT = "import"; + public static final String ACTION_EXPORT = "export"; + + public static final String OUTPUT_ERROR_REASON = "errorReason"; + public static final String ERROR_GENERIC = "errorTypeGeneric"; + public static final String ERROR_PASSWORD_REQUIRED = "errorTypePasswordRequired"; + public static final String OUTPUT_ERROR_DETAILS = "errorDetails"; + + public ImportExportWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + Log.e("CATIMA", "Started import/export worker"); + + Context context = getApplicationContext(); + + Data inputData = getInputData(); + + String uriString = inputData.getString(INPUT_URI); + String action = inputData.getString(INPUT_ACTION); + String format = inputData.getString(INPUT_FORMAT); + String password = inputData.getString(INPUT_PASSWORD); + + if (action.equals(ACTION_IMPORT)) { + Log.e("CATIMA", "Import requested"); + + setForegroundAsync(createForegroundInfo(NotificationHelper.CHANNEL_IMPORT, NotificationHelper.IMPORT_PROGRESS_ID, R.string.importing)); + + ImportExportResult result; + + InputStream stream; + try { + stream = context.getContentResolver().openInputStream(Uri.parse(uriString)); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + + final SQLiteDatabase database = new DBHelper(context).getWritableDatabase(); + + try { + InputStreamReader writer = new InputStreamReader(stream, StandardCharsets.UTF_8); + result = MultiFormatImporter.importData(context, database, stream, DataFormat.valueOf(format), password.toCharArray()); + writer.close(); + } catch (IOException e) { + Log.e(TAG, "Unable to import file", e); + NotificationHelper.sendNotification(context, NotificationHelper.IMPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_IMPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.importFailedTitle), e.getLocalizedMessage()).build()); + + Data failureData = new Data.Builder() + .putString(OUTPUT_ERROR_REASON, ERROR_GENERIC) + .putString(OUTPUT_ERROR_DETAILS, e.getLocalizedMessage()) + .putString(INPUT_URI, uriString) + .putString(INPUT_ACTION, action) + .putString(INPUT_FORMAT, format) + .putString(INPUT_PASSWORD, password) + .build(); + + return Result.failure(failureData); + } + + Log.i(TAG, "Import result: " + result); + + if (result.resultType() == ImportExportResultType.Success) { + NotificationHelper.sendNotification(context, NotificationHelper.IMPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_IMPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.importSuccessfulTitle), context.getString(R.string.importSuccessful)).build()); + + return Result.success(); + } else if (result.resultType() == ImportExportResultType.BadPassword) { + Log.e(TAG, "Needs password, unhandled for now"); + NotificationHelper.sendNotification(context, NotificationHelper.IMPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_IMPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.importing), context.getString(R.string.passwordRequired)).build()); + + Data failureData = new Data.Builder() + .putString(OUTPUT_ERROR_REASON, ERROR_PASSWORD_REQUIRED) + .putString(OUTPUT_ERROR_DETAILS, result.developerDetails()) + .putString(INPUT_URI, uriString) + .putString(INPUT_ACTION, action) + .putString(INPUT_FORMAT, format) + .putString(INPUT_PASSWORD, password) + .build(); + + return Result.failure(failureData); + } else { + NotificationHelper.sendNotification(context, NotificationHelper.IMPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_IMPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.importFailedTitle), context.getString(R.string.importFailed)).build()); + + Data failureData = new Data.Builder() + .putString(OUTPUT_ERROR_REASON, ERROR_GENERIC) + .putString(OUTPUT_ERROR_DETAILS, result.developerDetails()) + .putString(INPUT_URI, uriString) + .putString(INPUT_ACTION, action) + .putString(INPUT_FORMAT, format) + .putString(INPUT_PASSWORD, password) + .build(); + + return Result.failure(failureData); + } + } else { + Log.e("CATIMA", "Export requested"); + + setForegroundAsync(createForegroundInfo(NotificationHelper.CHANNEL_EXPORT, NotificationHelper.EXPORT_PROGRESS_ID, R.string.exporting)); + + ImportExportResult result; + + OutputStream stream; + try { + stream = context.getContentResolver().openOutputStream(Uri.parse(uriString)); + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + + final SQLiteDatabase database = new DBHelper(context).getReadableDatabase(); + + try { + OutputStreamWriter writer = new OutputStreamWriter(stream, StandardCharsets.UTF_8); + result = MultiFormatExporter.exportData(context, database, stream, DataFormat.valueOf(format), password.toCharArray()); + writer.close(); + } catch (IOException e) { + Log.e(TAG, "Unable to export file", e); + NotificationHelper.sendNotification(context, NotificationHelper.EXPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_EXPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.exportFailedTitle), e.getLocalizedMessage()).build()); + + Data failureData = new Data.Builder() + .putString(OUTPUT_ERROR_REASON, ERROR_GENERIC) + .putString(OUTPUT_ERROR_DETAILS, e.getLocalizedMessage()) + .putString(INPUT_URI, uriString) + .putString(INPUT_ACTION, action) + .putString(INPUT_FORMAT, format) + .putString(INPUT_PASSWORD, password) + .build(); + + return Result.failure(failureData); + } + + Log.i(TAG, "Export result: " + result); + + if (result.resultType() == ImportExportResultType.Success) { + NotificationHelper.sendNotification(context, NotificationHelper.EXPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_EXPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.exportSuccessfulTitle), context.getString(R.string.exportSuccessful)).build()); + + return Result.success(); + } else { + NotificationHelper.sendNotification(context, NotificationHelper.EXPORT_ID, NotificationHelper.createNotificationBuilder(context, NotificationHelper.CHANNEL_EXPORT, R.drawable.ic_import_export_white_24dp, context.getString(R.string.exportFailedTitle), context.getString(R.string.exportFailed)).build()); + + Data failureData = new Data.Builder() + .putString(OUTPUT_ERROR_REASON, ERROR_GENERIC) + .putString(OUTPUT_ERROR_DETAILS, result.developerDetails()) + .putString(INPUT_URI, uriString) + .putString(INPUT_ACTION, action) + .putString(INPUT_FORMAT, format) + .putString(INPUT_PASSWORD, password) + .build(); + + return Result.failure(failureData); + } + } + } + + @NonNull + private ForegroundInfo createForegroundInfo(@NonNull String channel, int notificationId, int title) { + Context context = getApplicationContext(); + + String cancel = context.getString(R.string.cancel); + // This PendingIntent can be used to cancel the worker + PendingIntent intent = WorkManager.getInstance(context) + .createCancelPendingIntent(getId()); + + Notification.Builder notificationBuilder = NotificationHelper.createNotificationBuilder(context, channel, R.drawable.ic_import_export_white_24dp, context.getString(title), null); + + Notification notification = notificationBuilder + .setOngoing(true) + // Add the cancel action to the notification which can + // be used to cancel the worker + .addAction(android.R.drawable.ic_delete, cancel, intent) + .build(); + + return new ForegroundInfo(notificationId, notification); + } +} diff --git a/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java b/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java index 99eb3ef469..feca2d8864 100644 --- a/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java +++ b/app/src/main/java/protect/card_locker/importexport/MultiFormatImporter.java @@ -45,7 +45,7 @@ public static ImportExportResult importData(Context context, SQLiteDatabase data break; } - String error = null; + String error; if (importer != null) { File inputFile; try { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c6473e256..6d4225d28a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,4 +346,7 @@ Could not find a supported file manager Which of the found barcodes do you want to use? Page %d + Export started, check your notifications for the result + Import started, check your notifications for the result + Permission to show notifications needed for this action… diff --git a/app/src/test/java/protect/card_locker/ImportExportTest.java b/app/src/test/java/protect/card_locker/ImportExportTest.java index 34d8c6d3d1..7223229957 100644 --- a/app/src/test/java/protect/card_locker/ImportExportTest.java +++ b/app/src/test/java/protect/card_locker/ImportExportTest.java @@ -574,72 +574,6 @@ public void corruptedImportNothingSaved() { } } - class TestTaskCompleteListener implements ImportExportTask.TaskCompleteListener { - ImportExportResult result; - - public void onTaskComplete(ImportExportResult result, DataFormat dataFormat) { - this.result = result; - } - } - - @Test - @LooperMode(LooperMode.Mode.PAUSED) - public void useImportExportTask() throws FileNotFoundException { - final int NUM_CARDS = 10; - - final File sdcardDir = Environment.getExternalStorageDirectory(); - final File exportFile = new File(sdcardDir, "Catima.csv"); - - TestHelpers.addLoyaltyCards(mDatabase, NUM_CARDS); - - TestTaskCompleteListener listener = new TestTaskCompleteListener(); - - // Export to the file - final String password = "123456789"; - FileOutputStream fileOutputStream = new FileOutputStream(exportFile); - ImportExportTask task = new ImportExportTask(activity, DataFormat.Catima, fileOutputStream, password.toCharArray(), listener); - TaskHandler mTasks = new TaskHandler(); - mTasks.executeTask(TaskHandler.TYPE.EXPORT, task); - - // Actually run the task to completion - mTasks.flushTaskList(TaskHandler.TYPE.EXPORT, false, false, true); - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(5000)); - ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); - - - // Check that the listener was executed - assertNotNull(listener.result); - assertEquals(ImportExportResultType.Success, listener.result.resultType()); - - TestHelpers.getEmptyDb(activity); - - // Import everything back from the default location - - listener = new TestTaskCompleteListener(); - - FileInputStream fileStream = new FileInputStream(exportFile); - - task = new ImportExportTask(activity, DataFormat.Catima, fileStream, password.toCharArray(), listener); - mTasks.executeTask(TaskHandler.TYPE.IMPORT, task); - - // Actually run the task to completion - // I am CONVINCED there must be a better way than to wait on this Queue with a flush. - mTasks.flushTaskList(TaskHandler.TYPE.IMPORT, false, false, true); - shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(5000)); - ShadowLooper.runUiThreadTasksIncludingDelayedTasks(); - - // Check that the listener was executed - assertNotNull(listener.result); - assertEquals(ImportExportResultType.Success, listener.result.resultType()); - - assertEquals(NUM_CARDS, DBHelper.getLoyaltyCardCount(mDatabase)); - - checkLoyaltyCards(); - - // Clear the database for the next format under test - TestHelpers.getEmptyDb(activity); - } - @Test public void importWithoutColorsV1() { InputStream inputStream = getClass().getResourceAsStream("catima_v1_no_colors.csv");