// Copyright 2016 Google, Inc. // // 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.firebase.jobdispatcher; import static com.firebase.jobdispatcher.GooglePlayReceiver.getJobCoder; import static com.firebase.jobdispatcher.TestUtil.flushExecutorService; import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import android.content.Intent; import android.os.Bundle; import android.os.IBinder; import android.os.Looper; import android.os.Parcel; import android.os.RemoteException; import android.support.annotation.NonNull; import android.support.v4.util.Pair; import com.firebase.jobdispatcher.JobInvocation.Builder; import com.google.android.gms.gcm.PendingCallback; import com.google.common.util.concurrent.SettableFuture; import java.io.PrintWriter; import java.io.StringWriter; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowLooper; import org.robolectric.shadows.ShadowSystemClock; /** Tests for the {@link JobService} class. */ @RunWith(RobolectricTestRunner.class) @Config( manifest = Config.NONE, sdk = 23, shadows = {ShadowSystemClock.class}) public class JobServiceTest { private static final int TIMEOUT_MS = 3_000; private static CountDownLatch countDownLatch; private final IJobCallback noopCallback = new IJobCallback.Stub() { @Override public void jobFinished(Bundle invocationData, @JobService.JobResult int result) {} }; @Before public void setUp() {} @After public void tearDown() { countDownLatch = null; } @Test public void testOnStartCommand_handlesNullIntent() { JobService service = spy(new ExampleJobService()); int startId = 7; try { service.onStartCommand(null, 0, startId); verify(service).stopSelf(startId); } catch (NullPointerException npe) { fail("Unexpected NullPointerException after calling onStartCommand with a null Intent."); } } @Test public void testOnStartCommand_handlesNullAction() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent nullActionIntent = new Intent(); service.onStartCommand(nullActionIntent, 0, startId); verify(service).stopSelf(startId); } @Test public void testOnStartCommand_handlesEmptyAction() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent emptyActionIntent = new Intent(""); service.onStartCommand(emptyActionIntent, 0, startId); verify(service).stopSelf(startId); } @Test public void testOnStartCommand_handlesUnknownAction() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent emptyActionIntent = new Intent("foo.bar.baz"); service.onStartCommand(emptyActionIntent, 0, startId); verify(service).stopSelf(startId); } @Test public void testOnStartCommand_handlesStartJob_nullData() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); service.onStartCommand(executeJobIntent, 0, startId); verify(service).stopSelf(startId); } @Test public void testOnStartCommand_handlesStartJob_noTag() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); Parcel p = Parcel.obtain(); p.writeStrongBinder(mock(IBinder.class)); executeJobIntent.putExtra("callback", new PendingCallback(p)); service.onStartCommand(executeJobIntent, 0, startId); verify(service).stopSelf(startId); p.recycle(); } @Test public void testOnStartCommand_handlesStartJob_noCallback() { JobService service = spy(new ExampleJobService()); int startId = 7; Intent executeJobIntent = new Intent(JobService.ACTION_EXECUTE); executeJobIntent.putExtra("tag", "foobar"); service.onStartCommand(executeJobIntent, 0, startId); verify(service).stopSelf(startId); } @Test public void testOnStartCommand_handlesStartJob_validRequest() throws Exception { JobService service = spy(new ExampleJobService()); Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(ExampleJobService.class) .setRetryStrategy(RetryStrategy.DEFAULT_EXPONENTIAL) .setTrigger(Trigger.NOW) .setLifetime(Lifetime.FOREVER) .build(); countDownLatch = new CountDownLatch(1); Bundle jobSpecData = getJobCoder().encode(jobSpec, new Bundle()); IRemoteJobService remoteJobService = IRemoteJobService.Stub.asInterface(service.onBind(new Intent(JobService.ACTION_EXECUTE))); remoteJobService.start(jobSpecData, noopCallback); flush(service); assertTrue("Expected job to run to completion", countDownLatch.await(5, TimeUnit.SECONDS)); } @Test public void testOnStartCommand_handlesStartJob_doNotStartRunningJobAgain() throws Exception { StoppableJobService service = new StoppableJobService(/* shouldReschedule= */ false); Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(StoppableJobService.class) .setTrigger(Trigger.NOW) .build(); Bundle jobSpecData = getJobCoder().encode(jobSpec, new Bundle()); IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, null); IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, null); flush(service); assertEquals(1, service.getNumberOfStartRequestsReceived()); } @Test public void stop_noCallback_finished() throws Exception { JobService service = spy(new StoppableJobService(/* shouldReschedule= */ false)); JobInvocation job = new Builder() .setTag("Tag") .setTrigger(Trigger.NOW) .setService(StoppableJobService.class.getName()) .build(); IRemoteJobService.Stub.asInterface(service.onBind(null)) .stop(getJobCoder().encode(job, new Bundle()), true); flush(service); verify(service, never()).onStopJob(job); } @Test public void stop_withCallback_retry() throws Exception { StoppableJobService service = spy(new StoppableJobService(/* shouldReschedule= */ false)); JobInvocation job = new Builder() .setTag("Tag") .setTrigger(Trigger.NOW) .setService(StoppableJobService.class.getName()) .build(); Bundle jobSpecData = getJobCoder().encode(job, new Bundle()); FutureSettingJobCallback callback = new FutureSettingJobCallback(); // start the service IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, callback); IRemoteJobService.Stub.asInterface(service.onBind(null)).stop(jobSpecData, true); flush(service); assertEquals(1, service.getNumberOfStopRequestsReceived()); callback.verifyCalledWithJobAndResult(job, JobService.RESULT_SUCCESS); } @Test public void stop_withCallback_done() throws Exception { StoppableJobService service = spy(new StoppableJobService(/* shouldReschedule= */ true)); JobInvocation job = new Builder() .setTag("Tag") .setTrigger(Trigger.NOW) .setService(StoppableJobService.class.getName()) .build(); Bundle jobSpecData = getJobCoder().encode(job, new Bundle()); FutureSettingJobCallback callback = new FutureSettingJobCallback(); IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, callback); IRemoteJobService.Stub.asInterface(service.onBind(null)).stop(jobSpecData, true); flush(service); assertEquals(1, service.getNumberOfStopRequestsReceived()); callback.verifyCalledWithJobAndResult(job, JobService.RESULT_FAIL_RETRY); } @Test public void onStartJob_jobFinishedReschedule() throws Exception { // Verify that a retry request from within onStartJob will cause the retry result to be sent // to the bouncer service's handler, regardless of what value is ultimately returned from // onStartJob. JobService reschedulingService = new JobService() { @Override public boolean onStartJob(@NonNull JobParameters job) { // Reschedules job. jobFinished(job, true /* retry this job */); return false; } @Override public boolean onStopJob(@NonNull JobParameters job) { return false; } }; Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(reschedulingService.getClass()) .setTrigger(Trigger.NOW) .build(); FutureSettingJobCallback callback = new FutureSettingJobCallback(); IRemoteJobService.Stub.asInterface(reschedulingService.onBind(null)) .start(getJobCoder().encode(jobSpec, new Bundle()), callback); flush(reschedulingService); callback.verifyCalledWithJobAndResult(jobSpec, JobService.RESULT_FAIL_RETRY); } @Test public void onStartJob_jobFinishedNotReschedule() throws Exception { // Verify that a termination request from within onStartJob will cause the result to be sent // to the bouncer service's handler, regardless of what value is ultimately returned from // onStartJob. JobService reschedulingService = new JobService() { @Override public boolean onStartJob(@NonNull JobParameters job) { jobFinished(job, false /* don't retry this job */); return false; } @Override public boolean onStopJob(@NonNull JobParameters job) { return false; } }; Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(reschedulingService.getClass()) .setTrigger(Trigger.NOW) .build(); FutureSettingJobCallback callback = new FutureSettingJobCallback(); IRemoteJobService.Stub.asInterface(reschedulingService.onBind(null)) .start(getJobCoder().encode(jobSpec, new Bundle()), callback); flush(reschedulingService); callback.verifyCalledWithJobAndResult(jobSpec, JobService.RESULT_SUCCESS); } @Test public void onUnbind_removesUsedCallbacks_withBackgroundWork() throws Exception { verifyOnUnbindCausesResult( new JobService() { @Override public boolean onStartJob(@NonNull JobParameters job) { return true; // More work to do in background } @Override public boolean onStopJob(@NonNull JobParameters job) { return true; // Still doing background work } }, JobService.RESULT_FAIL_RETRY); } @Test public void onUnbind_removesUsedCallbacks_noBackgroundWork() throws Exception { verifyOnUnbindCausesResult( new JobService() { @Override public boolean onStartJob(@NonNull JobParameters job) { return true; // more work to do in background } @Override public boolean onStopJob(@NonNull JobParameters job) { return false; // Done with background work } }, JobService.RESULT_FAIL_NORETRY); } @Test public void onStop_calledOnMainThread() throws Exception { final SettableFuture<Looper> looperFuture = SettableFuture.create(); final JobService service = new JobService() { @Override public boolean onStartJob(@NonNull JobParameters job) { return true; // more work to do } @Override public boolean onStopJob(@NonNull JobParameters job) { looperFuture.set(Looper.myLooper()); return false; } }; Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(service.getClass()) .setTrigger(Trigger.NOW) .build(); final Bundle jobSpecData = getJobCoder().encode(jobSpec, new Bundle()); IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, noopCallback); // call stopJob on a background thread and wait for it Executors.newSingleThreadExecutor() .submit( new Runnable() { @Override public void run() { try { IRemoteJobService.Stub.asInterface(service.onBind(null)).stop(jobSpecData, true); } catch (RemoteException e) { throw new AssertionError("calling stop on binder unexpectedly failed", e); } } }) .get(1, TimeUnit.SECONDS); flush(service); assertEquals( "onStopJob was not called on main thread", Looper.getMainLooper(), looperFuture.get(1, TimeUnit.SECONDS)); } @Test public void dump_noTasksStarted() throws Exception { assertThat(dump(new ExampleJobService())).isEqualTo("No running jobs\n"); } @Test public void dump_oneRunningJob() throws Exception { countDownLatch = new CountDownLatch(1); JobService service = new JobService() { @Override public boolean onStartJob(JobParameters job) { countDownLatch.countDown(); return true; // more work to do } @Override public boolean onStopJob(JobParameters job) { return false; } }; Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("one_running_job") .setService(service.getClass()) .setTrigger(Trigger.NOW) .build(); Bundle jobSpecData = getJobCoder().encode(jobSpec, new Bundle()); FutureSettingJobCallback callback = new FutureSettingJobCallback(); IRemoteJobService stub = IRemoteJobService.Stub.asInterface(service.onBind(null)); ShadowSystemClock.setCurrentTimeMillis(10_000L); // Start the job stub.start(jobSpecData, callback); flush(service); // Make sure it was started assertThat(countDownLatch.await(1, TimeUnit.SECONDS)).isTrue(); // Fast forward 30s ShadowSystemClock.setCurrentTimeMillis(40_000L); assertThat(dump(service)) .isEqualTo("Running jobs:\n * \"one_running_job\" has been running for 00:30\n"); stub.stop(jobSpecData, /* needToSendResult= */ false); flush(service); assertThat(dump(service)).isEqualTo("No running jobs\n"); } private static String dump(JobService service) throws Exception { StringWriter sw = new StringWriter(); service.dumpImpl(new PrintWriter(sw)); return sw.toString(); } private static void flush(JobService jobService) throws Exception { flushExecutorService(jobService.backgroundExecutor); ShadowLooper.idleMainLooper(); } private static void verifyOnUnbindCausesResult(JobService service, int expectedResult) throws Exception { Job jobSpec = TestUtil.getBuilderWithNoopValidator() .setTag("tag") .setService(service.getClass()) .setTrigger(Trigger.NOW) .build(); Bundle jobSpecData = getJobCoder().encode(jobSpec, new Bundle()); FutureSettingJobCallback callback = new FutureSettingJobCallback(); // start the service IRemoteJobService.Stub.asInterface(service.onBind(null)).start(jobSpecData, callback); // shouldn't have sent a result message yet (still doing background work) assertFalse(callback.getJobFinishedFuture().isDone()); // manually trigger the onUnbind hook service.onUnbind(new Intent()); flush(service); callback.verifyCalledWithJobAndResult(jobSpec, expectedResult); // Calling jobFinished should not attempt to send a second message callback.reset(); service.jobFinished(jobSpec, false); assertFalse(callback.getJobFinishedFuture().isDone()); } private static class FutureSettingJobCallback extends IJobCallback.Stub { SettableFuture<Pair<Bundle, Integer>> jobFinishedFuture = SettableFuture.create(); SettableFuture<Pair<Bundle, Integer>> getJobFinishedFuture() { return jobFinishedFuture; } void reset() { jobFinishedFuture = SettableFuture.create(); } void verifyCalledWithJobAndResult(JobParameters job, int result) throws Exception { Pair<Bundle, Integer> jobFinishedResult = getJobFinishedFuture().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); assertNotNull(jobFinishedResult); JobCoder jc = getJobCoder(); assertEquals( // re-encode so they're the same class jc.decode(jc.encode(job, new Bundle())).build(), jc.decode(jobFinishedResult.first).build()); assertEquals(result, (int) jobFinishedResult.second); } @Override public void jobFinished(Bundle invocationData, @JobService.JobResult int result) { jobFinishedFuture.set(Pair.create(invocationData, result)); } } /** A simple JobService that just counts down the {@link #countDownLatch}. */ public static class ExampleJobService extends JobService { @Override public boolean onStartJob(@NonNull JobParameters job) { countDownLatch.countDown(); return false; } @Override public boolean onStopJob(@NonNull JobParameters job) { return false; } } /** A JobService that allows customizing the onStopJob result. */ public static class StoppableJobService extends JobService { private final boolean shouldReschedule; private final AtomicInteger numberOfStartRequestsReceived = new AtomicInteger(); private final AtomicInteger numberOfStopRequestsReceived = new AtomicInteger(); public StoppableJobService(boolean shouldReschedule) { this.shouldReschedule = shouldReschedule; } @Override public boolean onStartJob(@NonNull JobParameters job) { numberOfStartRequestsReceived.incrementAndGet(); return true; } @Override public boolean onStopJob(@NonNull JobParameters job) { numberOfStopRequestsReceived.incrementAndGet(); return shouldReschedule; } public int getNumberOfStartRequestsReceived() { return numberOfStartRequestsReceived.get(); } public int getNumberOfStopRequestsReceived() { return numberOfStopRequestsReceived.get(); } } }