Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android 34 WorkManager Scheduler #6221

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
emilypgoogle marked this conversation as resolved.
Show resolved Hide resolved
import java.util.concurrent.TimeUnit;

@RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
public class WorkManagerScheduler implements WorkScheduler {
emilypgoogle marked this conversation as resolved.
Show resolved Hide resolved
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;
}

@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("transport-" + jobId).get()) {
emilypgoogle marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getWorkInfosByTag returns a list, but it should always be exactly 1, right? If that's the case, we need to verify that condition, as if it's not true something else has gone wrong, instead of looping through it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, maybe it keeps finished jobs for some period of time? It wouldn't be a condition we verify though, scheduling can be called with force = true beforehand and schedule multiple before calling with force = false, I think.

if (!info.getState().isFinished()) {
Logging.d(
LOG_TAG,
"Upload for context %s is already scheduled. Returning...",
transportContext);
return;
}
}
} catch (Exception e) {
emilypgoogle marked this conversation as resolved.
Show resolved Hide resolved
}
}

Data.Builder dataBuilder = new Data.Builder();
dataBuilder.putInt(ATTEMPT_NUMBER, attemptNumber);
dataBuilder.putString(BACKEND_NAME, transportContext.getBackendName());
dataBuilder.putInt(EVENT_PRIORITY, PriorityMapping.toInt(transportContext.getPriority()));
emilypgoogle marked this conversation as resolved.
Show resolved Hide resolved
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("transport-" + jobId)
.build();
manager.enqueue(request);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions transport/transport-runtime/transport-runtime.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading