diff --git a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/SchedulingModule.java b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/SchedulingModule.java index 20ef1dd0f90..94059f615de 100644 --- a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/SchedulingModule.java +++ b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/SchedulingModule.java @@ -19,6 +19,7 @@ import com.google.android.datatransport.runtime.scheduling.jobscheduling.AlarmManagerScheduler; import com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoScheduler; import com.google.android.datatransport.runtime.scheduling.jobscheduling.SchedulerConfig; +import com.google.android.datatransport.runtime.scheduling.jobscheduling.WorkManagerScheduler; import com.google.android.datatransport.runtime.scheduling.jobscheduling.WorkScheduler; import com.google.android.datatransport.runtime.scheduling.persistence.EventStore; import com.google.android.datatransport.runtime.time.Clock; @@ -32,7 +33,9 @@ public abstract class SchedulingModule { @Provides static WorkScheduler workScheduler( Context context, EventStore eventStore, SchedulerConfig config, @Monotonic Clock clock) { - if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return new WorkManagerScheduler(context, eventStore, config); + } else if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return new JobInfoScheduler(context, eventStore, config); } else { return new AlarmManagerScheduler(context, eventStore, clock, config); diff --git a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoScheduler.java b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoScheduler.java index 14fc99f043a..965e4efc6de 100644 --- a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoScheduler.java +++ b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoScheduler.java @@ -14,7 +14,8 @@ package com.google.android.datatransport.runtime.scheduling.jobscheduling; -import static android.util.Base64.*; +import static android.util.Base64.DEFAULT; +import static android.util.Base64.encodeToString; import android.app.job.JobInfo; import android.app.job.JobScheduler; @@ -23,14 +24,10 @@ import android.os.Build; import android.os.PersistableBundle; import androidx.annotation.RequiresApi; -import androidx.annotation.VisibleForTesting; import com.google.android.datatransport.runtime.TransportContext; import com.google.android.datatransport.runtime.logging.Logging; import com.google.android.datatransport.runtime.scheduling.persistence.EventStore; import com.google.android.datatransport.runtime.util.PriorityMapping; -import java.nio.ByteBuffer; -import java.nio.charset.Charset; -import java.util.zip.Adler32; /** * Schedules the service {@link JobInfoSchedulerService} based on the backendname. Used for Apis 21 @@ -58,21 +55,6 @@ public JobInfoScheduler( this.config = config; } - @VisibleForTesting - int getJobId(TransportContext transportContext) { - Adler32 checksum = new Adler32(); - checksum.update(context.getPackageName().getBytes(Charset.forName("UTF-8"))); - checksum.update(transportContext.getBackendName().getBytes(Charset.forName("UTF-8"))); - checksum.update( - ByteBuffer.allocate(4) - .putInt(PriorityMapping.toInt(transportContext.getPriority())) - .array()); - if (transportContext.getExtras() != null) { - checksum.update(transportContext.getExtras()); - } - return (int) checksum.getValue(); - } - private boolean isJobServiceOn(JobScheduler scheduler, int jobId, int attemptNumber) { for (JobInfo jobInfo : scheduler.getAllPendingJobs()) { int existingAttemptNumber = jobInfo.getExtras().getInt(ATTEMPT_NUMBER); @@ -106,7 +88,7 @@ public void schedule(TransportContext transportContext, int attemptNumber, boole ComponentName serviceComponent = new ComponentName(context, JobInfoSchedulerService.class); JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); - int jobId = getJobId(transportContext); + int jobId = WorkScheduler.getJobId(context, transportContext); // Check if there exists a job scheduled for this backend name. if (!force && isJobServiceOn(jobScheduler, jobId, attemptNumber)) { Logging.d( diff --git a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerScheduler.java b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerScheduler.java new file mode 100644 index 00000000000..c119457cba6 --- /dev/null +++ b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerScheduler.java @@ -0,0 +1,118 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.android.datatransport.runtime.scheduling.jobscheduling; + +import static android.util.Base64.DEFAULT; +import static android.util.Base64.encodeToString; + +import android.content.Context; +import android.os.Build; +import androidx.annotation.RequiresApi; +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkInfo; +import androidx.work.WorkManager; +import androidx.work.WorkRequest; +import com.google.android.datatransport.runtime.TransportContext; +import com.google.android.datatransport.runtime.logging.Logging; +import com.google.android.datatransport.runtime.scheduling.persistence.EventStore; +import com.google.android.datatransport.runtime.util.PriorityMapping; +import java.util.concurrent.TimeUnit; + +@RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +public class WorkManagerScheduler implements WorkScheduler { + private static final String LOG_TAG = "WorkManagerScheduler"; + + static final String ATTEMPT_NUMBER = "attemptNumber"; + static final String BACKEND_NAME = "backendName"; + static final String EVENT_PRIORITY = "priority"; + static final String EXTRAS = "extras"; + private final Context context; + + private final EventStore eventStore; + + private final SchedulerConfig config; + + public WorkManagerScheduler( + Context applicationContext, EventStore eventStore, SchedulerConfig config) { + this.context = applicationContext; + this.eventStore = eventStore; + this.config = config; + } + + private String getTag(int jobId) { + return "transport-" + jobId; + } + + @Override + public void schedule(TransportContext transportContext, int attemptNumber) { + schedule(transportContext, attemptNumber, false); + } + + @Override + public void schedule(TransportContext transportContext, int attemptNumber, boolean force) { + WorkManager manager = WorkManager.getInstance(context); + + int jobId = WorkScheduler.getJobId(context, transportContext); + if (!force) { + try { + for (WorkInfo info : manager.getWorkInfosByTag(this.getTag(jobId)).get()) { + if (!info.getState().isFinished()) { + Logging.d( + LOG_TAG, + "Upload for context %s is already scheduled. Returning...", + transportContext); + return; + } + } + } catch (Exception e) { + // Various Future failure states that shouldn't be possible + throw new RuntimeException(e); + } + } + + Data.Builder dataBuilder = + new Data.Builder() + .putInt(ATTEMPT_NUMBER, attemptNumber) + .putString(BACKEND_NAME, transportContext.getBackendName()) + .putInt(EVENT_PRIORITY, PriorityMapping.toInt(transportContext.getPriority())); + if (transportContext.getExtras() != null) { + dataBuilder.putString(EXTRAS, encodeToString(transportContext.getExtras(), DEFAULT)); + } + + long backendTime = eventStore.getNextCallTime(transportContext); + boolean hasPendingEvents = force && eventStore.hasPendingEventsFor(transportContext); + + long scheduleDelay = + config.getScheduleDelay( + transportContext.getPriority(), backendTime, attemptNumber, hasPendingEvents); + + Logging.d( + LOG_TAG, + "Scheduling upload for context %s in %dms(Backend next call timestamp %d). Attempt %d", + transportContext, + scheduleDelay, + backendTime, + attemptNumber); + + WorkRequest request = + new OneTimeWorkRequest.Builder(WorkManagerSchedulerWorker.class) + .setInitialDelay(scheduleDelay, TimeUnit.MILLISECONDS) + .setInputData(dataBuilder.build()) + .addTag(this.getTag(jobId)) + .build(); + manager.enqueue(request); + } +} diff --git a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerSchedulerWorker.java b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerSchedulerWorker.java new file mode 100644 index 00000000000..d739a4e0379 --- /dev/null +++ b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkManagerSchedulerWorker.java @@ -0,0 +1,61 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.android.datatransport.runtime.scheduling.jobscheduling; + +import android.content.Context; +import android.os.Build; +import android.util.Base64; +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import com.google.android.datatransport.runtime.TransportContext; +import com.google.android.datatransport.runtime.TransportRuntime; +import com.google.android.datatransport.runtime.util.PriorityMapping; + +@RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +public class WorkManagerSchedulerWorker extends Worker { + + public WorkManagerSchedulerWorker( + @NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + } + + @NonNull + @Override + public Result doWork() { + Data data = getInputData(); + String backendName = data.getString(JobInfoScheduler.BACKEND_NAME); + String extras = data.getString(JobInfoScheduler.EXTRAS); + + int priority = data.getInt(JobInfoScheduler.EVENT_PRIORITY, 0); + int attemptNumber = data.getInt(JobInfoScheduler.ATTEMPT_NUMBER, 0); + TransportRuntime.initialize(getApplicationContext()); + TransportContext.Builder transportContext = + TransportContext.builder() + .setBackendName(backendName) + .setPriority(PriorityMapping.valueOf(priority)); + + if (extras != null) { + transportContext.setExtras(Base64.decode(extras, Base64.DEFAULT)); + } + + TransportRuntime.getInstance() + .getUploader() + .upload(transportContext.build(), attemptNumber, () -> {}); + return Result.success(); + } +} diff --git a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkScheduler.java b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkScheduler.java index 08f15ae623c..0edfe6ef5f7 100644 --- a/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkScheduler.java +++ b/transport/transport-runtime/src/main/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/WorkScheduler.java @@ -14,11 +14,32 @@ package com.google.android.datatransport.runtime.scheduling.jobscheduling; +import android.content.Context; +import androidx.annotation.VisibleForTesting; import com.google.android.datatransport.runtime.TransportContext; +import com.google.android.datatransport.runtime.util.PriorityMapping; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.zip.Adler32; /** Schedules the services to be able to eventually log events to their respective backends. */ public interface WorkScheduler { void schedule(TransportContext transportContext, int attemptNumber); void schedule(TransportContext transportContext, int attemptNumber, boolean force); + + @VisibleForTesting + static int getJobId(Context context, TransportContext transportContext) { + Adler32 checksum = new Adler32(); + checksum.update(context.getPackageName().getBytes(Charset.forName("UTF-8"))); + checksum.update(transportContext.getBackendName().getBytes(Charset.forName("UTF-8"))); + checksum.update( + ByteBuffer.allocate(4) + .putInt(PriorityMapping.toInt(transportContext.getPriority())) + .array()); + if (transportContext.getExtras() != null) { + checksum.update(transportContext.getExtras()); + } + return (int) checksum.getValue(); + } } diff --git a/transport/transport-runtime/src/test/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoSchedulerTest.java b/transport/transport-runtime/src/test/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoSchedulerTest.java index 197daa2747c..a2ea0087f26 100644 --- a/transport/transport-runtime/src/test/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoSchedulerTest.java +++ b/transport/transport-runtime/src/test/java/com/google/android/datatransport/runtime/scheduling/jobscheduling/JobInfoSchedulerTest.java @@ -61,7 +61,7 @@ public class JobInfoSchedulerTest { public void schedule_secondAttemptThenForce() { store.recordNextCallTime(TRANSPORT_CONTEXT, 5); scheduler.schedule(TRANSPORT_CONTEXT, 2); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(1); JobInfo jobInfo = jobScheduler.getAllPendingJobs().get(0); @@ -86,7 +86,7 @@ public void schedule_secondAttemptThenForce() { public void schedule_longWaitTimeFirstAttempt() { store.recordNextCallTime(TRANSPORT_CONTEXT, 1000000); scheduler.schedule(TRANSPORT_CONTEXT, 1); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(1); JobInfo jobInfo = jobScheduler.getAllPendingJobs().get(0); @@ -101,7 +101,7 @@ public void schedule_longWaitTimeFirstAttempt() { @Test public void schedule_noTimeRecordedForBackend() { scheduler.schedule(TRANSPORT_CONTEXT, 1); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(1); JobInfo jobInfo = jobScheduler.getAllPendingJobs().get(0); @@ -117,7 +117,7 @@ public void schedule_noTimeRecordedForBackend() { public void schedule_smallWaitTImeFirstAttempt() { store.recordNextCallTime(TRANSPORT_CONTEXT, 5); scheduler.schedule(TRANSPORT_CONTEXT, 1); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(1); JobInfo jobInfo = jobScheduler.getAllPendingJobs().get(0); @@ -133,7 +133,7 @@ public void schedule_smallWaitTImeFirstAttempt() { public void schedule_longWaitTimeTenthAttempt() { store.recordNextCallTime(TRANSPORT_CONTEXT, 1000000); scheduler.schedule(TRANSPORT_CONTEXT, 10); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(1); JobInfo jobInfo = jobScheduler.getAllPendingJobs().get(0); @@ -148,7 +148,7 @@ public void schedule_longWaitTimeTenthAttempt() { @Test public void schedule_twoJobs() { store.recordNextCallTime(TRANSPORT_CONTEXT, 5); - int jobId = scheduler.getJobId(TRANSPORT_CONTEXT); + int jobId = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); // Schedule first job scheduler.schedule(TRANSPORT_CONTEXT, 1); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); @@ -221,8 +221,8 @@ public void schedule_smallWaitTimeFirstAttempt_multiplePriorities() { store.recordNextCallTime(TRANSPORT_CONTEXT, 5); scheduler.schedule(TRANSPORT_CONTEXT, 1); scheduler.schedule(UNMETERED_TRANSPORT_CONTEXT, 1); - int jobId1 = scheduler.getJobId(TRANSPORT_CONTEXT); - int jobId2 = scheduler.getJobId(UNMETERED_TRANSPORT_CONTEXT); + int jobId1 = WorkScheduler.getJobId(context, TRANSPORT_CONTEXT); + int jobId2 = WorkScheduler.getJobId(context, UNMETERED_TRANSPORT_CONTEXT); assertThat(jobScheduler.getAllPendingJobs()).isNotEmpty(); assertThat(jobScheduler.getAllPendingJobs().size()).isEqualTo(2); diff --git a/transport/transport-runtime/transport-runtime.gradle b/transport/transport-runtime/transport-runtime.gradle index 0eb853e98c8..ecc46a4b2a0 100644 --- a/transport/transport-runtime/transport-runtime.gradle +++ b/transport/transport-runtime/transport-runtime.gradle @@ -110,6 +110,7 @@ dependencies { api "com.google.firebase:firebase-encoders-proto:16.0.0" implementation 'androidx.annotation:annotation:1.3.0' + implementation 'androidx.work:work-runtime:2.9.1' implementation 'javax.inject:javax.inject:1' compileOnly "com.google.auto.value:auto-value-annotations:1.6.6"