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()
+ }
+}