Add PeriodicAction utility class

Bug: 241591222
Test: included
Change-Id: I3911433c4e1499a9a867f710d6a191c38cea5a96
diff --git a/device-provisioner/BUILD b/device-provisioner/BUILD
index cf01395..a311ad6 100644
--- a/device-provisioner/BUILD
+++ b/device-provisioner/BUILD
@@ -14,5 +14,6 @@
         "//tools/base/adblib-tools:studio.android.sdktools.adblib.tools[module]",
         "//tools/base/sdklib:studio.android.sdktools.sdklib[module]",
         "//tools/adt/idea/.idea/libraries:truth[test]",
+        "//tools/adt/idea/.idea/libraries:jetbrains.kotlinx.coroutines.test[test]",
     ],
 )
diff --git a/device-provisioner/android.sdktools.device-provisioner.iml b/device-provisioner/android.sdktools.device-provisioner.iml
index 9c5ad7d..d5422d4 100644
--- a/device-provisioner/android.sdktools.device-provisioner.iml
+++ b/device-provisioner/android.sdktools.device-provisioner.iml
@@ -13,5 +13,6 @@
     <orderEntry type="module" module-name="android.sdktools.adblib.tools" />
     <orderEntry type="module" module-name="android.sdktools.sdklib" />
     <orderEntry type="library" scope="TEST" name="truth" level="project" />
+    <orderEntry type="library" scope="TEST" name="jetbrains.kotlinx.coroutines.test" level="project" />
   </component>
 </module>
\ No newline at end of file
diff --git a/device-provisioner/src/main/com/android/sdklib/deviceprovisioner/PeriodicAction.kt b/device-provisioner/src/main/com/android/sdklib/deviceprovisioner/PeriodicAction.kt
new file mode 100644
index 0000000..5f204aa
--- /dev/null
+++ b/device-provisioner/src/main/com/android/sdklib/deviceprovisioner/PeriodicAction.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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.android.sdklib.deviceprovisioner
+
+import com.android.annotations.concurrency.GuardedBy
+import java.time.Duration
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+/**
+ * An action that runs periodically, but when needed, we can immediately trigger a refresh. After a
+ * manually-triggered refresh, we wait the usual period before running again.
+ *
+ * If the action throws an exception, it will propagate to the parent scope, and no further actions
+ * will be scheduled.
+ *
+ * If runNow() is invoked while the action is running, the action will be cancelled, and then run
+ * again. Note that cancellation is cooperative, and thus it's possible for the action to still be
+ * running when the next invocation starts.
+ */
+class PeriodicAction(
+  private val scope: CoroutineScope,
+  private val period: Duration,
+  private val action: suspend () -> Unit
+) {
+  private val lock = Object()
+
+  /** The current job, which is set to null when we are shut down. */
+  @GuardedBy("lock") private var job: Job? = Job() // unused non-null job to start
+
+  init {
+    runAfterDelay()
+  }
+
+  private fun updateJob(block: suspend () -> Unit): Job {
+    synchronized(lock) {
+      (job ?: throw CancellationException()).cancel()
+      return scope.launch { block() }.also { job = it }
+    }
+  }
+
+  private fun runAfterDelay(): Job = updateJob {
+    delay(period.toMillis())
+    action()
+    runAfterDelay()
+  }
+
+  fun runNow(): Job = updateJob {
+    action()
+    runAfterDelay()
+  }
+
+  fun cancel() {
+    synchronized(lock) {
+      job?.cancel()
+      job = null
+    }
+  }
+}
diff --git a/device-provisioner/src/test/com/android/sdklib/deviceprovisioner/PeriodicActionTest.kt b/device-provisioner/src/test/com/android/sdklib/deviceprovisioner/PeriodicActionTest.kt
new file mode 100644
index 0000000..9c8a8b4
--- /dev/null
+++ b/device-provisioner/src/test/com/android/sdklib/deviceprovisioner/PeriodicActionTest.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * 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.android.sdklib.deviceprovisioner
+
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.Test
+
+class PeriodicActionTest {
+  @Test
+  fun run() = runBlockingTest {
+    var i = 0
+    val action = PeriodicAction(this, Duration.ofHours(1)) { i++ }
+    advanceTimeBy(Duration.ofHours(5).toMillis() + 1000)
+    assertThat(i).isEqualTo(5)
+    action.cancel()
+  }
+
+  @Test
+  fun runNow() = runBlockingTest {
+    var i = 0
+    val action = PeriodicAction(this, Duration.ofHours(1)) { i++ }
+    advanceTimeBy(Duration.ofMinutes(59).toMillis())
+    action.runNow()
+    assertThat(i).isEqualTo(1)
+    advanceTimeBy(Duration.ofMinutes(3).toMillis())
+    assertThat(i).isEqualTo(1)
+    advanceTimeBy(Duration.ofMinutes(59).toMillis())
+    assertThat(i).isEqualTo(2)
+    action.cancel()
+  }
+
+  @Test
+  fun runNowCancellation() = runBlockingTest {
+    var i = 0
+    val action =
+      PeriodicAction(this, Duration.ofSeconds(1)) {
+        delay(Duration.ofHours(1).toMillis())
+        i++
+      }
+    action.runNow()
+    advanceTimeBy(Duration.ofMinutes(59).toMillis())
+    action.runNow()
+    advanceTimeBy(Duration.ofMinutes(59).toMillis())
+    action.runNow()
+    // Cancellations prevent the action from completing.
+    assertThat(i).isEqualTo(0)
+
+    advanceTimeBy(Duration.ofMinutes(61).toMillis())
+    assertThat(i).isEqualTo(1)
+
+    val job = action.runNow()
+    advanceTimeBy(Duration.ofMinutes(40).toMillis())
+    job.cancel()
+    advanceTimeBy(Duration.ofMinutes(40).toMillis())
+    assertThat(i).isEqualTo(1)
+
+    action.cancel()
+  }
+
+  @Test
+  fun actionThrows() = runBlockingTest {
+    var i = 0
+    var exceptions = 0
+    val exceptionHandler = CoroutineExceptionHandler { ctx, t -> exceptions++ }
+    val action =
+      PeriodicAction(this + exceptionHandler, Duration.ofHours(1)) {
+        if (i % 2 == 1) {
+          throw Exception()
+        }
+        i++
+      }
+    advanceTimeBy(Duration.ofHours(5).toMillis() + 1000)
+    assertThat(i).isEqualTo(1)
+    assertThat(exceptions).isEqualTo(1)
+    action.cancel()
+  }
+}