diff --git a/native/android/app/build.gradle b/native/android/app/build.gradle --- a/native/android/app/build.gradle +++ b/native/android/app/build.gradle @@ -707,6 +707,13 @@ } else { implementation jscFlavor } + def work_version = "2.8.1" + // (Java only) + implementation "androidx.work:work-runtime:$work_version" + // Guava for listenable future to solve the bug: + // https://stackoverflow.com/questions/64290141/android-studio-class-file-for-com-google-common-util-concurrent-listenablefuture + // https://github.com/google/ExoPlayer/issues/7993 + implementation "com.google.guava:guava:31.0.1-android" } if (isNewArchitectureEnabled()) { diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java --- a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsBlobServiceClient.java @@ -1,6 +1,13 @@ package app.comm.android.notifications; +import android.content.Context; import android.util.Log; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; import com.google.firebase.messaging.RemoteMessage; import java.io.IOException; import java.lang.OutOfMemoryError; @@ -17,8 +24,6 @@ import org.json.JSONObject; public class CommAndroidNotificationsBlobServiceClient { - private static final String BLOB_SERVICE_URL = - "https://blob.commtechnologies.org"; // The FirebaseMessagingService docs state that message // processing should complete within at most 20 seconds // window. Therefore we limit http time call to 15 seconds @@ -26,11 +31,17 @@ private static final int NOTIFICATION_PROCESSING_TIME_LIMIT = 15; // OkHttp docs advise to share OkHttpClient instances // https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/#okhttpclients-should-be-shared - private static final OkHttpClient httpClient = + + public static final OkHttpClient httpClient = new OkHttpClient.Builder() .callTimeout(NOTIFICATION_PROCESSING_TIME_LIMIT, TimeUnit.SECONDS) .build(); + public static final String BLOB_SERVICE_URL = + "https://blob.commtechnologies.org"; + public static final String BLOB_HASH_KEY = "blob_hash"; + public static final String BLOB_HOLDER_KEY = "holder"; + @FunctionalInterface public interface BlobServiceMessageConsumer { void accept(RemoteMessage message, byte[] blob); @@ -71,7 +82,28 @@ }); } - private String getAuthToken() { + public void scheduleDeferredBlobDeletion( + String blobHash, + String blobHolder, + Context context) { + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + WorkRequest deleteBlobWorkRequest = + new OneTimeWorkRequest.Builder(DeleteBlobWork.class) + .setConstraints(constraints) + .setInitialDelay( + NOTIFICATION_PROCESSING_TIME_LIMIT, TimeUnit.SECONDS) + .setInputData(new Data.Builder() + .putString(BLOB_HASH_KEY, blobHash) + .putString(BLOB_HOLDER_KEY, blobHolder) + .build()) + .build(); + + WorkManager.getInstance(context).enqueue(deleteBlobWorkRequest); + } + + public static String getAuthToken() { // Authentication data are retrieved on every request // since they might change while CommNotificationsHandler // thread is running so we should not rely on caching diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java --- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java @@ -44,6 +44,7 @@ private static final String ENCRYPTED_PAYLOAD_KEY = "encryptedPayload"; private static final String ENCRYPTION_FAILED_KEY = "encryptionFailed"; private static final String BLOB_HASH_KEY = "blobHash"; + private static final String BLOB_HOLDER_KEY = "holder"; private static final String AES_ENCRYPTION_KEY_KEY = "encryptionKey"; private static final String CHANNEL_ID = "default"; @@ -108,7 +109,11 @@ } String blobHash = message.getData().get(BLOB_HASH_KEY); - if (blobHash != null) { + String blobHolder = message.getData().get(BLOB_HOLDER_KEY); + + if (blobHash != null && blobHolder != null) { + blobServiceClient.scheduleDeferredBlobDeletion( + blobHash, blobHolder, this.getApplicationContext()); blobServiceClient.getAndConsumeAsync( blobHash, message, diff --git a/native/android/app/src/main/java/app/comm/android/notifications/DeleteBlobWork.java b/native/android/app/src/main/java/app/comm/android/notifications/DeleteBlobWork.java new file mode 100644 --- /dev/null +++ b/native/android/app/src/main/java/app/comm/android/notifications/DeleteBlobWork.java @@ -0,0 +1,95 @@ +package app.comm.android.notifications; + +import android.content.Context; +import android.util.Log; +import androidx.work.Data; +import androidx.work.ListenableWorker.Result; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import java.io.IOException; +import java.util.function.Consumer; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONException; +import org.json.JSONObject; + +public class DeleteBlobWork extends Worker { + + private static final int MAX_RETRY_ATTEMPTS = 10; + + public DeleteBlobWork(Context context, WorkerParameters params) { + super(context, params); + } + + @Override + public Result doWork() { + String blobHash = getInputData().getString( + CommAndroidNotificationsBlobServiceClient.BLOB_HASH_KEY); + String blobHolder = getInputData().getString( + CommAndroidNotificationsBlobServiceClient.BLOB_HOLDER_KEY); + + String jsonBody; + try { + jsonBody = + new JSONObject() + .put( + CommAndroidNotificationsBlobServiceClient.BLOB_HASH_KEY, + blobHash) + .put( + CommAndroidNotificationsBlobServiceClient.BLOB_HOLDER_KEY, + blobHolder) + .toString(); + } catch (JSONException e) { + // This should never happen since the code + // throwing is just simple JSON creation. + // If it happens there is no way to retry + // so we fail immediately. + Log.w( + "COMM", + "Failed to create JSON from blob hash and holder provided.", + e); + return Result.failure(); + } + + String authToken = CommAndroidNotificationsBlobServiceClient.getAuthToken(); + RequestBody requestBody = + RequestBody.create(MediaType.parse("application/json"), jsonBody); + Request request = + new Request.Builder() + .delete(requestBody) + .url( + CommAndroidNotificationsBlobServiceClient.BLOB_SERVICE_URL + + "/blob") + .header("Authorization", authToken) + .build(); + + try { + Response response = + CommAndroidNotificationsBlobServiceClient.httpClient.newCall(request) + .execute(); + if (response.isSuccessful()) { + return Result.success(); + } + Log.w( + "COMM", + "Failed to execute blob deletion request. HTTP code:" + + response.code() + " status: " + response.message()); + return retryOrFail(); + } catch (IOException e) { + Log.w( + "COMM", + "IO exception occurred while issuing blob deletion request.", + e); + return retryOrFail(); + } + } + + private Result retryOrFail() { + if (getRunAttemptCount() > MAX_RETRY_ATTEMPTS) { + return Result.failure(); + } + return Result.retry(); + } +}