Migrate ExportService to WorkManager.

Part of #1315.
This commit is contained in:
Dennis Guse
2025-06-28 17:11:27 +02:00
parent 6f97399aeb
commit 02a77e5ce7
7 changed files with 177 additions and 139 deletions
@@ -27,6 +27,11 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkInfo;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;
import java.util.ArrayList;
import java.util.List;
@@ -41,7 +46,6 @@ import de.dennisguse.opentracks.io.file.ErrorListDialog;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.ExportUtils;
import de.dennisguse.opentracks.util.FileUtils;
/**
@@ -56,7 +60,8 @@ import de.dennisguse.opentracks.util.FileUtils;
* So, for this check actually a different file name might be used than in the ExportService.
* * Saved state as an object instead of individual values.
*/
public class ExportActivity extends AppCompatActivity implements ExportService.ExportServiceResultReceiver.Receiver {
//TODO Make an AbstractActivity
public class ExportActivity extends AppCompatActivity {
private static final String TAG = ExportActivity.class.getSimpleName();
@@ -84,8 +89,6 @@ public class ExportActivity extends AppCompatActivity implements ExportService.E
private TrackFileFormat trackFileFormat;
private Uri directoryUri;
private ExportService.ExportServiceResultReceiver resultReceiver;
private List<String> directoryFiles;
private int trackExportSuccessCount;
@@ -161,8 +164,6 @@ public class ExportActivity extends AppCompatActivity implements ExportService.E
DocumentFile documentFile = DocumentFile.fromTreeUri(this, directoryUri);
String directoryDisplayName = FileUtils.getPath(documentFile);
resultReceiver = new ExportService.ExportServiceResultReceiver(new Handler(), this);
if (savedInstanceState == null) {
autoConflict = ConflictResolutionStrategy.CONFLICT_NONE;
setProgress();
@@ -231,19 +232,45 @@ public class ExportActivity extends AppCompatActivity implements ExportService.E
if (fileExists && conflictResolution == ConflictResolutionStrategy.CONFLICT_NONE) {
conflict(exportTask);
} else if (fileExists && conflictResolution == ConflictResolutionStrategy.CONFLICT_SKIP) {
return;
}
if (fileExists && conflictResolution == ConflictResolutionStrategy.CONFLICT_SKIP) {
trackExportSkippedCount++;
nextExport(exportTask);
} else {
ExportService.enqueue(this, resultReceiver, exportTask, directoryUri);
return;
}
WorkManager workManager = WorkManager.getInstance(this);
WorkRequest exportRequest = new OneTimeWorkRequest.Builder(ExportWorker.class)
.setInputData(new Data.Builder()
.putLongArray(ExportWorker.TRACKIDS_KEY, exportTask.getTrackIds().stream().mapToLong(Track.Id::id).toArray())
.putString(ExportWorker.DIRECTORY_URI_KEY, directoryUri.toString())
.putString(ExportWorker.TRACKFILEFORMAT_KEY, exportTask.getTrackFileFormat().toString())
.putString(ExportWorker.FILENAME_KEY, exportTask.getFilename())
.build())
.build();
workManager
.getWorkInfoByIdLiveData(exportRequest.getId())
.observe(this, workInfo -> {
if (workInfo != null) {
WorkInfo.State state = workInfo.getState();
switch (state) {
case SUCCEEDED -> onExportSuccess(exportTask);
case FAILED ->
onExportError(exportTask, workInfo.getOutputData().getString(ExportWorker.RESULT_EXPORT_ERROR_MESSAGE_KEY));
}
}
});
workManager.enqueue(exportRequest);
}
private void export(ExportTask exportTask) {
export(exportTask, autoConflict);
}
@Deprecated //TODO Check should be done in ExportService
@Deprecated //TODO Check should be done in ExportWorker
private boolean exportFileExists(ExportTask exportTask) {
String filename;
if (exportTask.isMultiExport()) {
@@ -314,7 +341,6 @@ public class ExportActivity extends AppCompatActivity implements ExportService.E
}
}
@Override
public void onExportSuccess(ExportTask exportTask) {
if (exportFileExists(exportTask)) {
trackExportOverwrittenCount++;
@@ -325,7 +351,6 @@ public class ExportActivity extends AppCompatActivity implements ExportService.E
nextExport(exportTask);
}
@Override
public void onExportError(ExportTask exportTask, String errorMessage) {
trackExportErrorCount++;
String name;
@@ -1,103 +0,0 @@
package de.dennisguse.opentracks.io.file.exporter;
import android.app.job.JobService;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.JobIntentService;
import androidx.documentfile.provider.DocumentFile;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.util.ExportUtils;
public class ExportService extends JobIntentService {
private static final String TAG = ExportService.class.getSimpleName();
private static final int JOB_ID = 1;
private static final String EXTRA_RECEIVER = "extra_receiver";
private static final String EXTRA_EXPORT_TASK = "export_task";
private static final String EXTRA_DIRECTORY_URI = "extra_directory_uri";
public static void enqueue(Context context, ExportServiceResultReceiver receiver, ExportTask exportTask, Uri directoryUri) {
Intent intent = new Intent(context, JobService.class);
intent.putExtra(EXTRA_RECEIVER, receiver);
intent.putExtra(EXTRA_EXPORT_TASK, exportTask);
intent.putExtra(EXTRA_DIRECTORY_URI, directoryUri);
enqueueWork(context, ExportService.class, JOB_ID, intent);
}
@Override
protected void onHandleWork(@NonNull Intent intent) {
// Get all data.
ResultReceiver resultReceiver = intent.getParcelableExtra(EXTRA_RECEIVER);
ExportTask exportTask = intent.getParcelableExtra(EXTRA_EXPORT_TASK);
Uri directoryUri = intent.getParcelableExtra(EXTRA_DIRECTORY_URI);
// Prepare resultCode and bundle to send to the receiver.
Bundle bundle = new Bundle();
bundle.putParcelable(ExportServiceResultReceiver.RESULT_EXTRA_EXPORT_TASK, exportTask);
// Build directory file.
DocumentFile directoryFile = DocumentFile.fromTreeUri(this, directoryUri);
if (directoryFile == null || !directoryFile.canWrite()) {
bundle.putString(ExportServiceResultReceiver.EXTRA_EXPORT_ERROR_MESSAGE, getString(R.string.export_cannot_write_to_dir) + ": " + directoryFile);
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_ERROR, bundle);
return;
}
// Export and send result
try {
ExportUtils.exportTrack(this, directoryFile, exportTask);
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_SUCCESS, bundle);
} catch (Exception e) {
Log.e(TAG, "Export failed: " + e);
e.printStackTrace(); //TODO Remove
bundle.putString(ExportServiceResultReceiver.EXTRA_EXPORT_ERROR_MESSAGE, e.getMessage());
resultReceiver.send(ExportServiceResultReceiver.RESULT_CODE_ERROR, bundle);
}
}
public static class ExportServiceResultReceiver extends ResultReceiver {
public static final int RESULT_CODE_SUCCESS = 1;
public static final int RESULT_CODE_ERROR = 0;
public static final String RESULT_EXTRA_EXPORT_TASK = "result_extra_export_task";
public static final String EXTRA_EXPORT_ERROR_MESSAGE = "extra_export_error_message";
private final Receiver receiver;
public ExportServiceResultReceiver(Handler handler, @NonNull Receiver receiver) {
super(handler);
this.receiver = receiver;
}
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
ExportTask exportTask = resultData.getParcelable(RESULT_EXTRA_EXPORT_TASK);
switch (resultCode) {
case RESULT_CODE_SUCCESS -> receiver.onExportSuccess(exportTask);
case RESULT_CODE_ERROR -> receiver.onExportError(exportTask, resultData.getString(EXTRA_EXPORT_ERROR_MESSAGE));
default -> throw new RuntimeException("Unknown resultCode.");
}
}
public interface Receiver {
default void onExportSuccess(ExportTask exportTask) {
}
default void onExportError(ExportTask exportTask, String errorMessage) {
}
}
}
}
@@ -0,0 +1,187 @@
package de.dennisguse.opentracks.io.file.exporter;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.DocumentsContract;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.documentfile.provider.DocumentFile;
import androidx.work.Data;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkInfo;
import androidx.work.WorkManager;
import androidx.work.WorkRequest;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.io.file.TrackFilenameGenerator;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.settings.SettingsActivity;
import de.dennisguse.opentracks.util.IntentUtils;
public class ExportUtils {
private static final String TAG = ExportUtils.class.getSimpleName();
public static void postWorkoutExport(AppCompatActivity context, Track.Id trackId) {
if (PreferencesUtils.shouldInstantExportAfterWorkout()) {
TrackFileFormat trackFileFormat = PreferencesUtils.getExportTrackFileFormat();
DocumentFile directory = IntentUtils.toDocumentFile(context, PreferencesUtils.getDefaultExportDirectoryUri());
WorkManager workManager = WorkManager.getInstance(context);
WorkRequest exportRequest = new OneTimeWorkRequest.Builder(ExportWorker.class)
.setInputData(new Data.Builder()
.putLongArray(ExportWorker.TRACKIDS_KEY, new long[]{trackId.id()})
.putString(ExportWorker.DIRECTORY_URI_KEY, directory.toString())
.putString(ExportWorker.TRACKFILEFORMAT_KEY, trackFileFormat.toString())
.putString(ExportWorker.FILENAME_KEY, null)
.build())
.build();
workManager
.getWorkInfoByIdLiveData(exportRequest.getId())
.observe(context, workInfo -> {
if (workInfo != null) {
if (workInfo.getState() == WorkInfo.State.FAILED) {
Intent intent = new Intent(context, SettingsActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.putExtra(SettingsActivity.EXTRAS_EXPORT_ERROR_MESSAGE, workInfo.getProgress().getString(ExportWorker.RESULT_EXPORT_ERROR_MESSAGE_KEY));
context.startActivity(intent);
}
}
});
workManager.enqueue(exportRequest);
}
}
public static void exportTrack(Context context, DocumentFile directory, @Nullable String filenameForMultiple, TrackFileFormat trackFileFormat, List<Track.Id> trackIds) {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
List<Track> tracks = trackIds.stream()
.map(contentProviderUtils::getTrack)
.toList();
Uri exportDocumentFileUri;
if (tracks.size() == 1) {
exportDocumentFileUri = getExportDocumentFileUri(context, tracks.get(0), trackFileFormat, directory);
} else {
String filename = TrackFilenameGenerator.format(filenameForMultiple, trackFileFormat);
exportDocumentFileUri = getExportDocumentFileUri(context, filename, trackFileFormat, directory);
}
if (exportDocumentFileUri == null) {
throw new RuntimeException("Couldn't create document file for export");
}
TrackExporter trackExporter = trackFileFormat.createTrackExporter(context, contentProviderUtils);
try (OutputStream outputStream = context.getContentResolver().openOutputStream(exportDocumentFileUri, "wt")) {
if (!trackExporter.writeTrack(tracks, outputStream)) {
if (!DocumentFile.fromSingleUri(context, exportDocumentFileUri).delete()) {
throw new RuntimeException("Unable to delete exportDocumentFile");
}
throw new RuntimeException("Unable to export track");
}
} catch (FileNotFoundException e) {
throw new RuntimeException("Unable to open exportDocumentFile " + exportDocumentFileUri, e);
} catch (IOException e) {
throw new RuntimeException("Unable to close exportDocumentFile output stream", e);
}
}
@Deprecated
public static void exportTrack(Context context, DocumentFile directory, ExportTask exportTask) {
ContentProviderUtils contentProviderUtils = new ContentProviderUtils(context);
List<Track> tracks = exportTask.getTrackIds().stream().map(contentProviderUtils::getTrack).collect(Collectors.toList());
Uri exportDocumentFileUri;
if (tracks.size() == 1) {
exportDocumentFileUri = getExportDocumentFileUri(context, tracks.get(0), exportTask.getTrackFileFormat(), directory);
} else {
String filename = TrackFilenameGenerator.format(exportTask.getFilename(), exportTask.getTrackFileFormat());
exportDocumentFileUri = getExportDocumentFileUri(context, filename, exportTask.getTrackFileFormat(), directory);
}
if (exportDocumentFileUri == null) {
throw new RuntimeException("Couldn't create document file for export");
}
TrackExporter trackExporter = exportTask.getTrackFileFormat().createTrackExporter(context, contentProviderUtils);
try (OutputStream outputStream = context.getContentResolver().openOutputStream(exportDocumentFileUri, "wt")) {
if (!trackExporter.writeTrack(tracks, outputStream)) {
if (!DocumentFile.fromSingleUri(context, exportDocumentFileUri).delete()) {
throw new RuntimeException("Unable to delete exportDocumentFile");
}
throw new RuntimeException("Unable to export track");
}
} catch (FileNotFoundException e) {
throw new RuntimeException("Unable to open exportDocumentFile " + exportDocumentFileUri, e);
} catch (IOException e) {
throw new RuntimeException("Unable to close exportDocumentFile output stream", e);
}
}
public static List<String> getAllFiles(Context context, Uri directoryUri) {
List<String> fileNames = new ArrayList<>();
final ContentResolver resolver = context.getContentResolver();
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(directoryUri, DocumentsContract.getDocumentId(directoryUri));
try (Cursor c = resolver.query(childrenUri, new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null)) {
while (c.moveToNext()) {
fileNames.add(c.getString(0));
}
} catch (Exception e) {
Log.w(TAG, "Failed query: " + e);
}
return fileNames;
}
private static Uri getExportDocumentFileUri(Context context, Track track, TrackFileFormat trackFileFormat, DocumentFile directory) {
String exportFileName = PreferencesUtils.getTrackFileformatGenerator().format(track, trackFileFormat);
return getExportDocumentFileUri(context, exportFileName, trackFileFormat, directory);
}
private static Uri getExportDocumentFileUri(Context context, String exportFileName, TrackFileFormat trackFileFormat, DocumentFile directory) {
Uri exportDocumentFileUri = findFile(context, directory.getUri(), exportFileName);
if (exportDocumentFileUri == null) {
final DocumentFile file = directory.createFile(trackFileFormat.getMimeType(), exportFileName);
if (file != null) {
exportDocumentFileUri = file.getUri();
}
}
return exportDocumentFileUri;
}
private static Uri findFile(Context context, Uri directoryUri, String exportFileName) {
final ContentResolver resolver = context.getContentResolver();
final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(directoryUri, DocumentsContract.getDocumentId(directoryUri));
try (Cursor c = resolver.query(childrenUri, new String[]{DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null)) {
while (c.moveToNext()) {
final String documentId = c.getString(0);
final String documentName = c.getString(1);
if (documentName.equals(exportFileName)) {
return DocumentsContract.buildDocumentUriUsingTree(directoryUri, documentId);
}
}
} catch (Exception e) {
Log.w(TAG, "Find file error: failed query: " + e);
}
return null;
}
}
@@ -0,0 +1,70 @@
package de.dennisguse.opentracks.io.file.exporter;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.documentfile.provider.DocumentFile;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.Arrays;
import java.util.List;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
public class ExportWorker extends Worker {
private static final String TAG = ExportWorker.class.getSimpleName();
static final String TRACKIDS_KEY = "TRACKIDS_KEY";
static final String DIRECTORY_URI_KEY = "DIRECTORY_URI_KEY";
static final String TRACKFILEFORMAT_KEY = "TRACKFILEFORMAT_KEY";
static final String FILENAME_KEY = "FILENAME_KEY"; //optional; only used for multiple tracks into one file
static final String RESULT_EXPORT_ERROR_MESSAGE_KEY = "EXPORT_ERROR_MESSAGE";
private final List<Track.Id> trackIds;
private final DocumentFile directoryFile;
private final TrackFileFormat trackFileFormat;
private final String filename;
public ExportWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
trackIds = Arrays.stream(getInputData().getLongArray(TRACKIDS_KEY))
.mapToObj(Track.Id::new)
.toList();
directoryFile = DocumentFile.fromTreeUri(context, Uri.parse(getInputData().getString(DIRECTORY_URI_KEY)));
trackFileFormat = TrackFileFormat.valueOf(getInputData().getString(TRACKFILEFORMAT_KEY));
filename = getInputData().getString(FILENAME_KEY);
}
@NonNull
@Override
public Result doWork() {
if (directoryFile == null || !directoryFile.canWrite()) {
return Result.failure(new Data.Builder().putString(RESULT_EXPORT_ERROR_MESSAGE_KEY, getApplicationContext().getString(R.string.export_cannot_write_to_dir) + ": " + directoryFile).build());
}
try {
//TODO move method to ExportWorker?
ExportUtils.exportTrack(getApplicationContext(), directoryFile, filename, trackFileFormat, trackIds);
return Result.success();
} catch (Exception e) {
Log.e(TAG, "Export failed: " + e);
return Result.failure(new Data.Builder().putString(RESULT_EXPORT_ERROR_MESSAGE_KEY, e.getMessage()).build());
}
}
}