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");