From c1486fd70cd75ee29802515b98a17b2630cc529f Mon Sep 17 00:00:00 2001
From: AlexanderAlferov <64849422+AlexanderAlferov@users.noreply.github.com>
Date: Mon, 9 Nov 2020 14:41:17 +0300
Subject: [PATCH]  Deadman Notification if exposure check was not successful
 for 36h (EXPOSUREAPP-2332) (#1513)

* Deadman notification: strings, onetimeworker, time calculation and unit tests

* Periodic deadman notification worker added

* Deadman notification refactor

* Deadman notification refactor
New tests

* Worker refactored to new DI

* Test boilerplate added

* Additional tests

* Clean up

* Added mock providers for deadman notification workers

* Comments and code clean up

* Test clean up

* Schedule work from CoronaWarnApplication

* Variable naming fix

* Switch to foreground state

* Formatting

* Formatting

* Inject Notification Manager Compat

* Change WorkManager initialization, make it impossible to inject the instance without it being initialized.

Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
Co-authored-by: Matthias Urhahn <matthias.urhahn@sap.com>
---
 .../coronawarnapp/CoronaWarnApplication.kt    |  14 ++-
 .../DeadmanNotificationOneTimeWorker.kt       |  44 +++++++
 .../DeadmanNotificationPeriodicWorker.kt      |  46 +++++++
 .../deadman/DeadmanNotificationScheduler.kt   |  59 +++++++++
 .../deadman/DeadmanNotificationSender.kt      |  73 ++++++++++++
 .../DeadmanNotificationTimeCalculation.kt     |  45 +++++++
 .../deadman/DeadmanNotificationWorkBuilder.kt |  43 +++++++
 .../rki/coronawarnapp/ui/main/MainActivity.kt |  10 +-
 .../coronawarnapp/util/di/AndroidModule.kt    |   8 ++
 ...ManagerSetup.kt => WorkManagerProvider.kt} |   9 +-
 .../coronawarnapp/util/worker/WorkerBinder.kt |  16 +++
 .../DeadmanNotificationOneTimeWorkerTest.kt   |  65 ++++++++++
 .../DeadmanNotificationPeriodicWorkerTest.kt  |  65 ++++++++++
 .../DeadmanNotificationSchedulerTest.kt       | 112 ++++++++++++++++++
 .../deadman/DeadmanNotificationSenderTest.kt  |  45 +++++++
 .../DeadmanNotificationTimeCalculationTest.kt | 103 ++++++++++++++++
 .../DeadmanNotificationWorkBuilderTest.kt     |  56 +++++++++
 .../util/worker/WorkerBinderTest.kt           |  10 ++
 18 files changed, 812 insertions(+), 11 deletions(-)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/{WorkManagerSetup.kt => WorkManagerProvider.kt} (74%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt

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 473252103..ebe7c473f 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 000000000..039330a1c
--- /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 000000000..53e5d06ca
--- /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 000000000..d3b1523be
--- /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 000000000..a261454f0
--- /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 000000000..26018e3dd
--- /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 000000000..c574f5d76
--- /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 4b07eea49..efb78a5cb 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 3b8359370..6fdc8c4a0 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 3e7c0ee22..dff4a55e3 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 7bae929f2..99541ed60 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 000000000..3455d8b82
--- /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 000000000..bc53f70fe
--- /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 000000000..26e170849
--- /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 000000000..35bae1704
--- /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 000000000..ae93b2979
--- /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 000000000..d7ca037ee
--- /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 37b956e80..468877bba 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()
 }
-- 
GitLab