Migrate ImportService to WorkManager.

Part of #1315.
This commit is contained in:
Dennis Guse
2025-06-29 20:38:18 +02:00
parent c7ca6800f7
commit e879eedbd6
7 changed files with 286 additions and 297 deletions
-5
View File
@@ -395,11 +395,6 @@ limitations under the License.
android:icon="@drawable/ic_logo_color_24dp"
android:label="@string/recording_service" />
<service
android:name=".io.file.importer.ImportService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<!--Necessary for SDK < 33 to (re-)store per app locale information-->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -25,13 +25,12 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.AbstractActivity;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.TrackRecordedActivity;
import de.dennisguse.opentracks.databinding.ImportActivityBinding;
@@ -43,7 +42,7 @@ import de.dennisguse.opentracks.util.IntentUtils;
*
* @author Rodrigo Damazio
*/
public class ImportActivity extends FragmentActivity {
public class ImportActivity extends AbstractActivity {
private static final String TAG = ImportActivity.class.getSimpleName();
@@ -57,14 +56,11 @@ public class ImportActivity extends FragmentActivity {
private ArrayList<Uri> documentUris = new ArrayList<>();
private boolean isDirectory;
private ImportViewModel viewModel;
private ImportViewModel.Summary summary;
private Importer importer;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewBinding = ImportActivityBinding.inflate(getLayoutInflater());
setContentView(viewBinding.getRoot());
final Intent intent = getIntent();
final ClipData intentClipData = intent.getClipData();
@@ -99,16 +95,36 @@ public class ImportActivity extends FragmentActivity {
documentFiles = documentUris.stream().map(it -> DocumentFile.fromSingleUri(this, it)).collect(Collectors.toList());
}
viewModel = new ViewModelProvider(this).get(ImportViewModel.class);
viewModel.getImportData(documentFiles).observe(this, data -> {
summary = data;
setProgress();
});
importer = new Importer(this, documentFiles);
importer.getLiveData()
.observe(this, summary -> {
setProgress(summary);
});
importer.startImport();
//Works for a directory, but we might have received multiple files via SEND_MULTIPLE.
viewBinding.importActivityToolbar.setTitle(getString(R.string.import_progress_message, documentFiles.get(0).getName()));
}
@NonNull
@Override
protected View createRootView() {
viewBinding = ImportActivityBinding.inflate(getLayoutInflater());
return viewBinding.getRoot();
}
@Override
protected void onDestroy() {
super.onDestroy();
viewBinding = null;
}
@Override
public boolean onSupportNavigateUp() {
importer.cancel();
return super.onSupportNavigateUp();
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
@@ -116,7 +132,7 @@ public class ImportActivity extends FragmentActivity {
outState.putBoolean(BUNDLE_IS_DIRECTORY, isDirectory);
}
private void setProgress() {
private void setProgress(Importer.Summary summary) {
int done = summary.getCount();
viewBinding.importProgressDone.setText("" + done);
viewBinding.importProgressTotal.setText("" + summary.getTotalCount());
@@ -130,11 +146,11 @@ public class ImportActivity extends FragmentActivity {
viewBinding.importProgressSummaryErrorsGroup.setVisibility(summary.getErrorCount() > 0 ? View.VISIBLE : View.GONE);
if (summary.isDone()) {
onImportEnded();
onImportEnded(summary);
}
}
private void onImportEnded() {
private void onImportEnded(Importer.Summary summary) {
viewBinding.importProgressAlertMsg.setVisibility(View.VISIBLE);
viewBinding.importProgressAlertIcon.setVisibility(View.VISIBLE);
@@ -146,13 +162,13 @@ public class ImportActivity extends FragmentActivity {
});
if (summary.getErrorCount() > 0) {
toggleUIEndWithErrors();
toggleUIEndWithErrors(summary);
} else {
toggleUIEndOk();
toggleUIEndOk(summary);
}
}
private void toggleUIEndWithErrors() {
private void toggleUIEndWithErrors(Importer.Summary summary) {
viewBinding.importProgressLeftButton.setVisibility(View.VISIBLE);
viewBinding.importProgressLeftButton.setText(getString(R.string.generic_show_errors));
viewBinding.importProgressLeftButton.setOnClickListener((view) -> ErrorListDialog.showDialog(getSupportFragmentManager(), getString(R.string.import_error_list_dialog_title), summary.getFileErrors()));
@@ -160,7 +176,7 @@ public class ImportActivity extends FragmentActivity {
viewBinding.importProgressAlertMsg.setText(getResources().getQuantityString(R.plurals.generic_completed_with_errors, summary.getErrorCount(), summary.getErrorCount()));
}
private void toggleUIEndOk() {
private void toggleUIEndOk(Importer.Summary summary) {
viewBinding.importProgressAlertIcon.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_dialog_success_24dp));
viewBinding.importProgressAlertMsg.setText(getString(R.string.generic_completed));
@@ -1,97 +0,0 @@
package de.dennisguse.opentracks.io.file.importer;
import android.app.job.JobService;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.JobIntentService;
import androidx.documentfile.provider.DocumentFile;
import java.io.IOException;
import java.util.ArrayList;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.FileUtils;
public class ImportService extends JobIntentService {
private static final String TAG = ImportService.class.getSimpleName();
private static final int JOB_ID = 2;
private static final String EXTRA_RECEIVER = "extra_receiver";
private static final String EXTRA_URI = "extra_uri";
private ResultReceiver resultReceiver;
public static void enqueue(Context context, ImportServiceResultReceiver receiver, Uri uri) {
Intent intent = new Intent(context, JobService.class);
intent.putExtra(EXTRA_RECEIVER, receiver);
intent.putExtra(EXTRA_URI, uri);
enqueueWork(context, ImportService.class, JOB_ID, intent);
}
@Override
protected void onHandleWork(@NonNull Intent intent) {
resultReceiver = intent.getParcelableExtra(EXTRA_RECEIVER);
Uri uri = intent.getParcelableExtra(EXTRA_URI);
importFile(DocumentFile.fromSingleUri(this, uri));
}
private void importFile(DocumentFile file) {
ArrayList<Track.Id> trackIds = new ArrayList<>();
String fileExtension = FileUtils.getExtension(file);
try {
Distance maxRecordingDistance = PreferencesUtils.getMaxRecordingDistance();
boolean preventReimport = PreferencesUtils.getPreventReimportTracks();
TrackImporter trackImporter = new TrackImporter(this, new ContentProviderUtils(this), maxRecordingDistance, preventReimport);
if (TrackFileFormat.GPX.getExtension().equals(fileExtension)) {
trackIds.addAll(new XMLImporter(new GPXTrackImporter(this, trackImporter)).importFile(this, file.getUri()));
} else if (TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA.getExtension().equals(fileExtension)) {
trackIds.addAll(new XMLImporter(new KMLTrackImporter(this, trackImporter)).importFile(this, file.getUri()));
} else if (TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getExtension().equals(fileExtension)) {
trackIds.addAll(new KMZTrackImporter(this, trackImporter).importFile(file.getUri()));
} else {
Log.d(TAG, "Unsupported file format.");
sendResult(ImportServiceResultReceiver.RESULT_CODE_ERROR, null, file, getString(R.string.import_unsupported_format));
return;
}
if (!trackIds.isEmpty()) {
sendResult(ImportServiceResultReceiver.RESULT_CODE_IMPORTED, trackIds, file, getString(R.string.import_file_imported, file.getName()));
} else {
sendResult(ImportServiceResultReceiver.RESULT_CODE_ERROR, trackIds, file, getString(R.string.import_unable_to_import_file, file.getName()));
}
} catch (IOException e) {
Log.d(TAG, "Unable to import file", e);
sendResult(ImportServiceResultReceiver.RESULT_CODE_ERROR, null, file, getString(R.string.import_unable_to_import_file, e.getMessage()));
} catch (ImportParserException e) {
Log.d(TAG, "Parser error: " + e.getMessage(), e);
sendResult(ImportServiceResultReceiver.RESULT_CODE_ERROR, null, file, getString(R.string.import_parser_error, e.getMessage()));
} catch (ImportAlreadyExistsException e) {
Log.d(TAG, "Track already exists: " + e.getMessage(), e);
sendResult(ImportServiceResultReceiver.RESULT_CODE_ALREADY_EXISTS, null, file, e.getMessage());
}
}
private void sendResult(int resultCode, ArrayList<Track.Id> trackId, DocumentFile file, String message) {
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(ImportServiceResultReceiver.RESULT_EXTRA_LIST_TRACK_ID, trackId);
bundle.putString(ImportServiceResultReceiver.RESULT_EXTRA_FILENAME, file.getName());
bundle.putString(ImportServiceResultReceiver.RESULT_EXTRA_MESSAGE, message);
resultReceiver.send(resultCode, bundle);
}
}
@@ -1,38 +0,0 @@
package de.dennisguse.opentracks.io.file.importer;
import android.os.Bundle;
import android.os.Handler;
import android.os.ResultReceiver;
import androidx.annotation.NonNull;
/**
* Create a new ResultReceive to receive results.
* Your {@link #onReceiveResult} method will be called from the thread running <var>handler</var> if given, or from an arbitrary thread if null.
*/
public class ImportServiceResultReceiver extends ResultReceiver {
public static final int RESULT_CODE_ERROR = 0;
public static final int RESULT_CODE_IMPORTED = 1;
public static final int RESULT_CODE_ALREADY_EXISTS = 2;
public static final String RESULT_EXTRA_LIST_TRACK_ID = "result_track_id";
public static final String RESULT_EXTRA_FILENAME = "result_extra_filename";
public static final String RESULT_EXTRA_MESSAGE = "result_extra_message";
private final Receiver receiver;
public ImportServiceResultReceiver(Handler handler, @NonNull Receiver receiver) {
super(handler);
this.receiver = receiver;
}
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
receiver.onReceiveResult(resultCode, resultData);
}
public interface Receiver {
void onReceiveResult(int resultCode, Bundle resultData);
}
}
@@ -1,138 +0,0 @@
package de.dennisguse.opentracks.io.file.importer;
import android.app.Application;
import android.os.Bundle;
import android.os.Handler;
import androidx.annotation.NonNull;
import androidx.documentfile.provider.DocumentFile;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.util.FileUtils;
public class ImportViewModel extends AndroidViewModel implements ImportServiceResultReceiver.Receiver {
private static final String TAG = ImportViewModel.class.getSimpleName();
private MutableLiveData<Summary> importData;
private final ImportServiceResultReceiver resultReceiver;
private final Summary summary;
private boolean cancel = false;
private final List<DocumentFile> filesToImport = new ArrayList<>();
public ImportViewModel(@NonNull Application application) {
super(application);
resultReceiver = new ImportServiceResultReceiver(new Handler(), this);
summary = new Summary();
}
LiveData<Summary> getImportData(List<DocumentFile> documentFiles) {
if (importData == null) {
importData = new MutableLiveData<>();
loadData(documentFiles);
}
return importData;
}
void cancel() {
cancel = true;
}
private void loadData(List<DocumentFile> documentFiles) {
List<ArrayList<DocumentFile>> nestedFileList = documentFiles.stream()
.map(FileUtils::getFiles)
.collect(Collectors.toList());
List<DocumentFile> fileList = new ArrayList<>();
nestedFileList.forEach(fileList::addAll);
summary.totalCount = fileList.size();
filesToImport.addAll(fileList);
importNextFile();
}
private void importNextFile() {
if (cancel || filesToImport.isEmpty()) {
return;
}
ImportService.enqueue(getApplication(), resultReceiver, filesToImport.get(0).getUri());
filesToImport.remove(0);
}
@Override
public void onReceiveResult(int resultCode, Bundle resultData) {
if (resultData == null) {
throw new RuntimeException(TAG + ": onReceiveResult resultData NULL");
}
ArrayList<Track.Id> trackIds = resultData.getParcelableArrayList(ImportServiceResultReceiver.RESULT_EXTRA_LIST_TRACK_ID);
String fileName = resultData.getString(ImportServiceResultReceiver.RESULT_EXTRA_FILENAME);
String message = resultData.getString(ImportServiceResultReceiver.RESULT_EXTRA_MESSAGE);
switch (resultCode) {
case ImportServiceResultReceiver.RESULT_CODE_ERROR -> {
summary.errorCount++;
summary.fileErrors.add(getApplication().getString(R.string.import_error_info, fileName, message));
}
case ImportServiceResultReceiver.RESULT_CODE_IMPORTED -> {
summary.importedTrackIds.addAll(trackIds);
summary.successCount++;
}
case ImportServiceResultReceiver.RESULT_CODE_ALREADY_EXISTS -> summary.existsCount++;
default ->
throw new RuntimeException(TAG + ": import service result code invalid: " + resultCode);
}
importData.postValue(summary);
importNextFile();
}
static class Summary {
private int totalCount;
private int successCount;
private int existsCount;
private int errorCount;
private final ArrayList<Track.Id> importedTrackIds = new ArrayList<>();
private final ArrayList<String> fileErrors = new ArrayList<>();
public int getTotalCount() {
return totalCount;
}
public int getSuccessCount() {
return successCount;
}
public int getExistsCount() {
return existsCount;
}
public int getErrorCount() {
return errorCount;
}
public ArrayList<Track.Id> getImportedTrackIds() {
return importedTrackIds;
}
public ArrayList<String> getFileErrors() {
return fileErrors;
}
public int getCount() {
return getSuccessCount() + getExistsCount() + getErrorCount();
}
public boolean isDone() {
return getTotalCount() == getCount();
}
}
}
@@ -0,0 +1,100 @@
package de.dennisguse.opentracks.io.file.importer;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.io.IOException;
import java.util.ArrayList;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.ContentProviderUtils;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.io.file.TrackFileFormat;
import de.dennisguse.opentracks.settings.PreferencesUtils;
import de.dennisguse.opentracks.util.FileUtils;
public class ImportWorker extends Worker {
private static final String TAG = ImportWorker.class.getSimpleName();
static final String URI_KEY = "DIRECTORY_URI_KEY";
static final String RESULT_SUCCESS_LIST_TRACKIDS_KEY = "RESULT_TRACK_IDS";
static final String RESULT_URI_KEY = "RESULT_URI";
static final String RESULT_MESSAGE_KEY = "RESULT_MESSAGE";
static final String RESULT_FAILURE_IS_DUPLICATE = "RESULT_FAILURE_IS_DUPLICATE";
private final Uri uri;
public ImportWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
uri = Uri.parse(getInputData().getString(URI_KEY));
}
@NonNull
@Override
public Result doWork() {
ArrayList<Track.Id> trackIds = new ArrayList<>();
Context context = getApplicationContext();
Distance maxRecordingDistance = PreferencesUtils.getMaxRecordingDistance();
boolean preventReimport = PreferencesUtils.getPreventReimportTracks();
TrackImporter trackImporter = new TrackImporter(context, new ContentProviderUtils(context), maxRecordingDistance, preventReimport);
Data.Builder data = new Data.Builder()
.putString(RESULT_URI_KEY, uri.toString());
String fileExtension = FileUtils.getExtension(uri);
try {
if (TrackFileFormat.GPX.getExtension().equals(fileExtension)) {
trackIds.addAll(new XMLImporter(new GPXTrackImporter(getApplicationContext(), trackImporter)).importFile(context, uri));
} else if (TrackFileFormat.KML_WITH_TRACKDETAIL_AND_SENSORDATA.getExtension().equals(fileExtension)) {
trackIds.addAll(new XMLImporter(new KMLTrackImporter(getApplicationContext(), trackImporter)).importFile(context, uri));
} else if (TrackFileFormat.KMZ_WITH_TRACKDETAIL_AND_SENSORDATA_AND_PICTURES.getExtension().equals(fileExtension)) {
trackIds.addAll(new KMZTrackImporter(context, trackImporter).importFile(uri));
} else {
Log.d(TAG, "Unsupported file format.");
return Result.failure(data
.putString(RESULT_MESSAGE_KEY, context.getString(R.string.import_unsupported_format))
.build());
}
if (!trackIds.isEmpty()) {
return Result.success(data
.putString(RESULT_MESSAGE_KEY, context.getString(R.string.import_file_imported, uri.toString())) //TODO unused?
.putLongArray(RESULT_SUCCESS_LIST_TRACKIDS_KEY, trackIds.stream().mapToLong(Track.Id::id).toArray())
.build());
}
return Result.failure(data
.putString(RESULT_MESSAGE_KEY, context.getString(R.string.import_unable_to_import_file, uri.toString()))
.build());
} catch (IOException e) {
Log.d(TAG, "Unable to import file", e);
return Result.failure(data
.putString(RESULT_MESSAGE_KEY, context.getString(R.string.import_unable_to_import_file, e.getMessage()))
.build());
} catch (ImportParserException e) {
Log.d(TAG, "Parser error: " + e.getMessage(), e);
return Result.failure(data
.putString(RESULT_MESSAGE_KEY, context.getString(R.string.import_parser_error, e.getMessage()))
.build());
} catch (ImportAlreadyExistsException e) {
Log.d(TAG, "Track already exists: " + e.getMessage(), e);
return Result.failure(data
.putString(RESULT_MESSAGE_KEY, e.getMessage()) //TODO This is not used
.putBoolean(RESULT_FAILURE_IS_DUPLICATE, true)
.build());
}
}
}
@@ -0,0 +1,151 @@
package de.dennisguse.opentracks.io.file.importer;
import androidx.documentfile.provider.DocumentFile;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
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.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import de.dennisguse.opentracks.R;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.util.FileUtils;
//TODO Cancel does not abort current import
//TODO Provide some intermediate progress (not only when a file got imported)
public class Importer {
private final MutableLiveData<Summary> liveData = new MutableLiveData<>();
private final Summary summary = new Summary();
private final List<DocumentFile> filesToImport = new ArrayList<>();
private boolean cancel = false;
private final ImportActivity context;
private final WorkManager workManager;
//TODO Add summary callback
Importer(ImportActivity context, List<DocumentFile> documentFiles) {
this.context = context;
this.workManager = WorkManager.getInstance(context);
List<ArrayList<DocumentFile>> nestedFileList = documentFiles.stream()
.map(FileUtils::getFiles)
.collect(Collectors.toList());
List<DocumentFile> fileList = new ArrayList<>();
nestedFileList.forEach(fileList::addAll);
summary.totalCount = fileList.size();
filesToImport.addAll(fileList);
}
LiveData<Summary> getLiveData() {
return liveData;
}
void startImport() {
liveData.postValue(summary);
importNextFile();
}
void cancel() {
cancel = true;
}
private void importNextFile() {
if (cancel || filesToImport.isEmpty()) {
return;
}
final DocumentFile documentFile = filesToImport.get(0);
WorkRequest importRequest = new OneTimeWorkRequest.Builder(ImportWorker.class)
.setInputData(new Data.Builder()
.putString(ImportWorker.URI_KEY, documentFile.getUri().toString())
.build())
.build();
workManager
.getWorkInfoByIdLiveData(importRequest.getId())
.observe(context, workInfo -> {
if (workInfo != null) {
WorkInfo.State state = workInfo.getState();
if (state.isFinished()) {
switch (state) {
case SUCCEEDED -> {
summary.importedTrackIds.addAll(
Arrays.stream(workInfo.getOutputData().getLongArray(ImportWorker.RESULT_SUCCESS_LIST_TRACKIDS_KEY))
.mapToObj(Track.Id::new)
.toList());
summary.successCount++;
}
case FAILED -> {
if (workInfo.getOutputData().getBoolean(ImportWorker.RESULT_FAILURE_IS_DUPLICATE, false)) {
summary.existsCount++;
} else {
// Some error happened
String errorMessage = workInfo.getOutputData().getString(ImportWorker.RESULT_MESSAGE_KEY);
summary.fileErrors.add(context.getString(R.string.import_error_info, documentFile.getName(), errorMessage));
}
}
}
liveData.postValue(summary);
importNextFile();
}
}
});
workManager.enqueue(importRequest);
filesToImport.remove(0);
}
static class Summary {
private int totalCount;
private int successCount;
private int existsCount;
private final ArrayList<Track.Id> importedTrackIds = new ArrayList<>();
private final ArrayList<String> fileErrors = new ArrayList<>();
public int getTotalCount() {
return totalCount;
}
public int getSuccessCount() {
return successCount;
}
public int getExistsCount() {
return existsCount;
}
public int getErrorCount() {
return fileErrors.size();
}
public ArrayList<Track.Id> getImportedTrackIds() {
return importedTrackIds;
}
public ArrayList<String> getFileErrors() {
return fileErrors;
}
public int getCount() {
return getSuccessCount() + getExistsCount() + getErrorCount();
}
public boolean isDone() {
return getTotalCount() == getCount();
}
}
}