diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt index 473252103438e393733130f36590eb7841fb2b36..ebe7c473fc3d6037becb4bcab9981d8fe5fccf6f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt @@ -7,20 +7,22 @@ import android.content.IntentFilter import android.os.Bundle import android.view.WindowManager import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.WorkManager import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler import de.rki.coronawarnapp.exception.reporting.ErrorReportReceiver import de.rki.coronawarnapp.exception.reporting.ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL import de.rki.coronawarnapp.notification.NotificationHelper +import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.ForegroundState import de.rki.coronawarnapp.util.WatchdogService import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.ApplicationComponent -import de.rki.coronawarnapp.util.worker.WorkManagerSetup import de.rki.coronawarnapp.worker.BackgroundWorkHelper import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn @@ -41,7 +43,8 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { @Inject lateinit var watchdogService: WatchdogService @Inject lateinit var taskController: TaskController @Inject lateinit var foregroundState: ForegroundState - @Inject lateinit var workManagerSetup: WorkManagerSetup + @Inject lateinit var workManager: WorkManager + @Inject lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree override fun onCreate() { @@ -54,8 +57,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { Timber.plant(rollingLogHistory) - Timber.v("onCreate(): Initializing WorkManager") - workManagerSetup.setup() + Timber.v("onCreate(): WorkManager setup done: $workManager") NotificationHelper.createNotificationChannel() @@ -73,6 +75,10 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { foregroundState.isInForeground .onEach { isAppInForeground = it } .launchIn(GlobalScope) + + if (LocalData.onboardingCompletedTimestamp() != null) { + deadmanNotificationScheduler.schedulePeriodic() + } } private val activityLifecycleCallback = object : ActivityLifecycleCallbacks { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..039330a1ca998c927852a16cf9d0a0465b072503 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt @@ -0,0 +1,44 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import de.rki.coronawarnapp.worker.BackgroundConstants +import timber.log.Timber + +/** + * One time background deadman notification worker + * + * @see DeadmanNotificationScheduler + */ +class DeadmanNotificationOneTimeWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParams: WorkerParameters, + private val sender: DeadmanNotificationSender +) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + Timber.d("Background job started. Run attempt: $runAttemptCount") + + if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { + Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling") + + return Result.failure() + } + var result = Result.success() + try { + sender.sendNotification() + } catch (e: Exception) { + result = Result.retry() + } + + return result + } + + @AssistedInject.Factory + interface Factory : InjectedWorkerFactory<DeadmanNotificationOneTimeWorker> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..53e5d06ca010c8e45f40be61ab9219f0141d0381 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt @@ -0,0 +1,46 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import de.rki.coronawarnapp.worker.BackgroundConstants +import timber.log.Timber + +/** + * Periodic background deadman notification worker + * + * @see DeadmanNotificationScheduler + */ +class DeadmanNotificationPeriodicWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParams: WorkerParameters, + private val scheduler: DeadmanNotificationScheduler +) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + Timber.d("Background job started. Run attempt: $runAttemptCount") + + if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { + Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling") + + return Result.failure() + } + var result = Result.success() + try { + // Schedule one time deadman notification send work + scheduler.scheduleOneTime() + } catch (e: Exception) { + Timber.d(e) + result = Result.retry() + } + + return result + } + + @AssistedInject.Factory + interface Factory : InjectedWorkerFactory<DeadmanNotificationPeriodicWorker> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt new file mode 100644 index 0000000000000000000000000000000000000000..d3b1523be13e76e842a6536bedfbd18fc4e2bcca --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt @@ -0,0 +1,59 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import dagger.Reusable +import javax.inject.Inject + +@Reusable +class DeadmanNotificationScheduler @Inject constructor( + val timeCalculation: DeadmanNotificationTimeCalculation, + val workManager: WorkManager, + val workBuilder: DeadmanNotificationWorkBuilder +) { + + /** + * Enqueue background deadman notification onetime work + * Replace with new if older work exists. + */ + suspend fun scheduleOneTime() { + // Get initial delay + val delay = timeCalculation.getDelay() + + if (delay < 0) { + return + } else { + // Create unique work and enqueue + workManager.enqueueUniqueWork( + ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + workBuilder.buildOneTimeWork(delay) + ) + } + } + + /** + * Enqueue background deadman notification onetime work + * Replace with new if older work exists. + */ + fun schedulePeriodic() { + // Create unique work and enqueue + workManager.enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + workBuilder.buildPeriodicWork() + ) + } + + companion object { + /** + * Deadman notification one time work + */ + const val ONE_TIME_WORK_NAME = "DeadmanNotificationOneTimeWork" + /** + * Deadman notification periodic work + */ + const val PERIODIC_WORK_NAME = "DeadmanNotificationPeriodicWork" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt new file mode 100644 index 0000000000000000000000000000000000000000..a261454f0bcf45edc674d58e539bc284fdf58edf --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt @@ -0,0 +1,73 @@ +package de.rki.coronawarnapp.deadman + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.Reusable +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.notification.NotificationConstants +import de.rki.coronawarnapp.ui.main.MainActivity +import de.rki.coronawarnapp.util.ForegroundState +import de.rki.coronawarnapp.util.di.AppContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@Reusable +class DeadmanNotificationSender @Inject constructor( + @AppContext private val context: Context, + private val foregroundState: ForegroundState, + private val notificationManagerCompat: NotificationManagerCompat +) { + + private val channelId = + context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID) + + private fun createPendingIntentToMainActivity() = + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + 0 + ) + + private fun buildNotification( + title: String, + content: String + ): Notification? { + val builder = NotificationCompat.Builder(context, + channelId + ) + .setSmallIcon(NotificationConstants.NOTIFICATION_SMALL_ICON) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(createPendingIntentToMainActivity()) + .setAutoCancel(true) + .setContentTitle(title) + .setContentText(content) + + return builder.build() + } + + suspend fun sendNotification() { + if (foregroundState.isInForeground.first()) { + return + } + val title = context.getString(R.string.risk_details_deadman_notification_title) + val content = context.getString(R.string.risk_details_deadman_notification_body) + val notification = + buildNotification(title, content) ?: return + with(notificationManagerCompat) { + notify(DEADMAN_NOTIFICATION_ID, notification) + } + } + + companion object { + /** + * Deadman notification id + */ + const val DEADMAN_NOTIFICATION_ID = 3 + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt new file mode 100644 index 0000000000000000000000000000000000000000..26018e3ddac8285143d3bdd26a795a2cd9a62d84 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.deadman + +import dagger.Reusable +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.util.TimeStamper +import kotlinx.coroutines.flow.first +import org.joda.time.DateTimeConstants +import org.joda.time.Hours +import org.joda.time.Instant +import javax.inject.Inject + +@Reusable +class DeadmanNotificationTimeCalculation @Inject constructor( + val timeStamper: TimeStamper, + val enfClient: ENFClient +) { + + /** + * Calculate initial delay in minutes for deadman notification + */ + fun getHoursDiff(lastSuccess: Instant): Int { + val hoursDiff = Hours.hoursBetween(lastSuccess, timeStamper.nowUTC) + return (DEADMAN_NOTIFICATION_DELAY - hoursDiff.hours) * DateTimeConstants.MINUTES_PER_HOUR + } + + /** + * Get initial delay in minutes for deadman notification + * If last success date time is null (eg: on application first start) - return [DEADMAN_NOTIFICATION_DELAY] + */ + suspend fun getDelay(): Long { + val lastSuccess = enfClient.latestFinishedCalculation().first()?.finishedAt + return if (lastSuccess != null) { + getHoursDiff(lastSuccess).toLong() + } else { + (DEADMAN_NOTIFICATION_DELAY * DateTimeConstants.MINUTES_PER_HOUR).toLong() + } + } + + companion object { + /** + * Deadman notification background job delay set to 36 hours + */ + const val DEADMAN_NOTIFICATION_DELAY = 36 + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt new file mode 100644 index 0000000000000000000000000000000000000000..c574f5d761c3d8a613dc25ad5f235ef29ea9c2ea --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.BackoffPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import dagger.Reusable +import de.rki.coronawarnapp.worker.BackgroundConstants +import org.joda.time.DateTimeConstants +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@Reusable +class DeadmanNotificationWorkBuilder @Inject constructor() { + + fun buildOneTimeWork(delay: Long): OneTimeWorkRequest = + OneTimeWorkRequestBuilder<DeadmanNotificationOneTimeWorker>() + .setInitialDelay( + delay, + TimeUnit.MINUTES + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BackgroundConstants.BACKOFF_INITIAL_DELAY, + TimeUnit.MINUTES + ) + .build() + + fun buildPeriodicWork(): PeriodicWorkRequest = PeriodicWorkRequestBuilder<DeadmanNotificationPeriodicWorker>( + DateTimeConstants.MINUTES_PER_HOUR.toLong(), TimeUnit.MINUTES + ) + .setInitialDelay( + BackgroundConstants.KIND_DELAY, + TimeUnit.MINUTES + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BackgroundConstants.BACKOFF_INITIAL_DELAY, + TimeUnit.MINUTES + ) + .build() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt index 4b07eea491c78a3135f3bd0407a1bc041ebec75e..efb78a5cbf81f040fe8b6cac1c78e64785a86303 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt @@ -15,6 +15,7 @@ import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler import de.rki.coronawarnapp.playbook.BackgroundNoise import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.ui.base.startActivitySafely @@ -63,11 +64,11 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector { private lateinit var settingsViewModel: SettingsViewModel - @Inject - lateinit var backgroundPrioritization: BackgroundPrioritization + @Inject lateinit var backgroundPrioritization: BackgroundPrioritization - @Inject - lateinit var powerManagement: PowerManagement + @Inject lateinit var powerManagement: PowerManagement + + @Inject lateinit var deadmanScheduler: DeadmanNotificationScheduler /** * Register connection callback. @@ -105,6 +106,7 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector { scheduleWork() checkShouldDisplayBackgroundWarning() doBackgroundNoiseCheck() + deadmanScheduler.schedulePeriodic() } private fun doBackgroundNoiseCheck() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt index 3b8359370197e92077899388a9ba931e90a5e0ba..6fdc8c4a0a4137d21f1efb3535cd7cab66cf6cc4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt @@ -4,9 +4,11 @@ import android.app.Application import android.bluetooth.BluetoothAdapter import android.content.Context import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkManager import dagger.Module import dagger.Provides import de.rki.coronawarnapp.CoronaWarnApplication +import de.rki.coronawarnapp.util.worker.WorkManagerProvider import javax.inject.Singleton @Module @@ -30,4 +32,10 @@ class AndroidModule { fun notificationManagerCompat( @AppContext context: Context ): NotificationManagerCompat = NotificationManagerCompat.from(context) + + @Provides + @Singleton + fun workManager( + workManagerProvider: WorkManagerProvider + ): WorkManager = workManagerProvider.workManager } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt similarity index 74% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt index 3e7c0ee22be629676acafc785136f3a64af69f17..dff4a55e38f96608003dd00059d3c9e64e777aa2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt @@ -9,20 +9,23 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class WorkManagerSetup @Inject constructor( +class WorkManagerProvider @Inject constructor( @AppContext private val context: Context, private val cwaWorkerFactory: CWAWorkerFactory ) { - fun setup() { + val workManager by lazy { Timber.v("Setting up WorkManager.") val configuration = Configuration.Builder().apply { setMinimumLoggingLevel(android.util.Log.DEBUG) setWorkerFactory(cwaWorkerFactory) }.build() + Timber.v("WorkManager initialize...") WorkManager.initialize(context, configuration) - Timber.v("WorkManager setup done.") + WorkManager.getInstance(context).also { + Timber.v("WorkManager setup done: %s", it) + } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt index 7bae929f22ca532f2efcb9103cd5407e4a5e6438..99541ed60f1dfc69b41bb3a354af2dc78798d937 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt @@ -4,6 +4,8 @@ import androidx.work.ListenableWorker import dagger.Binds import dagger.Module import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.deadman.DeadmanNotificationOneTimeWorker +import de.rki.coronawarnapp.deadman.DeadmanNotificationPeriodicWorker import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker import de.rki.coronawarnapp.worker.BackgroundNoiseOneTimeWorker import de.rki.coronawarnapp.worker.BackgroundNoisePeriodicWorker @@ -55,4 +57,18 @@ abstract class WorkerBinder { abstract fun testResultRetrievalPeriodic( factory: DiagnosisTestResultRetrievalPeriodicWorker.Factory ): InjectedWorkerFactory<out ListenableWorker> + + @Binds + @IntoMap + @WorkerKey(DeadmanNotificationOneTimeWorker::class) + abstract fun deadmanNotificationOneTime( + factory: DeadmanNotificationOneTimeWorker.Factory + ): InjectedWorkerFactory<out ListenableWorker> + + @Binds + @IntoMap + @WorkerKey(DeadmanNotificationPeriodicWorker::class) + abstract fun deadmanNotificationPeriodic( + factory: DeadmanNotificationPeriodicWorker.Factory + ): InjectedWorkerFactory<out ListenableWorker> } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3455d8b824e70954665f16e270e3caec8b09ce60 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt @@ -0,0 +1,65 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.worker.BackgroundConstants +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationOneTimeWorkerTest : BaseTest() { + + @MockK lateinit var sender: DeadmanNotificationSender + @MockK lateinit var context: Context + @RelaxedMockK lateinit var workerParams: WorkerParameters + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createWorker() = DeadmanNotificationOneTimeWorker( + context = context, + workerParams = workerParams, + sender = sender + ) + + @Test + fun `create worker`() { + createWorker() + } + + @Test + fun `run worker success`() = runBlockingTest { + createWorker().doWork() + + coVerify(exactly = 1) { sender.sendNotification() } + } + + @Test + fun `run worker fail`() = runBlockingTest { + val worker = createWorker() + + worker.runAttemptCount shouldBe 0 + + every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1 + + worker.doWork() + + coVerify(exactly = 0) { sender.sendNotification() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc53f70fe193dcdf601b01cfd131231f57c6f6d1 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt @@ -0,0 +1,65 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.worker.BackgroundConstants +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationPeriodicWorkerTest : BaseTest() { + + @MockK lateinit var scheduler: DeadmanNotificationScheduler + @MockK lateinit var context: Context + @RelaxedMockK lateinit var workerParams: WorkerParameters + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createWorker() = DeadmanNotificationPeriodicWorker( + context = context, + workerParams = workerParams, + scheduler = scheduler + ) + + @Test + fun `create worker`() { + createWorker() + } + + @Test + fun `run worker success`() = runBlockingTest { + createWorker().doWork() + + coVerify(exactly = 1) { scheduler.scheduleOneTime() } + } + + @Test + fun `run worker fail`() = runBlockingTest { + val worker = createWorker() + + worker.runAttemptCount shouldBe 0 + + every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1 + + worker.doWork() + + coVerify(exactly = 0) { scheduler.scheduleOneTime() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..26e1708493bb8f3c56d47141493aba8c3a54384f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt @@ -0,0 +1,112 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.Operation +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import io.mockk.verifySequence +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationSchedulerTest : BaseTest() { + + @MockK lateinit var timeCalculation: DeadmanNotificationTimeCalculation + @MockK lateinit var workManager: WorkManager + @MockK lateinit var operation: Operation + @MockK lateinit var workBuilder: DeadmanNotificationWorkBuilder + @MockK lateinit var periodicWorkRequest: PeriodicWorkRequest + @MockK lateinit var oneTimeWorkRequest: OneTimeWorkRequest + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { workBuilder.buildPeriodicWork() } returns periodicWorkRequest + every { workBuilder.buildOneTimeWork(any()) } returns oneTimeWorkRequest + every { + workManager.enqueueUniquePeriodicWork( + DeadmanNotificationScheduler.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + any() + ) + } returns operation + + every { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } returns operation + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createScheduler() = DeadmanNotificationScheduler( + timeCalculation = timeCalculation, + workManager = workManager, + workBuilder = workBuilder + ) + + @Test + fun `one time work was scheduled`() = runBlockingTest { + coEvery { timeCalculation.getDelay() } returns 10L + + createScheduler().scheduleOneTime() + + verifySequence { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } + } + + @Test + fun `one time work was not scheduled`() = runBlockingTest { + coEvery { timeCalculation.getDelay() } returns -10L + + createScheduler().scheduleOneTime() + + verify(exactly = 0) { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + any(), any(), any() + ) + } + } + + @Test + fun `test periodic work was scheduled`() { + createScheduler().schedulePeriodic() + + verifySequence { + workManager.enqueueUniquePeriodicWork( + DeadmanNotificationScheduler.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + periodicWorkRequest + ) + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..35bae170457dcd40fd29593cfadfc54b91d3f98a --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import de.rki.coronawarnapp.notification.NotificationConstants +import de.rki.coronawarnapp.util.ForegroundState +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationSenderTest : BaseTest() { + + @MockK lateinit var context: Context + @MockK lateinit var foregroundState: ForegroundState + @MockK lateinit var notificationManagerCompat: NotificationManagerCompat + + private val channelId = "de.rki.coronawarnapp.notification.exposureNotificationChannelId" + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID) } returns channelId + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createSender() = DeadmanNotificationSender( + context = context, + foregroundState = foregroundState, + notificationManagerCompat = notificationManagerCompat + ) + + @Test + fun `sender creation`() { + createSender() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ae93b2979d83e914daf427ccd87e237eaba4f20c --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt @@ -0,0 +1,103 @@ +package de.rki.coronawarnapp.deadman + +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationTimeCalculationTest : BaseTest() { + + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var enfClient: ENFClient + @MockK lateinit var mockCalculation: Calculation + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { timeStamper.nowUTC } returns Instant.parse("2020-08-01T23:00:00.000Z") + every { enfClient.latestFinishedCalculation() } returns flowOf(mockCalculation) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createTimeCalculator() = DeadmanNotificationTimeCalculation( + timeStamper = timeStamper, + enfClient = enfClient + ) + + @Test + fun `12 hours difference`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe 720 + } + + @Test + fun `negative time difference`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe -2160 + } + + @Test + fun `success in future case`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T15:00:00.000Z")) shouldBe 2220 + } + + @Test + fun `12 hours delay`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z") + every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe 720 + + verify(exactly = 1) { enfClient.latestFinishedCalculation() } + } + + @Test + fun `negative delay`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z") + every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe -2160 + + verify(exactly = 1) { enfClient.latestFinishedCalculation() } + } + + @Test + fun `success in future delay`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") + every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe 2220 + + verify(exactly = 1) { enfClient.latestFinishedCalculation() } + } + + @Test + fun `initial delay - no successful calculations yet`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") + every { enfClient.latestFinishedCalculation() } returns flowOf(null) + + createTimeCalculator().getDelay() shouldBe 2160 + + verify(exactly = 1) { enfClient.latestFinishedCalculation() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7ca037ee3c5ea6416020ae6dd9c7a035e8c71b6 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt @@ -0,0 +1,56 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.BackoffPolicy +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationWorkBuilderTest : BaseTest() { + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `onetime work test`() { + testOneTimeWork(10L) + testOneTimeWork(-10L) + testOneTimeWork(0) + } + + /** + * Delay time in minutes + * Backoff delay 8 minutes + */ + private fun testOneTimeWork(delay: Long) { + val periodicWork = DeadmanNotificationWorkBuilder().buildOneTimeWork(delay) + + periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL + periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000 + periodicWork.workSpec.initialDelay shouldBe delay * 60 * 1000 + } + + /** + * Delay time in minutes + * Backoff delay 8 minutes + * Interval duration 1 hour + */ + @Test + fun `periodic work test`() { + val periodicWork = DeadmanNotificationWorkBuilder().buildPeriodicWork() + + periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL + periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000 + periodicWork.workSpec.intervalDuration shouldBe 60 * 60 * 1000 + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt index 37b956e809b2c776896f4f06f37b3f45952c08c4..468877bbaaa72b0dfc9f3177073a15d4b6e7024c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt @@ -4,6 +4,8 @@ import androidx.work.ListenableWorker import dagger.Component import dagger.Module import dagger.Provides +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler +import de.rki.coronawarnapp.deadman.DeadmanNotificationSender import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.util.di.AssistedInjectModule @@ -75,6 +77,14 @@ class MockProvider { @Provides fun playbook(): Playbook = mockk() + // For DeadmanNotificationScheduler + @Provides + fun sender(): DeadmanNotificationSender = mockk() + + // For DeadmanNotificationPeriodicWorker + @Provides + fun scheduler(): DeadmanNotificationScheduler = mockk() + @Provides fun taskController(): TaskController = mockk() }