diff --git a/ballast-scheduler/build.gradle.kts b/ballast-scheduler/build.gradle.kts index 882b6cd3..81c34229 100644 --- a/ballast-scheduler/build.gradle.kts +++ b/ballast-scheduler/build.gradle.kts @@ -25,7 +25,9 @@ kotlin { dependencies { } } val androidMain by getting { - dependencies { } + dependencies { + implementation("androidx.work:work-runtime-ktx:2.8.1") + } } val jsMain by getting { dependencies { } diff --git a/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleDispatcher.kt b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleDispatcher.kt new file mode 100644 index 00000000..9422fc57 --- /dev/null +++ b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleDispatcher.kt @@ -0,0 +1,139 @@ +package com.copperleaf.ballast.scheduler.workmanager + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import com.copperleaf.ballast.scheduler.SchedulerAdapter +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleWorker.Companion.DATA_ADAPTER_CLASS +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleWorker.Companion.DATA_INITIAL_INSTANT +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleWorker.Companion.DATA_LATEST_INSTANT +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleWorker.Companion.SCHEDULE_NAME_PREFIX +import kotlinx.coroutines.coroutineScope +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +/** + * This is a WorkManager job which takes a [SchedulerAdapter] and creates new WorkManager tasks for each registered + * schedule. Those are represented by OneTimeJobs which schedule their next execution moment after each subsequent + * execution. + * + * The newly scheduled jobs are instances of [BallastWorkManagerScheduleWorker] which run the current execution, and + * then schedule the next execution. + */ +@Suppress("UNCHECKED_CAST") +public open class BallastWorkManagerScheduleDispatcher( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + protected open fun getAdapter(adapterClassName: String): SchedulerAdapter<*, *, *> { + return (Class.forName(adapterClassName) as Class>) + .getConstructor() + .newInstance() + } + + final override suspend fun doWork(): Result = coroutineScope { + Log.d("BallastWorkManager", "Handling work dispatch") + val workManager = WorkManager.getInstance(applicationContext) + + val adapterClassName = inputData.getString(DATA_ADAPTER_CLASS)!! + val initialInstant = Instant.fromEpochMilliseconds(inputData.getLong(DATA_INITIAL_INSTANT, 0)) + val latestInstant = Instant.fromEpochMilliseconds(inputData.getLong(DATA_LATEST_INSTANT, 0)) + + // run the adapter to get the schedules which should run + val adapter: SchedulerAdapter<*, *, *> = getAdapter(adapterClassName) + val schedules = adapter.getRegisteredSchedules() + + // Make sure each registered schedule is set up + schedules.forEach { schedule -> + BallastWorkManagerScheduleWorker.setupSchedule( + workManager = workManager, + adapter = adapter, + registeredSchedule = schedule, + initialInstant = initialInstant, + latestInstant = latestInstant, + ) + } + + // remove schedules which are not part of the current adapter's schedule + try { + coroutineScope { + val scheduledJobTags = listOf("ballast", "schedule", adapterClassName) + val orphanedJobsForThisAdapter = workManager + .getWorkInfosByTag("ballast") + .await() + .filter { + // get the WorkManager jobs which were created from this adapter + it.tags.containsAll(scheduledJobTags) + } + .map { + // the remaining tag should be the schedule's key, which + tags.single { it.startsWith(SCHEDULE_NAME_PREFIX) }.removePrefix(SCHEDULE_NAME_PREFIX) + } + .filter { scheduleName -> + // get the WorkManager jobs which were created from this adapter, but are not part of the + // currently-scheduled jobs + schedules.none { it.key == scheduleName } + } + + // cancel those jobs + orphanedJobsForThisAdapter.forEach { workName -> + Log.d("BallastWorkManager", "Cancelling orphaned work schedule at '$workName'") + workManager.cancelUniqueWork(workName) + } + } + } catch (e: Exception) { + // ignore + } + + Result.success() + } + + public companion object { + + /** + * Schedule BallastWorkManagerScheduleDispatcher to run immediately as a unique job, replacing any existing + * jobs. The job is keyed off the [adapter]'s filly-qualified class name. + * + * BallastWorkManagerScheduleDispatcher will make sure schedules are configured for all the scheduled registered + * in the adapter. If a schedule does not exist, it will create it. If a registered schedule is already part of + * WorkManager, it will leave it alone, since the schedule job will do the work to enqueue each successive + * invocation. If there are enqueued jobs at keys which are not in the Adapter's registered schedules, those + * will be cancelled, as it will be assumed they were configured in a previous release, but removed in the + * current version. + */ + public fun scheduleWork( + workManager: WorkManager, + adapter: T, + ) where T : SchedulerAdapter, T : Function1 { + Log.d("BallastWorkManager", "Scheduling work dispatch") + val instant = Clock.System.now() + val adapterClassName = adapter.javaClassName + + workManager + .beginUniqueWork( + /* uniqueWorkName = */ adapterClassName, + /* existingWorkPolicy = */ ExistingWorkPolicy.REPLACE, + /* work = */ OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(DATA_ADAPTER_CLASS, adapterClassName) + .putLong(DATA_INITIAL_INSTANT, instant.toEpochMilliseconds()) + .putLong(DATA_LATEST_INSTANT, instant.toEpochMilliseconds()) + .build() + ) + .addTag("ballast") + .addTag("dispatcher") + .addTag(adapterClassName) + .build() + ) + .enqueue() + } + } +} diff --git a/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleWorker.kt b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleWorker.kt new file mode 100644 index 00000000..e1b385d0 --- /dev/null +++ b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/BallastWorkManagerScheduleWorker.kt @@ -0,0 +1,225 @@ +package com.copperleaf.ballast.scheduler.workmanager + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.WorkerParameters +import androidx.work.await +import com.copperleaf.ballast.scheduler.SchedulerAdapter +import com.copperleaf.ballast.scheduler.executor.ScheduleExecutor +import com.copperleaf.ballast.scheduler.internal.RegisteredSchedule +import com.copperleaf.ballast.scheduler.schedule.dropHistory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.time.toJavaDuration + +/** + * This is a WorkManager job which executes on each tick of the registered schedule, then enqueues the next Instant + * that the job should rerun. It also is responsible for accessing the target VM that the Input should be sent to on + * each task tick. + */ +public class BallastWorkManagerScheduleWorker( + context: Context, + workerParams: WorkerParameters +) : CoroutineWorker(context, workerParams) { + + final override suspend fun doWork(): Result = coroutineScope { + val workManager = WorkManager.getInstance(applicationContext) + val adapterClassQualifiedName: String = inputData.getString(DATA_ADAPTER_CLASS)!! + val scheduleKey: String = inputData.getString(DATA_KEY)!! + val adapterClass: Class> = + Class.forName(adapterClassQualifiedName) as Class> + val adapter: SchedulerAdapter<*, *, *> = adapterClass.getConstructor().newInstance() + val registeredSchedule: RegisteredSchedule<*, *, *> = + adapter.getRegisteredSchedules().single { it.key == scheduleKey } + + Log.d("BallastWorkManager", "running periodic job at '${registeredSchedule.key}'") + val initialInstant = Instant.fromEpochMilliseconds(inputData.getLong(DATA_INITIAL_INSTANT, 0)) + val latestInstant = Instant.fromEpochMilliseconds(inputData.getLong(DATA_LATEST_INSTANT, 0)) + + when (registeredSchedule.delayMode) { + ScheduleExecutor.DelayMode.FireAndForget -> { + BallastWorkManagerScheduleWorker.scheduleNextInvocation( + workManager = workManager, + adapter = adapter, + registeredSchedule = registeredSchedule, + initialInstant = initialInstant, + latestInstant = latestInstant, + ) + dispatchWork( + adapter = adapter, + registeredSchedule = registeredSchedule, + deferred = null, + ) + } + + ScheduleExecutor.DelayMode.Suspend -> { + val deferred = CompletableDeferred() + dispatchWork( + adapter = adapter, + registeredSchedule = registeredSchedule, + deferred = deferred, + ) + deferred.await() + BallastWorkManagerScheduleWorker.scheduleNextInvocation( + workManager = workManager, + adapter = adapter, + registeredSchedule = registeredSchedule, + initialInstant = initialInstant, + latestInstant = latestInstant, + ) + } + } + + Result.success() + } + + private suspend fun dispatchWork( + adapter: SchedulerAdapter<*, *, *>, + registeredSchedule: RegisteredSchedule<*, *, *>, + deferred: CompletableDeferred? + ) { + check(adapter is Function1<*, *>) { + "adapter must be Function1" + } + + invokeWith( + adapter, + registeredSchedule.scheduledInput() + ) + + deferred?.complete(Unit) + } + + private suspend fun invokeWith( + fn: Function1, + input: Any + ) { + withContext(Dispatchers.Main) { + fn.invoke(input as P1) + } + } + + public companion object { + public const val DATA_ADAPTER_CLASS: String = "DATA_ADAPTER_CLASS" + public const val DATA_KEY: String = "DATA_KEY" + public const val DATA_INITIAL_INSTANT: String = "DATA_INITIAL_INSTANT" + public const val DATA_LATEST_INSTANT: String = "DATA_LATEST_INSTANT" + public const val SCHEDULE_NAME_PREFIX: String = "ballast_schedule_key_" + + internal suspend fun setupSchedule( + workManager: WorkManager, + adapter: SchedulerAdapter<*, *, *>, + registeredSchedule: RegisteredSchedule<*, *, *>, + initialInstant: Instant, + latestInstant: Instant, + ) { + try { + val workInfo = workManager + .getWorkInfos(WorkQuery.fromUniqueWorkNames(listOf(registeredSchedule.key))) + .await() + if (workInfo.isNotEmpty()) { + // if there is already a scheduled task at this key, just return + return + } + } catch (e: Exception) { + // ignore + } + + Log.d("BallastWorkManager", "Creating periodic work schedule at '${registeredSchedule.key}'") + val nextInstant: Instant? = registeredSchedule.schedule + .dropHistory(initialInstant, latestInstant) + .firstOrNull() + if (nextInstant == null) { + // schedule has completed, don't schedule another task + Log.d("BallastWorkManager", "periodic work at '${registeredSchedule.key}' completed") + return + } + + val delayAmount = nextInstant - Clock.System.now() + val adapterClassName = adapter.javaClassName + + Log.d( + "BallastWorkManager", + "Scheduling next periodic work at '${registeredSchedule.key}' (to trigger at in $delayAmount at $nextInstant)" + ) + workManager + .beginUniqueWork( + /* uniqueWorkName = */ registeredSchedule.key, + /* existingWorkPolicy = */ ExistingWorkPolicy.REPLACE, + /* work = */ OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(DATA_ADAPTER_CLASS, adapterClassName) + .putString(DATA_KEY, registeredSchedule.key) + .putLong(DATA_INITIAL_INSTANT, initialInstant.toEpochMilliseconds()) + .putLong(DATA_LATEST_INSTANT, nextInstant.toEpochMilliseconds()) + .build() + ) + .addTag("ballast") + .addTag("schedule") + .addTag(adapterClassName) + .addTag("$SCHEDULE_NAME_PREFIX${registeredSchedule.key}") + .setInitialDelay(delayAmount.toJavaDuration()) + .build() + ) + .enqueue() + } + + internal fun scheduleNextInvocation( + workManager: WorkManager, + adapter: SchedulerAdapter<*, *, *>, + registeredSchedule: RegisteredSchedule<*, *, *>, + initialInstant: Instant, + latestInstant: Instant, + ) { + Log.d("BallastWorkManager", "Scheduling periodic work at '${registeredSchedule.key}'") + val nextInstant: Instant? = registeredSchedule.schedule + .dropHistory(initialInstant, latestInstant) + .firstOrNull() + if (nextInstant == null) { + // schedule has completed, don't schedule another task + Log.d("BallastWorkManager", "periodic work at '${registeredSchedule.key}' completed") + return + } + + val delayAmount = nextInstant - Clock.System.now() + val adapterClassName = adapter.javaClassName + + Log.d( + "BallastWorkManager", + "Scheduling next periodic work at '${registeredSchedule.key}' (to trigger at in $delayAmount at $nextInstant)" + ) + workManager + .beginUniqueWork( + /* uniqueWorkName = */ registeredSchedule.key, + /* existingWorkPolicy = */ ExistingWorkPolicy.REPLACE, + /* work = */ OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(DATA_ADAPTER_CLASS, adapterClassName) + .putString(DATA_KEY, registeredSchedule.key) + .putLong(DATA_INITIAL_INSTANT, initialInstant.toEpochMilliseconds()) + .putLong(DATA_LATEST_INSTANT, nextInstant.toEpochMilliseconds()) + .build() + ) + .addTag("ballast") + .addTag("schedule") + .addTag(adapterClassName) + .addTag("$SCHEDULE_NAME_PREFIX${registeredSchedule.key}") + .setInitialDelay(delayAmount.toJavaDuration()) + .build() + ) + .enqueue() + } + } +} diff --git a/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/utils.kt b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/utils.kt new file mode 100644 index 00000000..903109fd --- /dev/null +++ b/ballast-scheduler/src/androidMain/kotlin/com/copperleaf/ballast/scheduler/workmanager/utils.kt @@ -0,0 +1,21 @@ +package com.copperleaf.ballast.scheduler.workmanager + +import com.copperleaf.ballast.scheduler.SchedulerAdapter +import com.copperleaf.ballast.scheduler.internal.RegisteredSchedule +import com.copperleaf.ballast.scheduler.internal.SchedulerAdapterScopeImpl + + +internal fun SchedulerAdapter.getRegisteredSchedules() + : List> { + val adapter = this + val adapterScope = SchedulerAdapterScopeImpl() + + with(adapter) { + adapterScope.configureSchedules() + } + + // cancel any running schedules which have the same keys as the newly requested schedules + return adapterScope.schedules +} + +internal val T.javaClassName: String get() = this::class.java.name diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt index f1fc73cc..549143f5 100644 --- a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/SchedulerController.kt @@ -3,8 +3,8 @@ package com.copperleaf.ballast.scheduler import com.copperleaf.ballast.BallastViewModel import com.copperleaf.ballast.BallastViewModelConfiguration import com.copperleaf.ballast.SideJobScope -import com.copperleaf.ballast.scheduler.executor.ClockScheduleExecutor -import com.copperleaf.ballast.scheduler.executor.ScheduleExecutor +import com.copperleaf.ballast.scheduler.executor.CoroutineClockScheduleExecutor +import com.copperleaf.ballast.scheduler.executor.CoroutineScheduleExecutor import com.copperleaf.ballast.scheduler.vm.SchedulerContract import com.copperleaf.ballast.scheduler.vm.SchedulerFifoInputStrategy import com.copperleaf.ballast.scheduler.vm.SchedulerInputHandler @@ -18,7 +18,7 @@ public typealias SchedulerController = BallastViewModel< public fun BallastViewModelConfiguration.Builder.withSchedulerController( clock: Clock = Clock.System, - scheduleExecutor: ScheduleExecutor = ClockScheduleExecutor(clock), + scheduleExecutor: CoroutineScheduleExecutor = CoroutineClockScheduleExecutor(clock), ): BallastViewModelConfiguration.TypedBuilder< SchedulerContract.Inputs, SchedulerContract.Events, diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ClockScheduleExecutor.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineClockScheduleExecutor.kt similarity index 97% rename from ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ClockScheduleExecutor.kt rename to ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineClockScheduleExecutor.kt index abad3f3d..e3f54293 100644 --- a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ClockScheduleExecutor.kt +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineClockScheduleExecutor.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.delay import kotlinx.datetime.Clock import kotlinx.datetime.Instant -public class ClockScheduleExecutor( +public class CoroutineClockScheduleExecutor( private val clock: Clock = Clock.System, -) : ScheduleExecutor { +) : CoroutineScheduleExecutor { override suspend fun runSchedule( schedule: Schedule, diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineScheduleExecutor.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineScheduleExecutor.kt new file mode 100644 index 00000000..e4696955 --- /dev/null +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/CoroutineScheduleExecutor.kt @@ -0,0 +1,27 @@ +package com.copperleaf.ballast.scheduler.executor + +import com.copperleaf.ballast.scheduler.schedule.Schedule +import kotlinx.coroutines.CompletableDeferred +import kotlinx.datetime.Instant + +public interface CoroutineScheduleExecutor : ScheduleExecutor { + + /** + * Execute a [schedule] at the specified interval. The schedule will continue to run as long as the coroutine is + * alive, and this method may suspend indefinitely if the schedule sequence is infinite. + * + * On each iteration of the schedule, [enqueueTask] will be called if [predicate] is true. In the case that the + * scheduled task is allowed to process, the iteration will track the time to the next iteration according to the + * [delayMode]. In [DelayMode.FireAndForget], the next iteration is considered from the moment that the [enqueueTask] is + * called, and the deferred passed to [enqueueTask] will be null. In [DelayMode.Suspend], a deferred will be passed + * to [enqueueTask] to indicate that it should wait for the entire block to finish processing, and then complete + * the deferred. + */ + public suspend fun runSchedule( + schedule: Schedule, + delayMode: ScheduleExecutor.DelayMode = ScheduleExecutor.DelayMode.FireAndForget, + shouldHandleTask: suspend () -> Boolean = { true }, + onTaskDropped: (Instant) -> Unit = { }, + enqueueTask: (Instant, CompletableDeferred?) -> Unit, + ) +} diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ScheduleExecutor.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ScheduleExecutor.kt index 683451da..b1eab22d 100644 --- a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ScheduleExecutor.kt +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/executor/ScheduleExecutor.kt @@ -6,25 +6,6 @@ import kotlinx.datetime.Instant public interface ScheduleExecutor { - /** - * Execute a [schedule] at the specified interval. The schedule will continue to run as long as the coroutine is - * alive, and this method may suspend indefinitely if the schedule sequence is infinite. - * - * On each iteration of the schedule, [enqueueTask] will be called if [predicate] is true. In the case that the - * scheduled task is allowed to process, the iteration will track the time to the next iteration according to the - * [delayMode]. In [DelayMode.FireAndForget], the next iteration is considered from the moment that the [enqueueTask] is - * called, and the deferred passed to [enqueueTask] will be null. In [DelayMode.Suspend], a deferred will be passed - * to [enqueueTask] to indicate that it should wait for the entire block to finish processing, and then complete - * the deferred. - */ - public suspend fun runSchedule( - schedule: Schedule, - delayMode: DelayMode = DelayMode.FireAndForget, - shouldHandleTask: suspend () -> Boolean = { true }, - onTaskDropped: (Instant) -> Unit = { }, - enqueueTask: (Instant, CompletableDeferred?) -> Unit, - ) - public enum class DelayMode { FireAndForget, Suspend, diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt index 428d51e1..52cfc872 100644 --- a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/schedule/scheduleUtils.kt @@ -4,7 +4,10 @@ import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.time.Duration -public inline fun Schedule.transform(crossinline block: (Sequence) -> Sequence): Schedule { +// Basic schedule transformation functions +// --------------------------------------------------------------------------------------------------------------------- + +public inline fun Schedule.transformSchedule(crossinline block: (Sequence) -> Sequence): Schedule { val scheduleDelegate = this return Schedule { start -> scheduleDelegate @@ -13,12 +16,26 @@ public inline fun Schedule.transform(crossinline block: (Sequence) -> S } } - -public fun Schedule.adaptive(clock: Clock = Clock.System): Schedule { +public inline fun Schedule.transformScheduleStart(crossinline block: (Instant) -> Instant): Schedule { val scheduleDelegate = this return Schedule { start -> + scheduleDelegate + .generateSchedule(block(start)) + } +} + +// Configure a schedule with additional behavior +// --------------------------------------------------------------------------------------------------------------------- + +/** + * For a [FixedDelaySchedule], make the subsequent items delayed by the amount of time it takes to process them, rather + * than always generating a fixed interval. THis adapts the sequence such that there if a fixed amount of time between + * the end of one task and the start of another. + */ +public fun Schedule.adaptive(clock: Clock = Clock.System): Schedule { + return transformSchedule { scheduleSequence -> sequence { - val iterator = scheduleDelegate.generateSchedule(start).iterator() + val iterator = scheduleSequence.iterator() var current = iterator.next() yield(current) @@ -36,34 +53,38 @@ public fun Schedule.adaptive(clock: Clock = Clock.System): Schedule { } } - +/** + * Delay the first emission of a Schedule by a fixed [delay]. + */ public fun Schedule.delayed(delay: Duration): Schedule { - val scheduleDelegate = this - return Schedule { start -> - scheduleDelegate - .generateSchedule(start + delay) + return transformScheduleStart { start -> + start + delay } } +/** + * Delay the first emission of a Schedule until a specific [startInstant]. If the schedule was started with an Instant + * that is later than [startInstant], that later Instant will be used instead, since it is still after [startInstant]. + */ public fun Schedule.delayedUntil(startInstant: Instant): Schedule { - val scheduleDelegate = this - return Schedule { start -> - val laterStart = maxOf(start, startInstant) - - scheduleDelegate - .generateSchedule(laterStart) + return transformScheduleStart { start -> + maxOf(start, startInstant) } } +/** + * Only process scheduled tasks which are within the bounds (inclusive) of the [validRange]. Instants emitted before the + * start of the range will be ignored, and the first Instant emitted after the end of the range will terminate the + * sequence, making it finite. + */ public fun Schedule.bounded(validRange: ClosedRange): Schedule { check(!validRange.isEmpty()) { "the valid range of dates cannot be empty" } - val scheduleDelegate = this - return Schedule { start -> + return transformSchedule { scheduleSequence -> sequence { - val iterator = scheduleDelegate.generateSchedule(start).iterator() + val iterator = scheduleSequence.iterator() while (iterator.hasNext()) { val next = iterator.next() @@ -97,27 +118,46 @@ public fun Schedule.bounded(validRange: ClosedRange): Schedule { } public fun Schedule.until(endInclusive: Instant): Schedule { - val scheduleDelegate = this - return Schedule { start -> - scheduleDelegate - .generateSchedule(start) - .takeWhile { it <= endInclusive } + return transformSchedule { scheduleSequence -> + scheduleSequence.takeWhile { it <= endInclusive } } } public fun Schedule.take(n: Int): Schedule { - val scheduleDelegate = this - return Schedule { start -> - scheduleDelegate - .generateSchedule(start) - .take(n) + return transformSchedule { scheduleSequence -> + scheduleSequence.take(n) } } +// Get values from a schedule +// --------------------------------------------------------------------------------------------------------------------- + +/** + * Using the default system Clock, get the schedule's nearest instant later than `clock.now()` + */ public fun Schedule.getNext(clock: Clock = Clock.System): Instant? { return this.getNext(clock.now()) } +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ public fun Schedule.getNext(instant: Instant): Instant? { return this.generateSchedule(instant).firstOrNull() } + +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.getHistory(startInstant: Instant, currentInstant: Instant): Sequence { + return this.generateSchedule(startInstant) + .takeWhile { it < currentInstant } +} + +/** + * Using a specified start Instant, get the schedule's nearest instant later than `clock.now()` + */ +public fun Schedule.dropHistory(startInstant: Instant, currentInstant: Instant): Sequence { + return this.generateSchedule(startInstant) + .filter { it > currentInstant } +} diff --git a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt index 273233a2..4d3772c2 100644 --- a/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt +++ b/ballast-scheduler/src/commonMain/kotlin/com/copperleaf/ballast/scheduler/vm/SchedulerInputHandler.kt @@ -4,13 +4,13 @@ import com.copperleaf.ballast.InputHandler import com.copperleaf.ballast.InputHandlerScope import com.copperleaf.ballast.Queued import com.copperleaf.ballast.internal.restartableJob -import com.copperleaf.ballast.scheduler.executor.ScheduleExecutor +import com.copperleaf.ballast.scheduler.executor.CoroutineScheduleExecutor import com.copperleaf.ballast.scheduler.internal.SchedulerAdapterScopeImpl import kotlinx.datetime.Clock internal class SchedulerInputHandler( private val clock: Clock, - private val scheduleExecutor: ScheduleExecutor, + private val scheduleExecutor: CoroutineScheduleExecutor, ) : InputHandler< SchedulerContract.Inputs, SchedulerContract.Events, diff --git a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/AdaptiveClockScheduleExecutorTest.kt b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/AdaptiveClockScheduleExecutorTest.kt index 586f5e65..e8231fa8 100644 --- a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/AdaptiveClockScheduleExecutorTest.kt +++ b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/AdaptiveClockScheduleExecutorTest.kt @@ -1,6 +1,6 @@ package com.copperleaf.ballast.scheduler.schedule -import com.copperleaf.ballast.scheduler.executor.ClockScheduleExecutor +import com.copperleaf.ballast.scheduler.executor.CoroutineClockScheduleExecutor import com.copperleaf.ballast.scheduler.executor.ScheduleExecutor import io.kotest.core.spec.style.FunSpec import io.kotest.core.test.testCoroutineScheduler @@ -30,7 +30,7 @@ class AdaptiveClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .adaptive(testClock) .take(4) @@ -65,7 +65,7 @@ class AdaptiveClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .adaptive(testClock) .take(4) @@ -109,7 +109,7 @@ class AdaptiveClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .adaptive(testClock) .take(4) diff --git a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedClockScheduleExecutorTest.kt b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedClockScheduleExecutorTest.kt index 80234b29..66d93362 100644 --- a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedClockScheduleExecutorTest.kt +++ b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/FixedClockScheduleExecutorTest.kt @@ -1,6 +1,6 @@ package com.copperleaf.ballast.scheduler.schedule -import com.copperleaf.ballast.scheduler.executor.ClockScheduleExecutor +import com.copperleaf.ballast.scheduler.executor.CoroutineClockScheduleExecutor import com.copperleaf.ballast.scheduler.executor.ScheduleExecutor import io.kotest.core.spec.style.FunSpec import io.kotest.core.test.testCoroutineScheduler @@ -30,7 +30,7 @@ class FixedClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .take(4) val droppedTasks = mutableListOf() @@ -64,7 +64,7 @@ class FixedClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .take(4) val droppedTasks = mutableListOf() @@ -107,7 +107,7 @@ class FixedClockScheduleExecutorTest : FunSpec({ return Instant.fromEpochMilliseconds(testCoroutineScheduler.currentTime) } } - val executor = ClockScheduleExecutor(testClock) + val executor = CoroutineClockScheduleExecutor(testClock) val schedule = FixedDelaySchedule(10.minutes) .take(4) val droppedTasks = mutableListOf() diff --git a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/ScheduleTest.kt b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/ScheduleTest.kt index 8e0339d2..fea18855 100644 --- a/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/ScheduleTest.kt +++ b/ballast-scheduler/src/commonTest/kotlin/com/copperleaf/ballast/scheduler/schedule/ScheduleTest.kt @@ -361,4 +361,69 @@ class ScheduleTest : StringSpec({ startDay.atTime(0, 0, 5).toInstant(timeZone) } + "schedule.getHistory() unbounded test" { + val scheduleSequence = EveryMinuteSchedule(12) + .getHistory( + startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone), + currentInstant = startDay.atTime(2, 44, 0).toInstant(timeZone), + ) + .toList() + + scheduleSequence shouldBe listOf( + startDay.atTime(2, 37, 12).toInstant(timeZone), + startDay.atTime(2, 38, 12).toInstant(timeZone), + startDay.atTime(2, 39, 12).toInstant(timeZone), + startDay.atTime(2, 40, 12).toInstant(timeZone), + startDay.atTime(2, 41, 12).toInstant(timeZone), + startDay.atTime(2, 42, 12).toInstant(timeZone), + startDay.atTime(2, 43, 12).toInstant(timeZone), + ) + } + + "schedule.getHistory() bounded test" { + val scheduleSequence = EveryMinuteSchedule(12) + .take(3) + .getHistory( + startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone), + currentInstant = startDay.atTime(2, 44, 0).toInstant(timeZone), + ) + .toList() + + scheduleSequence shouldBe listOf( + startDay.atTime(2, 37, 12).toInstant(timeZone), + startDay.atTime(2, 38, 12).toInstant(timeZone), + startDay.atTime(2, 39, 12).toInstant(timeZone), + ) + } + + "schedule.dropHistory() unbounded test" { + val scheduleSequence = EveryMinuteSchedule(12) + .dropHistory( + startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone), + currentInstant = startDay.atTime(2, 44, 0).toInstant(timeZone), + ) + .take(4) + .toList() + + scheduleSequence shouldBe listOf( + startDay.atTime(2, 44, 12).toInstant(timeZone), + startDay.atTime(2, 45, 12).toInstant(timeZone), + startDay.atTime(2, 46, 12).toInstant(timeZone), + startDay.atTime(2, 47, 12).toInstant(timeZone), + ) + } + + "schedule.dropHistory() bounded test" { + val scheduleSequence = EveryMinuteSchedule(12) + .take(3) + .dropHistory( + startInstant = startDay.atTime(2, 37, 0).toInstant(timeZone), + currentInstant = startDay.atTime(2, 44, 0).toInstant(timeZone), + ) + .take(4) + .toList() + + scheduleSequence.shouldBeEmpty() + } + }) diff --git a/docs/src/orchid/resources/wiki/modules/ballast-scheduler.md b/docs/src/orchid/resources/wiki/modules/ballast-scheduler.md index 549c0765..2e693813 100644 --- a/docs/src/orchid/resources/wiki/modules/ballast-scheduler.md +++ b/docs/src/orchid/resources/wiki/modules/ballast-scheduler.md @@ -5,21 +5,52 @@ ## Overview -Ballast Scheduler is a simple way to run non-persistent periodic work, similar to [Spring @Scheduled][1] or the -[Java Timer], by dispatching an Input to one of your ViewModels on a configurable schedule. It is designed to be used in -long-lived processes, like on a backend server, or to run in the background of an active application. It is based on -[Kotlinx Datetime][4] for it date/time calculations, and works on all supported KMP targets. +Ballast Scheduler is still a work in progress. Any features/APIs described here might change at any time. -Ballast Scheduler does not currently support persistent work (such as restarting a schedule after a device restart), -though a future improvement would enable schedules to run on [Android WorkManager][3] or other platform-dependent -mechanisms for cross-platform persistent scheduling. +Ballast Scheduler is a simple way to run periodic work, similar to [Spring @Scheduled][1] or the [Java Timer][2], by +dispatching an Input to one of your ViewModels on a configurable schedule. It supports both non-persistent work on all +platforms by being embedded into an existing ViewModel and running purely on coroutines, and also experimental support +for persistent work by running on [Android WorkManager][3]. ## Basic Usage -Ballast Scheduler is installed into an existing Ballast ViewModel as an Interceptor. By sending an instance of -`SchedulerAdapter` to the Interceptor, you can start register a scheduled task. `SchedulerAdapter` is a `fun interface`, -so it can be passed to the `SchedulerInterceptor` as a lambda, and within the lambda you may register multiple -Schedules. +### Schedule Adapter + +To start, we need to define our scheduled work, which is done by creating an instance of `ScheduleAdapter`. Within the +adapter, we can set up one or more schedules to generate a sequence of Instants which should handle a specific type of +Input. + +A basic adapter looks like this: + +```kotlin +public class BallastSchedulerExampleAdapter : SchedulerAdapter< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State> { + override fun SchedulerAdapterScope< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>.configureSchedules() { + onSchedule( + key = "Every 30 Minutes", + schedule = EveryHourSchedule(0, 30), + scheduledInput = { SchedulerExampleContract.Inputs.Increment(1) }, + ) + onSchedule( + key = "Daily at 2am", + schedule = EveryDaySchedule(LocalTime(2, 0)), + scheduledInput = { ExampleContract.Inputs.Increment(1) }, + ) + } +} +``` + +### Embedded Scheduler + +An Embedded Scheduler is installed into an existing Ballast ViewModel as an Interceptor. By sending an instance of +`SchedulerAdapter` to the Interceptor, you can start register a scheduled task. `SchedulerAdapter` is a `fun interface`, +so it can be passed to the `SchedulerInterceptor` as a lambda, and within the lambda you may register multiple +Schedules. ```kotlin val vm = BasicViewModel( @@ -31,13 +62,21 @@ val vm = BasicViewModel( name = "Example" ) .apply { + // pass an Adapter class instance + this += SchedulerInterceptor(BallastSchedulerExampleAdapter()) + + // or set up the schedules as a lambda this += SchedulerInterceptor { + onSchedule( + key = "Every 30 Minutes", + schedule = EveryHourSchedule(0, 30), + scheduledInput = { SchedulerExampleContract.Inputs.Increment(1) }, + ) onSchedule( key = "Daily at 2am", schedule = EveryDaySchedule(LocalTime(2, 0)), - ) { - ExampleContract.Inputs.Increment(1) - } + scheduledInput = { ExampleContract.Inputs.Increment(1) }, + ) } } .build(), @@ -76,10 +115,6 @@ class ExampleInputHandler : InputHandler< } ``` -## Scheduler Configuration - -### Controller Configuration - The Scheduler is embedded into another ViewModel and sends Inputs back to it on the defined schedules, but it is itself also a ViewModel! This means you can add other Interceptors like Logging and Debugging into the Scheduler to observe or augment its functionality. The Configuration must include `.withSchedulerController()`. @@ -107,20 +142,94 @@ this += SchedulerInterceptor( ) ``` +### Android WorkManager + +Ballast Scheduler also supports persistent work on Android by configuring a schedule to run on top of WorkManager, +instead of embedded within a ViewModel. The general process is the same, but there are some restrictions to be aware of. +Most notably, you cannot use a lambda to create your `SchedulerAdapter`, since WorkManager needs to persist the state of +the schedule and rehydrate it later when each scheduled task is handled. It does this by using reflection to create your +`SchedulerAdapter` class, then determining the next Instant to run a Unique `OneTimeWorkRequest`. The Inputs generated +on each schedule "tick" are also passed back to the Adapter, since it is not directly connected to a ViewModel. You +should forward that Input to a ViewModel so it is processed by Ballast as normal. + +It is advised to use the [Android Startup library][5] to initialize your schedules, and to not create them dynamically +like you can with an embedded scheduler. Ballast Scheduler needs to be able to regularly sync its own schedule state and +configuration with WorkManager. + +```kotlin +public class BallastSchedulerExampleAdapter : SchedulerAdapter< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>, Function1 { + override fun SchedulerAdapterScope< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>.configureSchedules() { + onSchedule( + key = "Every 30 Minutes", + schedule = EveryHourSchedule(0, 30), + scheduledInput = { SchedulerExampleContract.Inputs.Increment(1) } + ) + } + + override fun invoke(p1: ExampleContract.Inputs) { + AppInjector.get().exampleViewModel().trySend(p1) + } +} +``` + +```kotlin +public class BallastSchedulerStartup : Initializer { + override fun create(context: Context) { + BallastWorkManagerScheduleDispatcher.scheduleWork( + WorkManager.getInstance(context), + BallastSchedulerExampleAdapter() + ) + } + + override fun dependencies(): List>> { + return listOf(WorkManagerInitializer::class.java) + } +} +``` + +### iOS BGTaskScheduler + +Running persistent scheduled work on iOS is not yet implemented. Ideally, it would work very similarly to running on +WorkManager, but using something like iOS's [BGTaskScheduler][6] + +## Schedule Configuration + +A `Schedule` produces a Sequence of the kotlin-datetime `Instant` (`Sequence`) given a starting `Instant`. It +is generally considered to be an _ideal version_ of the schedule, but depending on how long it takes to process the +Inputs dispatched by the schedule, the actual time that an Input is sent may be later, or some of the scheduled events +may be dropped. + +Several schedule types are available, but you are free to implement the `Schedule` interface yourself and provide a +custom sequence of scheduled tasks. + ### Delay Mode -When configuring a Schedule, you may choose whether you want the Inputs to be be "fire-and-forget" type tasks, or +When configuring a Schedule, you may choose whether you want the Inputs to be "fire-and-forget" type tasks, or whether the schedule executor should suspend until one scheduled Input is completely processed before attempting to run -the next scheduled task. +the next scheduled task. `ScheduleExecutor.DelayMode.FireAndForget` is the default. -``` -this += SchedulerInterceptor { - onSchedule( - key = "Daily at 2am", - delayMode = ScheduleExecutor.DelayMode.Suspend, - schedule = EveryDaySchedule(LocalTime(2, 0)), - ) { - ExampleContract.Inputs.Increment(1) +```kotlin +public class BallastSchedulerExampleAdapter : SchedulerAdapter< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State> { + override fun SchedulerAdapterScope< + ExampleContract.Inputs, + ExampleContract.Events, + ExampleContract.State>.configureSchedules() { + onSchedule( + key = "Daily at 2am", + delayMode = ScheduleExecutor.DelayMode.Suspend, + schedule = EveryDaySchedule(LocalTime(2, 0)), + ) { + ExampleContract.Inputs.Increment(1) + } } } ``` @@ -131,17 +240,6 @@ will determine how they two events are handled, as normal. `ScheduleExecutor.Del execution of the schedule while one Input is still processing, potentially dropping scheduled tasks to ensure that one Input finishes processing before sending the next one. -## Schedules - -A `Schedule` produces a Sqeunce of the kotlin-datetime `Instant` (`Sequence`) given a starting `Instant`. It is -generally considered to be an _ideal version_ of the schedule, but depending on how long it takes to process the Inputs -dispatched by the schedule, the actual time that an Input is sent may be later, or some of the scheduled events may be -dropped. A `ScheduleExecutor` is responsible to actually running a `Schedule` and dispatching events at the appropriate -time. A default implementation based on the kotlin-datetime `Clock.System` is provided. - -Several schedule types are available, but you are free to implement the `Schedule` interface yourself and provide a -custom sequence of scheduled tasks. Schedules are also - ### Fixed Delay Schedule The most basic type of `Schedule` is `FixedDelaySchedule`. It simply delays each subsequent task by a fixed `Duration` @@ -211,8 +309,9 @@ kotlin { } ``` - [1]: https://www.baeldung.com/spring-scheduled-tasks [2]: https://docs.oracle.com/javase/8/docs/api/java/util/Timer.html [3]: https://developer.android.com/topic/libraries/architecture/workmanager [4]: https://github.com/Kotlin/kotlinx-datetime +[5]: https://developer.android.com/topic/libraries/app-startup +[6]: https://developer.apple.com/documentation/backgroundtasks diff --git a/examples/scheduler/build.gradle.kts b/examples/scheduler/build.gradle.kts index a16f07c0..fe1d09df 100644 --- a/examples/scheduler/build.gradle.kts +++ b/examples/scheduler/build.gradle.kts @@ -50,6 +50,8 @@ kotlin { implementation(libs.androidx.material) implementation(libs.androidx.activityCompose) implementation(libs.ktor.client.cio) + implementation("androidx.work:work-runtime-ktx:2.8.1") + implementation("androidx.core:core:1.12.0") } } diff --git a/examples/scheduler/src/androidMain/AndroidManifest.xml b/examples/scheduler/src/androidMain/AndroidManifest.xml index 58cf1a6a..09fc51ce 100644 --- a/examples/scheduler/src/androidMain/AndroidManifest.xml +++ b/examples/scheduler/src/androidMain/AndroidManifest.xml @@ -1,21 +1,35 @@ - + + + android:name="com.copperleaf.ballast.examples.scheduler.MainApp" + android:icon="@android:drawable/ic_menu_compass" + android:label="@string/app_name" + android:theme="@style/Theme.Ballast" + android:networkSecurityConfig="@xml/network_security_config" + > + android:name="com.copperleaf.ballast.examples.scheduler.MainActivity" + android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" + android:launchMode="singleInstance" + android:windowSoftInputMode="adjustResize" + android:exported="true"> - - + + + + + + diff --git a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerExampleAdapter.kt b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerExampleAdapter.kt new file mode 100644 index 00000000..e8ef8bc7 --- /dev/null +++ b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerExampleAdapter.kt @@ -0,0 +1,35 @@ +package com.copperleaf.ballast.examples.scheduler + +import com.copperleaf.ballast.scheduler.SchedulerAdapter +import com.copperleaf.ballast.scheduler.SchedulerAdapterScope +import com.copperleaf.ballast.scheduler.schedule.EveryHourSchedule + +public class AndroidSchedulerExampleAdapter : SchedulerAdapter< + SchedulerExampleContract.Inputs, + SchedulerExampleContract.Events, + SchedulerExampleContract.State>, Function1 { + companion object { + val everyHour = "Once Every Hour" + } + + override fun SchedulerAdapterScope< + SchedulerExampleContract.Inputs, + SchedulerExampleContract.Events, + SchedulerExampleContract.State>.configureSchedules() { + onSchedule( + key = everyHour, + schedule = EveryHourSchedule(0, 30), + scheduledInput = { SchedulerExampleContract.Inputs.Increment(everyHour, 10) } + ) + } + + override fun invoke(p1: SchedulerExampleContract.Inputs) { + check(p1 is SchedulerExampleContract.Inputs.Increment) + + Notifications.notify( + context = MainApp.INSTANCE!!, + title = "Ballast Scheduler", + message = p1.scheduleKey + ) + } +} diff --git a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerStartup.kt b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerStartup.kt new file mode 100644 index 00000000..bfca5f6c --- /dev/null +++ b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/AndroidSchedulerStartup.kt @@ -0,0 +1,24 @@ +package com.copperleaf.ballast.examples.scheduler + +import android.content.Context +import android.util.Log +import androidx.startup.Initializer +import androidx.work.WorkManager +import androidx.work.WorkManagerInitializer +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleDispatcher + +public class AndroidSchedulerStartup : Initializer { + override fun create(context: Context) { + Log.d("BallastWorkManager", "Running AndroidSchedulerStartup") + val workManager = WorkManager.getInstance(context) + + BallastWorkManagerScheduleDispatcher.scheduleWork( + workManager, + AndroidSchedulerExampleAdapter() + ) + } + + override fun dependencies(): List>> { + return listOf(WorkManagerInitializer::class.java) + } +} diff --git a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainActivity.kt b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainActivity.kt index 9bf96930..fb04261e 100644 --- a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainActivity.kt +++ b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainActivity.kt @@ -3,10 +3,18 @@ package com.copperleaf.ballast.examples.scheduler import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.work.WorkManager +import com.copperleaf.ballast.scheduler.workmanager.BallastWorkManagerScheduleDispatcher public class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Notifications.notify( + context = MainApp.INSTANCE!!, + title = "Ballast Scheduler", + message = "App Launch" + ) + setContent { SchedulerExampleUi.Content() } diff --git a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainApp.kt b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainApp.kt new file mode 100644 index 00000000..62ee9d6a --- /dev/null +++ b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/MainApp.kt @@ -0,0 +1,14 @@ +package com.copperleaf.ballast.examples.scheduler + +import android.app.Application + +public class MainApp : Application() { + override fun onCreate() { + super.onCreate() + INSTANCE = this + } + + public companion object { + var INSTANCE: MainApp? = null + } +} diff --git a/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/Notifications.kt b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/Notifications.kt new file mode 100644 index 00000000..216eda51 --- /dev/null +++ b/examples/scheduler/src/androidMain/kotlin/com/copperleaf/ballast/examples/scheduler/Notifications.kt @@ -0,0 +1,56 @@ +package com.copperleaf.ballast.examples.scheduler + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.copperleaf.scheduler.R + +object Notifications { + + public fun notify(context: Context, title: String, message: String) = with(context) { + val channelName = createNotificationChannel() + + val builder = NotificationCompat.Builder(this, channelName) + .setSmallIcon(R.drawable.ic_android_black_24dp) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + + with(NotificationManagerCompat.from(this)) { + // notificationId is a unique int for each notification that you must define. + if (ActivityCompat.checkSelfPermission(MainApp.INSTANCE!!, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + // TODO: Consider calling + // ActivityCompat#requestPermissions + // here to request the missing permissions, and then overriding + // public void onRequestPermissionsResult(int requestCode, String[] permissions, + // int[] grantResults) + // to handle the case where the user grants the permission. See the documentation + // for ActivityCompat#requestPermissions for more details. + return + } + notify(0, builder.build()) + } + } + + private fun Context.createNotificationChannel(): String { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is not in the Support Library. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel("ballast", "Ballast Scheduler", importance).apply { + description = "Ballast Scheduler" + } + // Register the channel with the system. + val notificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + return "ballast" + } +} diff --git a/examples/scheduler/src/androidMain/res/drawable/ic_android_black_24dp.xml b/examples/scheduler/src/androidMain/res/drawable/ic_android_black_24dp.xml new file mode 100644 index 00000000..fe512307 --- /dev/null +++ b/examples/scheduler/src/androidMain/res/drawable/ic_android_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/examples/scheduler/src/androidMain/res/xml/network_security_config.xml b/examples/scheduler/src/androidMain/res/xml/network_security_config.xml new file mode 100644 index 00000000..6b2347be --- /dev/null +++ b/examples/scheduler/src/androidMain/res/xml/network_security_config.xml @@ -0,0 +1,6 @@ + + + + 10.0.2.2 + + diff --git a/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleAdapter.kt b/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleAdapter.kt index 56c493b8..c6fdbfe1 100644 --- a/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleAdapter.kt +++ b/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleAdapter.kt @@ -20,7 +20,6 @@ public class SchedulerExampleAdapter : SchedulerAdapter< val everyMinute = "Twice Every Minute" val everyHour = "6 Times Every Hour" val everyDay = "4 Times Every day" - val droppedTasks = "Dropped Tasks" } override fun SchedulerAdapterScope< diff --git a/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleViewModel.kt b/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleViewModel.kt index 1b7ad0b5..63210c73 100644 --- a/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleViewModel.kt +++ b/examples/scheduler/src/commonMain/kotlin/com/copperleaf/ballast/examples/scheduler/SchedulerExampleViewModel.kt @@ -9,11 +9,9 @@ import com.copperleaf.ballast.core.LoggingInterceptor import com.copperleaf.ballast.debugger.BallastDebuggerInterceptor import com.copperleaf.ballast.plusAssign import com.copperleaf.ballast.scheduler.SchedulerInterceptor -import com.copperleaf.ballast.scheduler.schedule.EveryDaySchedule import com.copperleaf.ballast.scheduler.withSchedulerController import com.copperleaf.ballast.withViewModel import kotlinx.coroutines.CoroutineScope -import kotlinx.datetime.LocalTime typealias SchedulerExampleViewModel = BallastViewModel< SchedulerExampleContract.Inputs, @@ -59,15 +57,6 @@ internal fun createViewModel( ) .apply { inputStrategy = FifoInputStrategy.typed() - - this += SchedulerInterceptor { - onSchedule( - key = "Daily at 2am", - schedule = EveryDaySchedule(LocalTime(2, 0)), - ) { - SchedulerExampleContract.Inputs.Increment("", 1) - } - } } .build(), eventHandler = SchedulerExampleEventHandler(), diff --git a/gradle-convention-plugins b/gradle-convention-plugins index 0b848c13..2d7a9ef2 160000 --- a/gradle-convention-plugins +++ b/gradle-convention-plugins @@ -1 +1 @@ -Subproject commit 0b848c138a3dcf3af410eb718d961aa97900aaa6 +Subproject commit 2d7a9ef25ab97fd6cce0eee55a18d4fe6f9d672c