diff --git a/package.json b/package.json index ace65222..afc165b5 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ ], "license": "Apache-2.0", "engines": { - "node": "14.x", - "npm": "6.x", + "node": ">=14.x", + "npm": ">=6.x", "cordova": ">=8.0.0" }, "standard": { diff --git a/plugin.xml b/plugin.xml index a2b5418b..ce78f338 100644 --- a/plugin.xml +++ b/plugin.xml @@ -13,9 +13,9 @@ - + - + diff --git a/src/android/FileTransferBackground.java b/src/android/FileTransferBackground.java index bdd82da4..830e63be 100644 --- a/src/android/FileTransferBackground.java +++ b/src/android/FileTransferBackground.java @@ -17,6 +17,7 @@ import androidx.work.OutOfQuotaPolicy; import androidx.work.WorkInfo; import androidx.work.WorkManager; +import androidx.work.WorkQuery; import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaPlugin; @@ -46,8 +47,6 @@ public class FileTransferBackground extends CordovaPlugin { private Data httpClientBaseConfig = Data.EMPTY; - public static boolean workerIsStarted; - private ScheduledExecutorService executorService = null; private int ccUpload; @@ -179,19 +178,57 @@ private void initManager(String options, final CallbackContext callbackContext) final AckDatabase ackDatabase = AckDatabase.getInstance(cordova.getContext()); - // Resend pending ACK at startup (and warmup database) - final List uploadEvents = ackDatabase - .uploadEventDao() - .getAll(); - - int ackDelay = 0; - for (UploadEvent ack : uploadEvents) { - executorService.schedule(() -> { - handleAck(ack.getOutputData()); - }, ackDelay, TimeUnit.MILLISECONDS); - ackDelay += 200; + // Delete any worker + WorkManager.getInstance(cordova.getContext()).cancelAllWork(); + WorkManager.getInstance(cordova.getContext()).pruneWork(); + + ackDatabase.runInTransaction(new Runnable() { + @Override + public void run() { + ackDatabase.pendingUploadDao().resetUploadingAsPending(); + } + }); + + // Start workers if there is pending uploads and no worker is already running + try { + List workInfoStates = new ArrayList<>(); + workInfoStates.add(WorkInfo.State.BLOCKED); + workInfoStates.add(WorkInfo.State.CANCELLED); + workInfoStates.add(WorkInfo.State.ENQUEUED); + workInfoStates.add(WorkInfo.State.RUNNING); + List workers = WorkManager.getInstance(cordova.getContext()).getWorkInfos(WorkQuery.Builder.fromStates(workInfoStates).build()).get(); + + ackDatabase.runInTransaction(new Runnable() { + @Override + public void run() { + if (ackDatabase.pendingUploadDao().getPendingUploadsCount() > 0 && workers.size() == 0) { + startWorkers(); + } + } + }); + } catch (ExecutionException | InterruptedException e) { + e.printStackTrace(); + logMessage("eventLabel='Uploader could not start worker:'" + e.getMessage() + "'"); } + // Resend pending ACK at startup (and warmup database) + + ackDatabase.runInTransaction(new Runnable() { + @Override + public void run() { + List uploadEvents = ackDatabase + .uploadEventDao() + .getAll(); + int ackDelay = 0; + for (UploadEvent ack : uploadEvents) { + executorService.schedule(() -> { + handleAck(ack.getOutputData()); + }, ackDelay, TimeUnit.MILLISECONDS); + ackDelay += 200; + } + } + }); + // Can't use observeForever anywhere else than the main thread cordova.getActivity().runOnUiThread(() -> { // Listen for upload progress @@ -202,12 +239,17 @@ private void initManager(String options, final CallbackContext callbackContext) for (WorkInfo info : tasks) { // No db in main thread executorService.schedule(() -> { - final List uploadEventsList = ackDatabase - .uploadEventDao() - .getAll(); - for (UploadEvent ack : uploadEventsList) { - handleAck(ack.getOutputData()); - } + ackDatabase.runInTransaction(new Runnable() { + @Override + public void run() { + List uploadEventsList = ackDatabase + .uploadEventDao() + .getAll(); + for (UploadEvent ack : uploadEventsList) { + handleAck(ack.getOutputData()); + } + } + }); }, 0, TimeUnit.MILLISECONDS); switch (info.getState()) { // If the upload in not finished, publish its progress @@ -226,6 +268,7 @@ private void initManager(String options, final CallbackContext callbackContext) case SUCCEEDED: logMessage("Task succeeded: " + info.getId()); completedTasks++; + break; case FAILED: // The task can't fail completely so something really bad has happened. logMessage("eventLabel='Uploader failed inexplicably' error='" + info.getOutputData() + "'"); @@ -257,6 +300,10 @@ private void addUpload(JSONObject jsonPayload) { final String uploadId = String.valueOf(payload.get("id")); + if (uploadId == null) { + return; + } + // Create headers final Map headers; try { @@ -325,9 +372,19 @@ private void addUpload(JSONObject jsonPayload) { ) ); - if (!workerIsStarted) { - startWorkers(); - workerIsStarted = true; + try { + List workInfoStates = new ArrayList<>(); + workInfoStates.add(WorkInfo.State.BLOCKED); + workInfoStates.add(WorkInfo.State.CANCELLED); + workInfoStates.add(WorkInfo.State.ENQUEUED); + workInfoStates.add(WorkInfo.State.RUNNING); + List workers = WorkManager.getInstance(cordova.getContext()).getWorkInfos(WorkQuery.Builder.fromStates(workInfoStates).build()).get(); + if (workers.size() == 0) { + startWorkers(); + } + } catch (ExecutionException | InterruptedException e) { + e.printStackTrace(); + logMessage("eventLabel='Uploader could not start worker:'" + e.getMessage() + "'"); } } @@ -336,13 +393,13 @@ private void startWorkers() { for (int i = 0; i < ccUpload; i++) { OneTimeWorkRequest.Builder workRequestBuilder = new OneTimeWorkRequest.Builder(UploadTask.class) - .setConstraints(new Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .keepResultsForAtLeast(0, TimeUnit.MILLISECONDS) - .setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS) - .addTag(FileTransferBackground.WORK_TAG_UPLOAD); + .setConstraints(new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .keepResultsForAtLeast(0, TimeUnit.MILLISECONDS) + .setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.SECONDS) + .addTag(FileTransferBackground.WORK_TAG_UPLOAD); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { workRequestBuilder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST); @@ -432,16 +489,20 @@ private void handleAck(final Data ackData) { * Cleanup response file and ACK entry. */ private void cleanupUpload(final String uploadId) { - final UploadEvent ack = AckDatabase.getInstance(cordova.getContext()).uploadEventDao().getById(uploadId); + final UploadEvent uploadEventAck = AckDatabase.getInstance(cordova.getContext()).uploadEventDao().getById(uploadId); // If the upload is done there is an ACK of it, so get file name from there - if (ack != null) { - if (ack.getOutputData().getString(UploadTask.KEY_OUTPUT_RESPONSE_FILE) != null) { - cordova.getContext().deleteFile(ack.getOutputData().getString(UploadTask.KEY_OUTPUT_RESPONSE_FILE)); + if (uploadEventAck != null) { + if (uploadEventAck.getOutputData().getString(UploadTask.KEY_OUTPUT_RESPONSE_FILE) != null) { + cordova.getContext().deleteFile(uploadEventAck.getOutputData().getString(UploadTask.KEY_OUTPUT_RESPONSE_FILE)); } // Also delete it from database - AckDatabase.getInstance(cordova.getContext()).uploadEventDao().delete(ack); + final PendingUpload pendingUploadAck = AckDatabase.getInstance(cordova.getContext()).pendingUploadDao().getById(uploadId); + if (pendingUploadAck != null) { + AckDatabase.getInstance(cordova.getContext()).pendingUploadDao().delete(pendingUploadAck); + } + AckDatabase.getInstance(cordova.getContext()).uploadEventDao().delete(uploadEventAck); } else { // Otherwise get the data from the task itself final WorkInfo task; diff --git a/src/android/PendingUploadDao.java b/src/android/PendingUploadDao.java index a1450265..e3eb12a0 100644 --- a/src/android/PendingUploadDao.java +++ b/src/android/PendingUploadDao.java @@ -5,6 +5,7 @@ import androidx.room.Insert; import androidx.room.OnConflictStrategy; import androidx.room.Query; +import androidx.room.Update; import java.util.List; @@ -34,11 +35,26 @@ public interface PendingUploadDao { @Query("SELECT COUNT(*) FROM pending_upload WHERE state = 'UPLOADED'") int getCompletedUploadsCount(); - @Query("UPDATE pending_upload SET state = 'PENDING' WHERE ID = :id") - void markAsPending(final String id); + @Query("UPDATE pending_upload SET state = 'PENDING' WHERE state = 'UPLOADING'") + void resetUploadingAsPending(); - @Query("UPDATE pending_upload SET state = 'UPLOADED' WHERE ID = :id") - void markAsUploaded(final String id); + @Update(onConflict = OnConflictStrategy.REPLACE) + default void markAsPending(final String id) { + setState(id, "PENDING"); + } + + @Update(onConflict = OnConflictStrategy.REPLACE) + default void markAsUploading(final String id) { + setState(id, "UPLOADING"); + } + + @Update(onConflict = OnConflictStrategy.REPLACE) + default void markAsUploaded(final String id) { + setState(id, "UPLOADED"); + } + + @Query("UPDATE OR REPLACE pending_upload SET state = :state WHERE ID = :id") + void setState(final String id, final String state); default boolean exists(final String id) { return getCountById(id) > 0; diff --git a/src/android/UploadForegroundNotification.java b/src/android/UploadForegroundNotification.java index 386e29c8..35f93fda 100644 --- a/src/android/UploadForegroundNotification.java +++ b/src/android/UploadForegroundNotification.java @@ -44,7 +44,7 @@ public static void done(final UUID uuid) { collectiveProgress.remove(uuid); } - static ForegroundInfo getForegroundInfo(final Context context) { + static ForegroundInfo getForegroundInfo(final Context context, float progress) { final long now = System.currentTimeMillis(); // Set to now to ensure other worker will be throttled final long lastUpdate = lastNotificationUpdateMs.getAndSet(now); @@ -56,9 +56,7 @@ static ForegroundInfo getForegroundInfo(final Context context) { return cachedInfo; } - float totalProgressStore = (float) (AckDatabase.getInstance(context).pendingUploadDao().getCompletedUploadsCount() / (double) AckDatabase.getInstance(context).pendingUploadDao().getAllCount()); - - FileTransferBackground.logMessage("eventLabel='getForegroundInfo: general (" + totalProgressStore + ") all (" + collectiveProgress + ")'"); + FileTransferBackground.logMessage("eventLabel='getForegroundInfo: general (" + (progress * 100) + ") all (" + collectiveProgress + ")'"); Class mainActivityClass = null; try { @@ -82,7 +80,7 @@ static ForegroundInfo getForegroundInfo(final Context context) { .setSmallIcon(notificationIconRes) .setColor(Color.rgb(57, 100, 150)) .setOngoing(true) - .setProgress(100, (int) (totalProgressStore * 100f), false) + .setProgress(100, (int) (progress * 100), false) .setContentIntent(pendingIntent) .addAction(notificationIconRes, "Open", pendingIntent) .build(); diff --git a/src/android/UploadNotification.java b/src/android/UploadNotification.java index 2d2fe2d1..f128d501 100644 --- a/src/android/UploadNotification.java +++ b/src/android/UploadNotification.java @@ -41,9 +41,8 @@ public class UploadNotification { )); } - public void updateProgress() { - float totalProgressStore = (float) (AckDatabase.getInstance(context).pendingUploadDao().getCompletedUploadsCount() / (double) AckDatabase.getInstance(context).pendingUploadDao().getAllCount()); - notificationBuilder.setProgress(100, (int) (totalProgressStore * 100f), false); + public void updateProgress(float progress) { + notificationBuilder.setProgress(100, (int) (progress * 100), false); notificationManager.notify(UploadNotification.notificationId, notificationBuilder.build()); } diff --git a/src/android/UploadTask.java b/src/android/UploadTask.java index f8bbf766..606d2fe2 100644 --- a/src/android/UploadTask.java +++ b/src/android/UploadTask.java @@ -93,16 +93,12 @@ public UploadTask(@NonNull Context context, @NonNull WorkerParameters workerPara super(context, workerParams); - // Migrating code from 4.0.9 to 4.0.10 - Check if upload comes from another worker and does not exists in table - String oldUploadTaskId = workerParams.getInputData().getString(KEY_INPUT_ID); - if (!firstMigrationFlag && oldUploadTaskId != null && AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getById(oldUploadTaskId) == null) { - FileTransferBackground.logMessage("Migrating upload " + oldUploadTaskId); - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().insert(new PendingUpload(oldUploadTaskId, workerParams.getInputData())); - FileTransferBackground.logMessage("Retrying migrated upload " + oldUploadTaskId + " after some seconds..."); - firstMigrationFlag = true; - } - - nextPendingUpload = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getFirstPendingEntry(); + AckDatabase.getInstance(getApplicationContext()).runInTransaction(new Runnable() { + @Override + public void run() { + nextPendingUpload = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getFirstPendingEntry(); + } + }); if (httpClient == null) { httpClient = new OkHttpClient.Builder() @@ -116,8 +112,6 @@ public UploadTask(@NonNull Context context, @NonNull WorkerParameters workerPara .build(); } - httpClient.dispatcher().setMaxRequests(nextPendingUpload.getInputData().getInt(KEY_INPUT_CONFIG_CONCURRENT_DOWNLOADS, 2)); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { UploadForegroundNotification.configure( nextPendingUpload.getInputData().getString(UploadTask.KEY_INPUT_NOTIFICATION_TITLE), @@ -142,35 +136,17 @@ public Result doWork() { return Result.retry(); } - // Migrating code from 4.0.9 to 4.0.10 - Check if upload comes from another worker and does not exists in table - String oldUploadTaskId = getInputData().getString(KEY_INPUT_ID); - if (oldUploadTaskId != null && AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getById(oldUploadTaskId) == null && firstMigrationFlag == true) { - FileTransferBackground.logMessage("Migrating upload " + oldUploadTaskId); - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().insert(new PendingUpload(oldUploadTaskId, getInputData())); - FileTransferBackground.logMessage("Retrying migrated upload " + oldUploadTaskId + " after some seconds..."); - return Result.success(); - } - - do { - nextPendingUpload = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getFirstPendingEntry(); - - final String id = nextPendingUpload.getInputData().getString(KEY_INPUT_ID); - - if (id == null) { - FileTransferBackground.logMessageError("doWork: ID is invalid !", null); - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsPending(nextPendingUpload.getId()); - return Result.failure(); - } + while (true) { + AckDatabase.getInstance(getApplicationContext()).runInTransaction(new Runnable() { + @Override + public void run() { + nextPendingUpload = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getFirstPendingEntry(); + AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsUploading(nextPendingUpload.getId()); + } + }); - // Check retry count - if (getRunAttemptCount() > MAX_TRIES) { - return Result.success(new Data.Builder() - .putString(KEY_OUTPUT_ID, id) - .putBoolean(KEY_OUTPUT_IS_ERROR, true) - .putString(KEY_OUTPUT_FAILURE_REASON, "Too many retries") - .putBoolean(KEY_OUTPUT_FAILURE_CANCELED, false) - .build() - ); + if (nextPendingUpload == null) { + break; } Request request = null; @@ -179,7 +155,7 @@ public Result doWork() { } catch (FileNotFoundException e) { FileTransferBackground.logMessageError("doWork: File not found !", e); return Result.success(new Data.Builder() - .putString(KEY_OUTPUT_ID, id) + .putString(KEY_OUTPUT_ID, nextPendingUpload.getId()) .putBoolean(KEY_OUTPUT_IS_ERROR, true) .putString(KEY_OUTPUT_FAILURE_REASON, "File not found !") .putBoolean(KEY_OUTPUT_FAILURE_CANCELED, false) @@ -191,7 +167,7 @@ public Result doWork() { // Register me uploadForegroundNotification.progress(getId(), 0f); - handleNotification(); + handleNotification(0f); // Start call currentCall = httpClient.newCall(request); @@ -225,13 +201,18 @@ public Result doWork() { // See #handleProgress for cancel code if (isStopped()) { final Data data = new Data.Builder() - .putString(KEY_OUTPUT_ID, id) + .putString(KEY_OUTPUT_ID, nextPendingUpload.getId()) .putBoolean(KEY_OUTPUT_IS_ERROR, true) .putString(KEY_OUTPUT_FAILURE_REASON, "User cancelled") .putBoolean(KEY_OUTPUT_FAILURE_CANCELED, true) .build(); - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsUploaded(nextPendingUpload.getId()); - AckDatabase.getInstance(getApplicationContext()).uploadEventDao().insert(new UploadEvent(id, data)); + AckDatabase.getInstance(getApplicationContext()).runInTransaction(new Runnable() { + @Override + public void run() { + AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsUploaded(nextPendingUpload.getId()); + AckDatabase.getInstance(getApplicationContext()).uploadEventDao().insert(new UploadEvent(nextPendingUpload.getId(), data)); + } + }); return Result.success(data); } else { // But if it was not it must be a connectivity problem or @@ -246,7 +227,7 @@ public Result doWork() { // Start building the output data final Data.Builder outputData = new Data.Builder() - .putString(KEY_OUTPUT_ID, id) + .putString(KEY_OUTPUT_ID, nextPendingUpload.getId()) .putBoolean(KEY_OUTPUT_IS_ERROR, false) .putInt(KEY_OUTPUT_STATUS_CODE, (!DEBUG_SKIP_UPLOAD) ? response.code() : 200); @@ -275,17 +256,25 @@ public Result doWork() { } final Data data = outputData.build(); - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsUploaded(nextPendingUpload.getId()); - AckDatabase.getInstance(getApplicationContext()).uploadEventDao().insert(new UploadEvent(id, data)); - } while(AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getPendingUploadsCount() > 0); - - final List pendingUploads = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getCompletedUploads(); - - for (PendingUpload pendingUpload: pendingUploads) { - AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().delete(pendingUpload); + AckDatabase.getInstance(getApplicationContext()).runInTransaction(new Runnable() { + @Override + public void run() { + AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().markAsUploaded(nextPendingUpload.getId()); + AckDatabase.getInstance(getApplicationContext()).uploadEventDao().insert(new UploadEvent(nextPendingUpload.getId(), data)); + } + }); } - FileTransferBackground.workerIsStarted = false; + AckDatabase.getInstance(getApplicationContext()).runInTransaction(new Runnable() { + @Override + public void run() { + final List pendingUploads = AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().getCompletedUploads(); + + for (PendingUpload pendingUpload:pendingUploads) { + AckDatabase.getInstance(getApplicationContext()).pendingUploadDao().delete(pendingUpload); + } + } + }); return Result.success(); } @@ -303,16 +292,13 @@ private void handleProgress(long bytesWritten, long totalBytes) { float percent = (float) bytesWritten / (float) totalBytes; UploadForegroundNotification.progress(getId(), percent); - - FileTransferBackground.logMessageInfo("handleProgress: " + getId() + " Progress: " + (int) (percent * 100f)); - final Data data = new Data.Builder() .putString(KEY_PROGRESS_ID, nextPendingUpload.getInputData().getString(KEY_INPUT_ID)) .putInt(KEY_PROGRESS_PERCENT, (int) (percent * 100f)) .build(); FileTransferBackground.logMessage("handleProgress: Progress data: " + data); setProgressAsync(data); - handleNotification(); + handleNotification(percent); } /** @@ -388,12 +374,12 @@ private Request createRequest() throws FileNotFoundException { return requestBuilder.build(); } - private void handleNotification() { + private void handleNotification(float progress) { FileTransferBackground.logMessage("Upload Notification"); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - setForegroundAsync(uploadForegroundNotification.getForegroundInfo(getApplicationContext())); + setForegroundAsync(uploadForegroundNotification.getForegroundInfo(getApplicationContext(), progress)); } else { - uploadNotification.updateProgress(); + uploadNotification.updateProgress(progress); } FileTransferBackground.logMessage("Upload Notification Exit"); }