diff --git a/client-android/build.gradle.kts b/client-android/build.gradle.kts index 8e5c173..89b0063 100644 --- a/client-android/build.gradle.kts +++ b/client-android/build.gradle.kts @@ -64,6 +64,10 @@ android { testOptions { unitTests.isIncludeAndroidResources = true } + + sourceSets { + getByName("androidTest").assets.srcDir("$projectDir/schemas") + } } kotlin { @@ -86,6 +90,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.service) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.markdown.renderer.m3) implementation(platform(libs.androidx.compose.bom)) diff --git a/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/3.json b/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/3.json new file mode 100644 index 0000000..9e14c59 --- /dev/null +++ b/client-android/schemas/eventually.client.persistence.tasks.TaskEntityDatabase/3.json @@ -0,0 +1,79 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "71604ee7ad683f4ea93090d15593f539", + "entities": [ + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `goal` TEXT NOT NULL, `schedule` TEXT NOT NULL, `contextSwitch` INTEGER NOT NULL, `isActive` INTEGER NOT NULL, `color` INTEGER, `executionAlarm` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schedule", + "columnName": "schedule", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contextSwitch", + "columnName": "contextSwitch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "executionAlarm", + "columnName": "executionAlarm", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '71604ee7ad683f4ea93090d15593f539')" + ] + } +} \ No newline at end of file diff --git a/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDatabaseMigrationSpec.kt b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDatabaseMigrationSpec.kt new file mode 100644 index 0000000..8bcd5ba --- /dev/null +++ b/client-android/src/androidTest/java/eventually/test/client/persistence/tasks/TaskEntityDatabaseMigrationSpec.kt @@ -0,0 +1,64 @@ +package eventually.test.client.persistence.tasks + +import android.content.Context +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import eventually.client.persistence.tasks.TaskEntityDatabase +import eventually.core.model.Task +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Duration +import java.time.Instant + +@RunWith(AndroidJUnit4::class) +class TaskEntityDatabaseMigrationSpec { + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + TaskEntityDatabase::class.java + ) + + @Test + fun migrateFromVersion2To3() { + helper.createDatabase(DatabaseName, 2).use { db -> + db.execSQL( + "INSERT INTO tasks (id, name, description, goal, schedule, contextSwitch, isActive, color) " + + "VALUES (42, 'test-task', 'test-description', 'test-goal', '{\"type\":\"once\",\"instant\":42}', 300, 1, 7)" + ) + } + + helper.runMigrationsAndValidate(DatabaseName, 3, true, TaskEntityDatabase.MIGRATION_2_3).close() + + val context = ApplicationProvider.getApplicationContext() + val database = Room + .databaseBuilder(context, TaskEntityDatabase::class.java, DatabaseName) + .addMigrations(TaskEntityDatabase.MIGRATION_1_2, TaskEntityDatabase.MIGRATION_2_3) + .build() + + try { + val tasks = runBlocking { database.dao().getAll() } + + assertThat(tasks.size, equalTo(1)) + assertThat(tasks.single().id, equalTo(42)) + assertThat(tasks.single().name, equalTo("test-task")) + assertThat(tasks.single().schedule, equalTo(Task.Schedule.Once(instant = Instant.ofEpochSecond(42)))) + assertThat(tasks.single().contextSwitch, equalTo(Duration.ofMinutes(5))) + assertThat(tasks.single().isActive, equalTo(true)) + assertThat(tasks.single().color, equalTo(7)) + assertThat(tasks.single().executionAlarm, equalTo(false)) + } finally { + database.close() + } + } + + companion object { + private const val DatabaseName: String = "migration-test.db" + } +} diff --git a/client-android/src/main/AndroidManifest.xml b/client-android/src/main/AndroidManifest.xml index 0d9a90b..b287dc4 100644 --- a/client-android/src/main/AndroidManifest.xml +++ b/client-android/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" /> + @@ -67,6 +68,13 @@ + + diff --git a/client-android/src/main/java/eventually/client/activities/AlarmActivity.kt b/client-android/src/main/java/eventually/client/activities/AlarmActivity.kt new file mode 100644 index 0000000..1108128 --- /dev/null +++ b/client-android/src/main/java/eventually/client/activities/AlarmActivity.kt @@ -0,0 +1,141 @@ +package eventually.client.activities + +import android.app.NotificationManager +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import eventually.client.R +import eventually.client.activities.helpers.TaskManagement +import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications +import eventually.client.scheduling.NotificationManagerExtensions.isInstanceExecutionAlarmActive +import eventually.client.serialization.Extras.requireInstanceId +import eventually.client.serialization.Extras.requireInstant +import eventually.client.serialization.Extras.requireTask +import eventually.client.settings.Settings.defaultPreferences +import eventually.client.settings.Settings.getDateTimeFormat +import eventually.client.settings.Settings.getPostponeLength +import eventually.client.ui.screens.AlarmScreen +import eventually.client.ui.theme.EventuallyTheme +import eventually.core.model.Task +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant +import java.util.UUID + +class AlarmActivity : ComponentActivity() { + private val preferences by lazy { defaultPreferences() } + + private var alarm: Alarm? by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + showOverLockScreen() + enableEdgeToEdge() + + alarm = intent.toAlarm() + + if (alarm == null) { + finish() + return + } + + setContent { + val dateTimeFormat = remember { preferences.getDateTimeFormat() } + val postponeLength = remember { preferences.getPostponeLength() } + + EventuallyTheme { + alarm?.let { current -> + LaunchedEffect(current) { + do { + delay(RingingCheckInterval) + } while (isRinging(current)) + + finish() + } + + AlarmScreen( + task = current.task, + execution = current.execution, + dateTimeFormat = dateTimeFormat, + postponeLength = postponeLength, + onPostpone = { postpone(current, postponeLength) }, + onDismiss = { dismiss(current) } + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.toAlarm()?.let { + setIntent(intent) + alarm = it + } + } + + private fun isRinging(alarm: Alarm): Boolean = + getSystemService(NotificationManager::class.java).isInstanceExecutionAlarmActive(alarm.task.id, alarm.instance) + + private fun dismiss(alarm: Alarm) { + if (isFinishing) return + + getSystemService(NotificationManager::class.java).deleteInstanceNotifications(alarm.task.id, alarm.instance) + TaskManagement.dismissTaskInstance(this, alarm.task.id, alarm.instance) + Toast.makeText(this, getString(R.string.toast_task_dismissed), Toast.LENGTH_SHORT).show() + finish() + } + + private fun postpone(alarm: Alarm, by: Duration) { + if (isFinishing) return + + getSystemService(NotificationManager::class.java).deleteInstanceNotifications(alarm.task.id, alarm.instance) + TaskManagement.postponeTaskInstance(this, alarm.task.id, alarm.instance, by) + Toast.makeText(this, getString(R.string.toast_task_postponed), Toast.LENGTH_SHORT).show() + finish() + } + + private fun showOverLockScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + data class Alarm(val task: Task, val instance: UUID, val execution: Instant) + + companion object { + const val ExtraTask: String = "eventually.client.activities.AlarmActivity.extra_task" + const val ExtraInstance: String = "eventually.client.activities.AlarmActivity.extra_instance" + const val ExtraExecution: String = "eventually.client.activities.AlarmActivity.extra_execution" + + fun Intent.toAlarm(): Alarm? = + runCatching { + Alarm( + task = requireTask(ExtraTask), + instance = requireInstanceId(ExtraInstance), + execution = requireInstant(ExtraExecution) + ) + }.getOrNull() + + private const val RingingCheckInterval: Long = 1000 + } +} diff --git a/client-android/src/main/java/eventually/client/persistence/Converters.kt b/client-android/src/main/java/eventually/client/persistence/Converters.kt index 97bdc8b..b653fea 100644 --- a/client-android/src/main/java/eventually/client/persistence/Converters.kt +++ b/client-android/src/main/java/eventually/client/persistence/Converters.kt @@ -96,7 +96,8 @@ class Converters { schedule = task.schedule, contextSwitch = task.contextSwitch, isActive = task.isActive, - color = task.color + color = task.color, + executionAlarm = task.executionAlarm ) fun entityToTask(entity: TaskEntity) = Task( @@ -107,7 +108,8 @@ class Converters { schedule = entity.schedule, contextSwitch = entity.contextSwitch, isActive = entity.isActive, - color = entity.color + color = entity.color, + executionAlarm = entity.executionAlarm ) fun taskToString(task: Task): String = @@ -222,6 +224,7 @@ class Converters { json.addProperty("context_switch", task.contextSwitch.seconds) json.addProperty("is_active", task.isActive) json.addProperty("color", task.color) + json.addProperty("execution_alarm", task.executionAlarm) return json } @@ -235,7 +238,8 @@ class Converters { schedule = jsonToSchedule(task.get("schedule").asJsonObject), contextSwitch = Duration.ofSeconds(task.get("context_switch").asLong), isActive = task.get("is_active").asBoolean, - color = task.get("color")?.asInt + color = task.get("color")?.asInt, + executionAlarm = task.get("execution_alarm")?.asBoolean ?: false ) } diff --git a/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntity.kt b/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntity.kt index 542f6d7..944045b 100644 --- a/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntity.kt +++ b/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntity.kt @@ -1,5 +1,6 @@ package eventually.client.persistence.tasks +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import eventually.core.model.Task @@ -15,5 +16,7 @@ data class TaskEntity( val schedule: Task.Schedule, val contextSwitch: Duration, val isActive: Boolean, - val color: Int? + val color: Int?, + @ColumnInfo(defaultValue = "0") + val executionAlarm: Boolean = false ) diff --git a/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntityDatabase.kt b/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntityDatabase.kt index 6c5d010..6c8dc58 100644 --- a/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntityDatabase.kt +++ b/client-android/src/main/java/eventually/client/persistence/tasks/TaskEntityDatabase.kt @@ -10,18 +10,24 @@ import androidx.sqlite.db.SupportSQLiteDatabase import eventually.client.persistence.Converters import org.jetbrains.annotations.TestOnly -@Database(entities = [TaskEntity::class], version = 2, exportSchema = true) +@Database(entities = [TaskEntity::class], version = 3, exportSchema = true) @TypeConverters(Converters::class) abstract class TaskEntityDatabase : RoomDatabase() { abstract fun dao(): TaskEntityDao companion object { - private val MIGRATION_1_2 = object : Migration(1, 2) { + val MIGRATION_1_2: Migration = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE tasks ADD COLUMN color INTEGER NULL") } } + val MIGRATION_2_3: Migration = object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE tasks ADD COLUMN executionAlarm INTEGER NOT NULL DEFAULT 0") + } + } + private const val DefaultDatabase: String = "tasks.db" @Volatile @@ -45,7 +51,7 @@ abstract class TaskEntityDatabase : RoomDatabase() { private fun build(context: Context, database: String) = Room .databaseBuilder(context.applicationContext, TaskEntityDatabase::class.java, database) - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build() } } diff --git a/client-android/src/main/java/eventually/client/scheduling/AlarmManagerExtensions.kt b/client-android/src/main/java/eventually/client/scheduling/AlarmManagerExtensions.kt index 650ac2d..3c15c66 100644 --- a/client-android/src/main/java/eventually/client/scheduling/AlarmManagerExtensions.kt +++ b/client-android/src/main/java/eventually/client/scheduling/AlarmManagerExtensions.kt @@ -5,12 +5,51 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import eventually.client.scheduling.NotificationManagerExtensions.getInstanceNotificationId +import eventually.client.serialization.Extras.putInstanceId +import eventually.client.serialization.Extras.putTaskId import java.time.Instant +import java.util.UUID object AlarmManagerExtensions { fun AlarmManager.putEvaluationAlarm(context: Context, instant: Instant) { + putAlarm(instant, getAlarmIntent(context)) + } + + fun AlarmManager.deleteEvaluationAlarm(context: Context) { + cancel(getAlarmIntent(context)) + } + + fun AlarmManager.putAlarmTimeout(context: Context, task: Int, instance: UUID, instant: Instant) { + val intent: PendingIntent = PendingIntent.getService( + context, + getAlarmTimeoutRequestCode(task, instance), + createAlarmTimeoutIntent(context, task, instance), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_UPDATE_CURRENT + ) + + putAlarm(instant, intent) + } + + fun AlarmManager.deleteAlarmTimeout(context: Context, task: Int, instance: UUID) { + val intent: PendingIntent? = PendingIntent.getService( + context, + getAlarmTimeoutRequestCode(task, instance), + createAlarmTimeoutIntent(context, task, instance), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_NO_CREATE + ) + + intent?.let { + cancel(it) + it.cancel() + } + } + + fun AlarmManager.canUseExactAlarms(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms() + + private fun AlarmManager.putAlarm(instant: Instant, intent: PendingIntent) { val triggerAt = instant.toEpochMilli() - val intent = getAlarmIntent(context) try { if (canUseExactAlarms()) { @@ -23,13 +62,6 @@ object AlarmManagerExtensions { } } - fun AlarmManager.deleteEvaluationAlarm(context: Context) { - cancel(getAlarmIntent(context)) - } - - fun AlarmManager.canUseExactAlarms(): Boolean = - Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms() - private fun getAlarmIntent(context: Context): PendingIntent { return PendingIntent.getService( context, @@ -39,5 +71,16 @@ object AlarmManagerExtensions { ) } + private fun getAlarmTimeoutRequestCode(task: Int, instance: UUID): Int = + getInstanceNotificationId(task, instance) + SchedulerServiceAlarmTimeoutRequestCode + + private fun createAlarmTimeoutIntent(context: Context, task: Int, instance: UUID): Intent = + Intent(context, SchedulerService::class.java).apply { + action = SchedulerService.ActionAlarmTimeout + putTaskId(SchedulerService.ActionAlarmTimeoutExtraTask, task) + putInstanceId(SchedulerService.ActionAlarmTimeoutExtraInstance, instance) + } + private const val SchedulerServiceEvaluationRequestCode: Int = 1 + private const val SchedulerServiceAlarmTimeoutRequestCode: Int = 5 } diff --git a/client-android/src/main/java/eventually/client/scheduling/NotificationManagerExtensions.kt b/client-android/src/main/java/eventually/client/scheduling/NotificationManagerExtensions.kt index b47b474..0b6719d 100644 --- a/client-android/src/main/java/eventually/client/scheduling/NotificationManagerExtensions.kt +++ b/client-android/src/main/java/eventually/client/scheduling/NotificationManagerExtensions.kt @@ -7,8 +7,14 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Color +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.provider.Settings as AndroidSettings import androidx.core.app.NotificationCompat import eventually.client.R +import eventually.client.activities.AlarmActivity import eventually.client.activities.MainActivity import eventually.client.activities.helpers.DateTimeExtensions.formatAsTime import eventually.client.activities.receivers.DismissReceiver @@ -16,11 +22,14 @@ import eventually.client.activities.receivers.PostponeReceiver import eventually.client.persistence.notifications.NotificationRepository import eventually.client.serialization.Extras.putDuration import eventually.client.serialization.Extras.putInstanceId +import eventually.client.serialization.Extras.putInstant +import eventually.client.serialization.Extras.putTask import eventually.client.serialization.Extras.putTaskId import eventually.client.settings.Settings.defaultPreferences import eventually.client.settings.Settings.getPostponeLength import eventually.core.model.Task import eventually.core.model.TaskInstance +import java.time.Duration import java.util.UUID object NotificationManagerExtensions { @@ -42,6 +51,18 @@ object NotificationManagerExtensions { name = context.getString(R.string.notification_channel_instance_context_switch_name) ) ) + + createNotificationChannel( + config.instanceExecutionAlarmChannel.toNotificationChannel( + name = context.getString(R.string.notification_channel_instance_execution_alarm_name) + ) + ) + + createNotificationChannel( + config.instanceMissedAlarmChannel.toNotificationChannel( + name = context.getString(R.string.notification_channel_instance_missed_alarm_name) + ) + ) } fun NotificationManager.putInstanceExecutionNotification( @@ -53,11 +74,7 @@ object NotificationManagerExtensions { val executionTime = instance.execution().formatAsTime(context) val title = context.getString(R.string.notification_instance_execution_title, executionTime, task.name) - val text = if (task.description.isEmpty()) { - context.getString(R.string.notification_instance_execution_no_description_text, task.goal) - } else { - context.getString(R.string.notification_instance_execution_text, task.goal, task.description) - } + val text = executionText(context, task) val intent = Intents.createTaskDetailsIntent(context, task, instance) val (dismissAction, postponeAction) = Actions.createInstanceActions(context, task, instance) @@ -75,6 +92,92 @@ object NotificationManagerExtensions { notify(getInstanceNotificationId(task, instance), notification) } + fun NotificationManager.putInstanceExecutionAlarm( + context: Context, + task: Task, + instance: TaskInstance, + timeout: Duration, + config: Config = Config.Default + ) { + val executionTime = instance.execution().formatAsTime(context) + + val title = context.getString(R.string.notification_instance_execution_title, executionTime, task.name) + val text = executionText(context, task) + + val intent = Intents.createAlarmIntent(context, task, instance) + val (dismissAction, postponeAction) = Actions.createInstanceActions(context, task, instance) + + val notification = NotificationCompat.Builder(context, config.instanceExecutionAlarmChannel.id) + .setSmallIcon(R.drawable.ic_alarm) + .setContentTitle(title) + .setContentText(text) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setOngoing(true) + .setContentIntent(intent) + .setFullScreenIntent(intent, true) + .setTimeoutAfter(timeout.plus(AlarmTimeoutGrace).toMillis()) + .addAction(dismissAction) + .addAction(postponeAction) + .build() + + notification.flags = notification.flags or Notification.FLAG_INSISTENT + + notify(getInstanceNotificationId(task, instance), notification) + } + + fun NotificationManager.putInstanceMissedAlarmNotification( + context: Context, + task: Task, + instance: TaskInstance, + config: Config = Config.Default + ) { + val executionTime = instance.execution().formatAsTime(context) + + val title = context.getString(R.string.notification_instance_missed_alarm_title, executionTime, task.name) + val text = executionText(context, task) + + val intent = Intents.createTaskDetailsIntent(context, task, instance) + val (dismissAction, postponeAction) = Actions.createInstanceActions(context, task, instance) + + val notification = NotificationCompat.Builder(context, config.instanceMissedAlarmChannel.id) + .setSmallIcon(R.drawable.ic_alarm) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(intent) + .setAutoCancel(true) + .addAction(dismissAction) + .addAction(postponeAction) + .build() + + notify(getInstanceNotificationId(task, instance), notification) + } + + fun NotificationManager.replaceInstanceExecutionAlarm( + context: Context, + task: Task, + instance: TaskInstance, + config: Config = Config.Default + ) { + if (isInstanceExecutionAlarmActive(task.id, instance.id, config)) { + cancel(getInstanceNotificationId(task, instance)) + putInstanceMissedAlarmNotification(context, task, instance, config) + } + } + + fun NotificationManager.isInstanceExecutionAlarmActive( + task: Int, + instance: UUID, + config: Config = Config.Default + ): Boolean { + val id = getInstanceNotificationId(task, instance) + + return activeNotifications.any { it.id == id && it.notification.channelId == config.instanceExecutionAlarmChannel.id } + } + + fun NotificationManager.canUseFullScreenAlarms(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || canUseFullScreenIntent() + fun NotificationManager.putInstanceContextSwitchNotification( context: Context, task: Task, @@ -142,11 +245,18 @@ object NotificationManagerExtensions { return notificationId to notification } + private fun executionText(context: Context, task: Task): String = + if (task.description.isEmpty()) { + context.getString(R.string.notification_instance_execution_no_description_text, task.goal) + } else { + context.getString(R.string.notification_instance_execution_text, task.goal, task.description) + } + private fun getInstanceNotificationId(task: Task, instance: TaskInstance): Int { return getInstanceNotificationId(task.id, instance.id) } - private fun getInstanceNotificationId(task: Int, instance: UUID): Int { + fun getInstanceNotificationId(task: Int, instance: UUID): Int { return java.util.Objects.hash(task, instance) and Int.MAX_VALUE } @@ -193,11 +303,35 @@ object NotificationManagerExtensions { ) } + fun createAlarmIntent(context: Context, task: Task, instance: TaskInstance): PendingIntent { + val intent = Intent(context, AlarmActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putTask(AlarmActivity.ExtraTask, task) + .putInstanceId(AlarmActivity.ExtraInstance, instance.id) + .putInstant(AlarmActivity.ExtraExecution, instance.execution()) + + return PendingIntent.getActivity( + context, + getInstanceNotificationId(task, instance) + AlarmActivityRequestCode, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + fun createFullScreenAlarmSettingsIntent(context: Context): Intent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Intent(AndroidSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT) + .setData(Uri.fromParts("package", context.packageName, null)) + } else { + null + } + fun createDismissIntent(context: Context, task: Task, instance: TaskInstance): PendingIntent { return PendingIntent.getBroadcast( context, getInstanceNotificationId(task, instance) + DismissInstanceRequestCode, Intent(DismissReceiver.Action).apply { + setPackage(context.packageName) putTaskId(DismissReceiver.ExtraTask, task.id) putInstanceId(DismissReceiver.ExtraInstance, instance.id) }, @@ -211,6 +345,7 @@ object NotificationManagerExtensions { getInstanceNotificationId(task, instance) + PostponeInstanceRequestCode, Intent(PostponeReceiver.Action).apply { val by = context.defaultPreferences().getPostponeLength() + setPackage(context.packageName) putTaskId(PostponeReceiver.ExtraTask, task.id) putInstanceId(PostponeReceiver.ExtraInstance, instance.id) putDuration(PostponeReceiver.ExtraBy, by) @@ -222,18 +357,25 @@ object NotificationManagerExtensions { private const val DetailsActivityRequestCode: Int = 1 private const val DismissInstanceRequestCode: Int = 2 private const val PostponeInstanceRequestCode: Int = 3 + private const val AlarmActivityRequestCode: Int = 4 } + private val AlarmTimeoutGrace: Duration = Duration.ofMinutes(1) + data class Config( val foregroundServiceChannel: Channel, val instanceExecutionChannel: Channel, - val instanceContextSwitchChannel: Channel + val instanceContextSwitchChannel: Channel, + val instanceExecutionAlarmChannel: Channel, + val instanceMissedAlarmChannel: Channel ) { companion object { val Default: Config = Config( foregroundServiceChannel = Channel.ForegroundServiceChannel, instanceExecutionChannel = Channel.InstanceExecutionChannel, - instanceContextSwitchChannel = Channel.InstanceContextSwitchChannel + instanceContextSwitchChannel = Channel.InstanceContextSwitchChannel, + instanceExecutionAlarmChannel = Channel.InstanceExecutionAlarmChannel, + instanceMissedAlarmChannel = Channel.InstanceMissedAlarmChannel ) } @@ -241,7 +383,8 @@ object NotificationManagerExtensions { val id: String, val importance: Int, val light: Int?, - val vibrationEnabled: Boolean + val vibrationEnabled: Boolean, + val alarm: Boolean = false ) { fun toNotificationChannel(name: String): NotificationChannel { val channel = NotificationChannel(id, name, importance) @@ -250,6 +393,16 @@ object NotificationManagerExtensions { channel.lightColor = light ?: Color.TRANSPARENT channel.enableVibration(vibrationEnabled) + if (alarm) { + channel.setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM), + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + } + return channel } @@ -274,6 +427,21 @@ object NotificationManagerExtensions { light = Color.YELLOW, vibrationEnabled = true ) + + val InstanceExecutionAlarmChannel: Channel = Channel( + id = "eventually.client.scheduling.notification_channel_instance_execution_alarm", + importance = NotificationManager.IMPORTANCE_HIGH, + light = Color.RED, + vibrationEnabled = true, + alarm = true + ) + + val InstanceMissedAlarmChannel: Channel = Channel( + id = "eventually.client.scheduling.notification_channel_instance_missed_alarm", + importance = NotificationManager.IMPORTANCE_DEFAULT, + light = Color.RED, + vibrationEnabled = false + ) } } } diff --git a/client-android/src/main/java/eventually/client/scheduling/NotificationQueue.kt b/client-android/src/main/java/eventually/client/scheduling/NotificationQueue.kt index 289ced7..f3b37b1 100644 --- a/client-android/src/main/java/eventually/client/scheduling/NotificationQueue.kt +++ b/client-android/src/main/java/eventually/client/scheduling/NotificationQueue.kt @@ -4,12 +4,18 @@ import android.app.AlarmManager import android.app.NotificationManager import android.content.Context import eventually.client.persistence.notifications.NotificationRepository +import eventually.client.scheduling.AlarmManagerExtensions.deleteAlarmTimeout import eventually.client.scheduling.AlarmManagerExtensions.deleteEvaluationAlarm +import eventually.client.scheduling.AlarmManagerExtensions.putAlarmTimeout import eventually.client.scheduling.AlarmManagerExtensions.putEvaluationAlarm import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications import eventually.client.scheduling.NotificationManagerExtensions.putInstanceContextSwitchNotification +import eventually.client.scheduling.NotificationManagerExtensions.putInstanceExecutionAlarm import eventually.client.scheduling.NotificationManagerExtensions.putInstanceExecutionNotification +import eventually.client.settings.Settings.defaultPreferences +import eventually.client.settings.Settings.getAlarmTimeout import eventually.core.scheduling.SchedulerOps +import java.time.Instant import java.util.LinkedList import java.util.Queue import java.util.UUID @@ -77,7 +83,13 @@ class NotificationQueue( } is SchedulerOps.Notification.PutInstanceExecutionNotification -> { - notificationManager.putInstanceExecutionNotification(context, it.task, it.instance) + if (it.task.executionAlarm) { + val timeout = context.defaultPreferences().getAlarmTimeout() + notificationManager.putInstanceExecutionAlarm(context, it.task, it.instance, timeout) + alarmManager.putAlarmTimeout(context, it.task.id, it.instance.id, Instant.now().plus(timeout)) + } else { + notificationManager.putInstanceExecutionNotification(context, it.task, it.instance) + } notificationRepository.put(it.task, it.instance, type = "execution") } @@ -88,6 +100,7 @@ class NotificationQueue( is SchedulerOps.Notification.DeleteInstanceNotifications -> { notificationManager.deleteInstanceNotifications(it.task, it.instance) + alarmManager.deleteAlarmTimeout(context, it.task, it.instance) notificationRepository.delete(it.task, it.instance) } } diff --git a/client-android/src/main/java/eventually/client/scheduling/SchedulerService.kt b/client-android/src/main/java/eventually/client/scheduling/SchedulerService.kt index 0b5ee2a..a40c70b 100644 --- a/client-android/src/main/java/eventually/client/scheduling/SchedulerService.kt +++ b/client-android/src/main/java/eventually/client/scheduling/SchedulerService.kt @@ -23,7 +23,10 @@ import eventually.client.persistence.Converters.Companion.asSchedule import eventually.client.persistence.notifications.NotificationRepository import eventually.client.persistence.schedules.TaskScheduleRepository import eventually.client.persistence.tasks.TaskRepository +import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications +import eventually.client.scheduling.NotificationManagerExtensions.isInstanceExecutionAlarmActive import eventually.client.scheduling.NotificationManagerExtensions.pruneInstanceNotifications +import eventually.client.scheduling.NotificationManagerExtensions.replaceInstanceExecutionAlarm import eventually.client.serialization.Extras.requireDuration import eventually.client.serialization.Extras.requireInstanceId import eventually.client.serialization.Extras.requireInstant @@ -32,12 +35,15 @@ import eventually.client.serialization.Extras.requireTaskId import eventually.client.settings.Settings import eventually.client.settings.Settings.defaultPreferences import eventually.client.settings.Settings.getSummaryConfig +import eventually.core.model.Task +import eventually.core.model.TaskInstance import eventually.core.model.TaskSchedule import eventually.core.model.TaskSummary import eventually.core.model.TaskSummaryConfig import eventually.core.scheduling.SchedulerOps import kotlinx.coroutines.runBlocking import java.time.Instant +import java.util.UUID import javax.inject.Inject @AndroidEntryPoint @@ -79,6 +85,8 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc runBlocking { if (msg.obj == PruneNotifications) notificationManager.pruneInstanceNotifications(notificationRepository) + (msg.obj as? AlarmTimeout)?.applyTo(this@SchedulerService, schedules, notificationManager) + msg .handle(schedules, taskRepository, taskScheduleRepository) .applyTo( @@ -122,7 +130,8 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc val obj = intent.toSchedulerMessage( requireConfig = ::requireConfig, reloadData = ::load, - pruneNotifications = ::prune + pruneNotifications = ::prune, + timeoutAlarm = ::timeoutAlarm ) obj?.let { @@ -212,6 +221,13 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc } } + private fun timeoutAlarm(task: Int, instance: UUID) { + handler.obtainMessage().let { msg -> + msg.obj = AlarmTimeout(task, instance) + handler.sendMessage(msg) + } + } + inner class SchedulerBinder : Binder() { val service: SchedulerService = this@SchedulerService } @@ -220,6 +236,21 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc data object PruneNotifications + data class AlarmTimeout(val task: Int, val instance: UUID) { + fun resolve(schedules: Map): Pair? = + schedules[task]?.let { schedule -> schedule.instances[instance]?.let { schedule.task to it } } + + fun applyTo(context: Context, schedules: Map, notificationManager: NotificationManager) { + when (val resolved = resolve(schedules)) { + null -> if (notificationManager.isInstanceExecutionAlarmActive(task, instance)) { + notificationManager.deleteInstanceNotifications(task, instance) + } + + else -> notificationManager.replaceInstanceExecutionAlarm(context, resolved.first, resolved.second) + } + } + } + companion object { const val ActionPut: String = "eventually.client.scheduling.SchedulerService.Put" const val ActionPutExtraTask: String = "eventually.client.scheduling.SchedulerService.Put.extra_task" @@ -248,10 +279,17 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc const val ActionReload: String = "eventually.client.scheduling.SchedulerService.Reload" const val ActionPruneNotifications: String = "eventually.client.scheduling.SchedulerService.PruneNotifications" + const val ActionAlarmTimeout: String = "eventually.client.scheduling.SchedulerService.AlarmTimeout" + const val ActionAlarmTimeoutExtraTask: String = + "eventually.client.scheduling.SchedulerService.AlarmTimeout.extra_task" + const val ActionAlarmTimeoutExtraInstance: String = + "eventually.client.scheduling.SchedulerService.AlarmTimeout.extra_instance" + fun Intent?.toSchedulerMessage( requireConfig: () -> TaskSummaryConfig, reloadData: () -> Unit, - pruneNotifications: () -> Unit + pruneNotifications: () -> Unit, + timeoutAlarm: (Int, UUID) -> Unit ): SchedulerOps.Message? = when (this?.action) { ActionPut -> { @@ -313,6 +351,15 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc null } + ActionAlarmTimeout -> { + requireConfig() + + val task = requireTaskId(ActionAlarmTimeoutExtraTask) + val instance = requireInstanceId(ActionAlarmTimeoutExtraInstance) + timeoutAlarm(task, instance) + null + } + null -> null else -> throw IllegalArgumentException("Unexpected action encountered: [$action]") @@ -338,6 +385,7 @@ class SchedulerService : LifecycleService(), SharedPreferences.OnSharedPreferenc when (val message = obj) { Load -> SchedulerOps.init(loadInit(taskRepository, taskScheduleRepository)) PruneNotifications -> null + is AlarmTimeout -> null is SchedulerOps.Message.Put -> { val taskId = taskRepository.put(message.task) diff --git a/client-android/src/main/java/eventually/client/settings/Settings.kt b/client-android/src/main/java/eventually/client/settings/Settings.kt index aa24b75..23a365a 100644 --- a/client-android/src/main/java/eventually/client/settings/Settings.kt +++ b/client-android/src/main/java/eventually/client/settings/Settings.kt @@ -25,6 +25,11 @@ object Settings { return Duration.ofMinutes(size) } + fun SharedPreferences?.getAlarmTimeout(): Duration { + val minutes = (this?.getString(Keys.AlarmTimeout, Defaults.AlarmTimeout) ?: Defaults.AlarmTimeout).toLong() + return Duration.ofMinutes(minutes) + } + fun SharedPreferences?.getDismissedRetention(): Duration { val days = (this?.getString(Keys.DismissedRetention, Defaults.DismissedRetention) ?: Defaults.DismissedRetention).toLong() @@ -92,6 +97,7 @@ object Settings { const val ShowAllInstances: String = "show_all_instances" const val DismissedRetention: String = "dismissed_retention" const val DismissedLimit: String = "dismissed_limit" + const val AlarmTimeout: String = "alarm_timeout" val SummaryConfig: Set = setOf(SummarySize, DismissedRetention, DismissedLimit) } @@ -106,6 +112,7 @@ object Settings { const val ShowAllInstances: Boolean = false const val DismissedRetention: String = "30" const val DismissedLimit: String = "100" + const val AlarmTimeout: String = "10" } sealed class DateTimeFormat { diff --git a/client-android/src/main/java/eventually/client/ui/components/TaskForm.kt b/client-android/src/main/java/eventually/client/ui/components/TaskForm.kt index 88269b2..38a2cf0 100644 --- a/client-android/src/main/java/eventually/client/ui/components/TaskForm.kt +++ b/client-android/src/main/java/eventually/client/ui/components/TaskForm.kt @@ -1,5 +1,6 @@ package eventually.client.ui.components +import android.app.NotificationManager import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -43,16 +44,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import eventually.client.R +import eventually.client.scheduling.NotificationManagerExtensions +import eventually.client.scheduling.NotificationManagerExtensions.canUseFullScreenAlarms import eventually.client.settings.Settings import eventually.client.ui.LayoutExtensions.isWide import java.time.DayOfWeek @@ -65,7 +71,8 @@ fun TaskForm( firstDayOfWeek: DayOfWeek, @StringRes operation: Int, onOperation: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + fullScreenAlarms: FullScreenAlarms = rememberFullScreenAlarms() ) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val wide = isWide @@ -97,6 +104,8 @@ fun TaskForm( Section(title = R.string.task_details_field_title_context_switch) { ContextSwitchInput(state = state) } + + ExecutionAlarmInput(state = state, fullScreenAlarms = fullScreenAlarms) } } } @@ -385,6 +394,63 @@ private fun ContextSwitchInput(state: TaskFormState) { } } +@Composable +private fun ExecutionAlarmInput(state: TaskFormState, fullScreenAlarms: FullScreenAlarms) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = state.executionAlarm, role = Role.Switch, onValueChange = { state.executionAlarm = it }) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = stringResource(R.string.task_details_field_title_execution_alarm), modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(8.dp)) + Switch(checked = state.executionAlarm, onCheckedChange = null) + } + + if (state.executionAlarm && !fullScreenAlarms.allowed) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val allowDescription = stringResource(R.string.task_details_field_full_screen_alarms_allow_description) + + Text( + text = stringResource(R.string.task_details_field_full_screen_alarms_denied), + modifier = Modifier.weight(1f).semantics { liveRegion = LiveRegionMode.Polite }, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + TextButton( + onClick = fullScreenAlarms.allow, + modifier = Modifier.semantics { contentDescription = allowDescription } + ) { + Text(text = stringResource(R.string.task_details_field_full_screen_alarms_allow)) + } + } + } +} + +class FullScreenAlarms(val allowed: Boolean, val allow: () -> Unit) + +@Composable +fun rememberFullScreenAlarms(): FullScreenAlarms { + val context = LocalContext.current + val manager = remember(context) { context.getSystemService(NotificationManager::class.java) } + var allowed by remember(manager) { mutableStateOf(manager.canUseFullScreenAlarms()) } + + LifecycleResumeEffect(manager) { + allowed = manager.canUseFullScreenAlarms() + onPauseOrDispose {} + } + + return FullScreenAlarms(allowed = allowed) { + NotificationManagerExtensions.Intents.createFullScreenAlarmSettingsIntent(context)?.let { intent -> + runCatching { context.startActivity(intent) } + } + } +} + @Composable private fun ColorInput(state: TaskFormState) { val defaultColor = colorResource(R.color.calendar_event).toArgb() diff --git a/client-android/src/main/java/eventually/client/ui/components/TaskFormState.kt b/client-android/src/main/java/eventually/client/ui/components/TaskFormState.kt index 6d6bd1e..343c935 100644 --- a/client-android/src/main/java/eventually/client/ui/components/TaskFormState.kt +++ b/client-android/src/main/java/eventually/client/ui/components/TaskFormState.kt @@ -36,6 +36,7 @@ class TaskFormState( contextSwitchUnit: ChronoUnit, isActive: Boolean, color: Int, + executionAlarm: Boolean, val expandable: Boolean, expanded: Boolean, errors: Set @@ -55,6 +56,7 @@ class TaskFormState( var contextSwitchUnit: ChronoUnit by mutableStateOf(contextSwitchUnit) var isActive: Boolean by mutableStateOf(isActive) var color: Int by mutableIntStateOf(color) + var executionAlarm: Boolean by mutableStateOf(executionAlarm) var expanded: Boolean by mutableStateOf(expanded) var errors: Set by mutableStateOf(errors) @@ -95,7 +97,8 @@ class TaskFormState( }, contextSwitch = Duration.of(contextSwitchAmount.toLong(), contextSwitchUnit), isActive = isActive, - color = color.takeIf { it != 0 && it != defaultColor } + color = color.takeIf { it != 0 && it != defaultColor }, + executionAlarm = executionAlarm ) private fun repeatingSchedule(zone: ZoneId): Task.Schedule.Repeating = Task.Schedule.Repeating( @@ -152,6 +155,7 @@ class TaskFormState( contextSwitchUnit = contextSwitchUnit, isActive = task?.isActive ?: true, color = task?.color ?: defaultColor, + executionAlarm = task?.executionAlarm ?: false, expandable = task == null, expanded = task != null, errors = emptySet() @@ -176,6 +180,7 @@ class TaskFormState( state.contextSwitchUnit, state.isActive, state.color, + state.executionAlarm, state.expandable, state.expanded, ArrayList(state.errors) @@ -199,9 +204,10 @@ class TaskFormState( contextSwitchUnit = saved[12] as ChronoUnit, isActive = saved[13] as Boolean, color = saved[14] as Int, - expandable = saved[15] as Boolean, - expanded = saved[16] as Boolean, - errors = (saved[17] as List).toSet() + executionAlarm = saved[15] as Boolean, + expandable = saved[16] as Boolean, + expanded = saved[17] as Boolean, + errors = (saved[18] as List).toSet() ) } ) diff --git a/client-android/src/main/java/eventually/client/ui/screens/AlarmScreen.kt b/client-android/src/main/java/eventually/client/ui/screens/AlarmScreen.kt new file mode 100644 index 0000000..c03ecc8 --- /dev/null +++ b/client-android/src/main/java/eventually/client/ui/screens/AlarmScreen.kt @@ -0,0 +1,146 @@ +package eventually.client.ui.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import eventually.client.R +import eventually.client.activities.helpers.DateTimeExtensions.formatAsTime +import eventually.client.settings.Settings +import eventually.client.ui.LayoutExtensions.isLandscape +import eventually.core.model.Task +import java.time.Duration +import java.time.Instant + +@Composable +fun AlarmScreen( + task: Task, + execution: Instant, + dateTimeFormat: Settings.DateTimeFormat, + postponeLength: Duration, + onPostpone: () -> Unit, + onDismiss: () -> Unit +) { + BackHandler {} + + Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { padding -> + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp)) { + if (isLandscape) { + Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + AlarmDetails( + task = task, + execution = execution, + dateTimeFormat = dateTimeFormat, + modifier = Modifier.weight(1f).fillMaxSize() + ) + Spacer(modifier = Modifier.width(24.dp)) + AlarmActions( + postponeLength = postponeLength, + onPostpone = onPostpone, + onDismiss = onDismiss, + modifier = Modifier.weight(1f) + ) + } + } else { + Column(modifier = Modifier.fillMaxSize()) { + AlarmDetails( + task = task, + execution = execution, + dateTimeFormat = dateTimeFormat, + modifier = Modifier.weight(1f).fillMaxWidth() + ) + AlarmActions( + postponeLength = postponeLength, + onPostpone = onPostpone, + onDismiss = onDismiss, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +@Composable +private fun AlarmDetails( + task: Task, + execution: Instant, + dateTimeFormat: Settings.DateTimeFormat, + modifier: Modifier = Modifier +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column( + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_alarm), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text(text = execution.formatAsTime(dateTimeFormat), style = MaterialTheme.typography.displayLarge) + Text(text = task.name, style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center) + Text( + text = task.goal, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + if (task.description.isNotEmpty()) { + Text(text = task.description, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun AlarmActions( + postponeLength: Duration, + onPostpone: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val minutes = postponeLength.toMinutes().toInt() + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + FilledTonalButton(onClick = onPostpone, modifier = Modifier.fillMaxWidth().heightIn(min = ActionHeight)) { + Text(text = pluralStringResource(R.plurals.alarm_action_postpone, minutes, minutes)) + } + + Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth().heightIn(min = ActionHeight)) { + Text(text = stringResource(R.string.notification_instance_action_dismiss_title)) + } + } +} + +private val ActionHeight = 64.dp diff --git a/client-android/src/main/java/eventually/client/ui/screens/SettingsScreen.kt b/client-android/src/main/java/eventually/client/ui/screens/SettingsScreen.kt index 3753650..5c76631 100644 --- a/client-android/src/main/java/eventually/client/ui/screens/SettingsScreen.kt +++ b/client-android/src/main/java/eventually/client/ui/screens/SettingsScreen.kt @@ -79,6 +79,11 @@ fun SettingsScreen(preferences: SharedPreferences, onBack: () -> Unit) { Settings.Keys.PostponeLength, Settings.Defaults.PostponeLength ) + val alarmTimeout = rememberStringPreference( + preferences, + Settings.Keys.AlarmTimeout, + Settings.Defaults.AlarmTimeout + ) val dismissedRetention = rememberStringPreference( preferences, Settings.Keys.DismissedRetention, @@ -152,6 +157,8 @@ fun SettingsScreen(preferences: SharedPreferences, onBack: () -> Unit) { number(postponeLength, R.string.settings_postpone_length_title, R.plurals.settings_postpone_length_hint) + number(alarmTimeout, R.string.settings_alarm_timeout_title, R.plurals.settings_alarm_timeout_hint) + category(R.string.settings_category_dismissed) number( diff --git a/client-android/src/main/res/values/strings.xml b/client-android/src/main/res/values/strings.xml index 24a0912..2de103c 100644 --- a/client-android/src/main/res/values/strings.xml +++ b/client-android/src/main/res/values/strings.xml @@ -43,6 +43,8 @@ Scheduler Service Task Notifications Task Context Switch Notifications + Task Alarms + Missed Task Alarms Background Scheduler Running @@ -52,9 +54,16 @@ [%1$s] - %2$s [%1$s] - %2$s [%s] + Missed alarm: [%1$s] - %2$s Dismiss Postpone + + + Postpone by %1$d minute + Postpone by %1$d minutes + + Task created Task updated @@ -125,6 +134,10 @@ Context Switch (required) Calendar Event Color Enabled + Alarm when due + Full-screen alarms are not allowed; alarms will only show as notifications + Allow + Allow full-screen alarms Schedule Once On @@ -205,6 +218,11 @@ Postpone a task by %1$s minute Postpone a task by %1$s minutes + Alarm timeout + + Stop ringing alarms after %1$s minute + Stop ringing alarms after %1$s minutes + Retention Keep dismissed instances from the last day diff --git a/client-android/src/test/java/eventually/test/client/activities/AlarmActivitySpec.kt b/client-android/src/test/java/eventually/test/client/activities/AlarmActivitySpec.kt new file mode 100644 index 0000000..f99d739 --- /dev/null +++ b/client-android/src/test/java/eventually/test/client/activities/AlarmActivitySpec.kt @@ -0,0 +1,247 @@ +package eventually.test.client.activities + +import android.app.Application +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.view.WindowManager +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.core.app.ApplicationProvider +import eventually.client.activities.AlarmActivity +import eventually.client.activities.AlarmActivity.Companion.toAlarm +import eventually.client.scheduling.NotificationManagerExtensions +import eventually.client.scheduling.NotificationManagerExtensions.createInstanceNotificationChannels +import eventually.client.scheduling.NotificationManagerExtensions.isInstanceExecutionAlarmActive +import eventually.client.scheduling.NotificationManagerExtensions.putInstanceExecutionAlarm +import eventually.client.scheduling.SchedulerService +import eventually.client.serialization.Extras.requireDuration +import eventually.client.serialization.Extras.requireInstanceId +import eventually.client.serialization.Extras.requireTaskId +import eventually.core.model.Task +import eventually.core.model.TaskInstance +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Config.OLDEST_SDK]) +class AlarmActivitySpec { + @get:Rule + val composeRule = createEmptyComposeRule() + + private val context = ApplicationProvider.getApplicationContext() + private val notificationManager = context.getSystemService(NotificationManager::class.java) + + @Before + fun createNotificationChannels() { + notificationManager.createInstanceNotificationChannels(context) + } + + @Test + fun showRingingAlarms() { + ring(task, instance) + + val activity = launch(task, instance).get() + + composeRule.onNodeWithText("test-task").assertExists() + composeRule.onNodeWithText("test-goal").assertExists() + composeRule.onNodeWithText("Postpone by 10 minutes").assertExists() + composeRule.onNodeWithText("Dismiss").assertExists() + + composeRule.mainClock.advanceTimeBy(RingingChecks) + + assertThat(activity.isFinishing, equalTo(false)) + composeRule.onNodeWithText("test-task").assertExists() + } + + @Test + fun handleAlarmsOnlyOnce() { + ring(task, instance) + + val activity = launch(task, instance).get() + + composeRule.onNodeWithText("Postpone by 10 minutes").performClick() + composeRule.onNodeWithText("Postpone by 10 minutes").performClick() + composeRule.onNodeWithText("Dismiss").performClick() + composeRule.waitForIdle() + + val application = shadowOf(context as Application) + assertThat(application.nextStartedService.action, equalTo(SchedulerService.ActionPostpone)) + assertThat(application.nextStartedService, equalTo(null)) + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun showOverLockScreen() { + val activity = launch(task, instance).get() + + val flags = activity.window.attributes.flags + val expected = WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + + assertThat(flags and expected, equalTo(expected)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + fun showOverLockScreenOnNewerVersions() { + val activity = launch(task, instance).get() + + assertThat(shadowOf(activity).showWhenLocked, equalTo(true)) + assertThat(shadowOf(activity).turnScreenOn, equalTo(true)) + assertThat( + activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, + equalTo(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + ) + } + + @Test + fun dismissAlarms() { + ring(task, instance) + + val activity = launch(task, instance).get() + + composeRule.onNodeWithText("Dismiss").performClick() + composeRule.waitForIdle() + + val started = shadowOf(context as Application).nextStartedService + assertThat(started.action, equalTo(SchedulerService.ActionDismiss)) + assertThat(started.requireTaskId(SchedulerService.ActionDismissExtraTask), equalTo(task.id)) + assertThat(started.requireInstanceId(SchedulerService.ActionDismissExtraInstance), equalTo(instance.id)) + + assertThat(notificationManager.isInstanceExecutionAlarmActive(task.id, instance.id), equalTo(false)) + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun postponeAlarms() { + ring(task, instance) + + val activity = launch(task, instance).get() + + composeRule.onNodeWithText("Postpone by 10 minutes").performClick() + composeRule.waitForIdle() + + val started = shadowOf(context as Application).nextStartedService + assertThat(started.action, equalTo(SchedulerService.ActionPostpone)) + assertThat(started.requireTaskId(SchedulerService.ActionPostponeExtraTask), equalTo(task.id)) + assertThat(started.requireInstanceId(SchedulerService.ActionPostponeExtraInstance), equalTo(instance.id)) + assertThat(started.requireDuration(SchedulerService.ActionPostponeExtraBy), equalTo(Duration.ofMinutes(10))) + + assertThat(notificationManager.isInstanceExecutionAlarmActive(task.id, instance.id), equalTo(false)) + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun closeWhenAlarmsStopRinging() { + ring(task, instance) + + val activity = launch(task, instance).get() + composeRule.onNodeWithText("test-task").assertExists() + + composeRule.mainClock.advanceTimeBy(RingingChecks) + assertThat(activity.isFinishing, equalTo(false)) + + notificationManager.cancel(NotificationManagerExtensions.getInstanceNotificationId(task.id, instance.id)) + + composeRule.mainClock.advanceTimeBy(RingingCheck) + assertThat(activity.isFinishing, equalTo(true)) + + assertThat(shadowOf(context as Application).nextStartedService, equalTo(null)) + } + + @Test + fun closeAlarmsThatAreNotRinging() { + val activity = launch(task, instance).get() + + composeRule.mainClock.advanceTimeBy(RingingCheck) + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun closeWithoutAlarms() { + val activity = Robolectric.buildActivity(AlarmActivity::class.java, Intent(context, AlarmActivity::class.java)) + .setup() + .get() + + assertThat(activity.isFinishing, equalTo(true)) + } + + @Test + fun showNewAlarms() { + val otherTask = task.copy(id = 43, name = "other-task") + val otherInstance = TaskInstance(instant = Instant.now().truncatedTo(ChronoUnit.SECONDS)) + + ring(task, instance) + ring(otherTask, otherInstance) + + val controller = launch(task, instance) + composeRule.onNodeWithText("test-task").assertExists() + + controller.newIntent(alarmIntent(otherTask, otherInstance)) + + composeRule.onNodeWithText("other-task").assertExists() + composeRule.onNodeWithText("test-task").assertDoesNotExist() + assertThat(controller.get().intent.toAlarm()?.task, equalTo(otherTask)) + + composeRule.mainClock.advanceTimeBy(RingingChecks) + assertThat(controller.get().isFinishing, equalTo(false)) + } + + @Test + fun ignoreInvalidNewAlarms() { + ring(task, instance) + + val controller = launch(task, instance) + + controller.newIntent(Intent(context, AlarmActivity::class.java)) + + composeRule.onNodeWithText("test-task").assertExists() + assertThat(controller.get().isFinishing, equalTo(false)) + assertThat(controller.get().intent.toAlarm()?.task, equalTo(task)) + } + + private fun ring(task: Task, instance: TaskInstance) { + notificationManager.putInstanceExecutionAlarm(context, task, instance, Duration.ofMinutes(10)) + } + + private fun launch(task: Task, instance: TaskInstance): ActivityController = + Robolectric.buildActivity(AlarmActivity::class.java, alarmIntent(task, instance)).setup() + + private fun alarmIntent(task: Task, instance: TaskInstance): Intent = + shadowOf(NotificationManagerExtensions.Intents.createAlarmIntent(context, task, instance)).savedIntent + + private val task = Task( + id = 42, + name = "test-task", + description = "test-description", + goal = "test-goal", + schedule = Task.Schedule.Once(instant = Instant.now().truncatedTo(ChronoUnit.SECONDS)), + contextSwitch = Duration.ofMinutes(5), + isActive = true, + color = null, + executionAlarm = true + ) + + private val instance = TaskInstance(instant = Instant.now().truncatedTo(ChronoUnit.SECONDS)) + + companion object { + private const val RingingCheck: Long = 1000 + private const val RingingChecks: Long = 3 * RingingCheck + } +} diff --git a/client-android/src/test/java/eventually/test/client/persistence/ConvertersSpec.kt b/client-android/src/test/java/eventually/test/client/persistence/ConvertersSpec.kt index 7a99b39..4468786 100644 --- a/client-android/src/test/java/eventually/test/client/persistence/ConvertersSpec.kt +++ b/client-android/src/test/java/eventually/test/client/persistence/ConvertersSpec.kt @@ -303,7 +303,8 @@ class ConvertersSpec { ), contextSwitch = Duration.ofMinutes(5), isActive = true, - color = 1 + color = 1, + executionAlarm = true ) val converted = Converters.taskToString(original) @@ -318,7 +319,8 @@ class ConvertersSpec { "schedule":{"type":"repeating","start":${start.epochSecond},"every":{"unit":"seconds","amount":1200},"days":[4,6,3]}, "context_switch":300, "is_active":true, - "color":1 + "color":1, + "execution_alarm":true }""".replace("\n", "").replace(" ", "") ) ) @@ -339,7 +341,8 @@ class ConvertersSpec { ), contextSwitch = Duration.ofMinutes(5), isActive = true, - color = 1 + color = 1, + executionAlarm = true ) val entity = TaskEntity( @@ -350,7 +353,8 @@ class ConvertersSpec { schedule = task.schedule, contextSwitch = task.contextSwitch, isActive = task.isActive, - color = 1 + color = 1, + executionAlarm = true ) assertThat(Converters.taskToEntity(task), equalTo(entity)) @@ -447,7 +451,8 @@ class ConvertersSpec { ), contextSwitch = Duration.ofMinutes(5), isActive = true, - color = 1 + color = 1, + executionAlarm = true ) val instant = Instant.now().truncatedTo(ChronoUnit.SECONDS) @@ -486,6 +491,7 @@ class ConvertersSpec { val converted = Converters.dataExportToString(original) assertThat(converted, containsString(""""tasks":[{"id":42""")) + assertThat(converted, containsString(""""execution_alarm":true""")) assertThat(converted, containsString(""""schedules":[{"task":42""")) assertThat(converted, containsString(""""notifications":[{"id":0,"task":42""")) @@ -584,6 +590,34 @@ class ConvertersSpec { assertThat(Converters.stringToDataExport(export), equalTo(expected)) } + @Test + fun convertDataExportsWithoutExecutionAlarms() { + val task = """{"id":42,"name":"test-task","description":"","goal":"test-goal",""" + + """"schedule":{"type":"once","instant":42},"context_switch":300,"is_active":true,"color":1}""" + + val export = """{"tasks":[$task],"schedules":[],"notifications":[]}""" + + assertThat( + Converters.stringToDataExport(export).tasks, + equalTo( + listOf( + Task( + id = 42, + name = "test-task", + description = "", + goal = "test-goal", + schedule = Task.Schedule.Once(instant = Instant.ofEpochSecond(42)), + contextSwitch = Duration.ofMinutes(5), + isActive = true, + color = 1, + executionAlarm = false + ) + ) + ) + ) + assertThat(Converters.stringToTask(task).executionAlarm, equalTo(false)) + } + private fun exportWith(every: String = """{"unit":"days","amount":1}""", contextSwitch: Long = 300): String { val task = """{"id":42,"name":"test-task","description":"","goal":"test-goal",""" + """"schedule":{"type":"repeating","start":0,"every":$every,"days":[1]},""" + diff --git a/client-android/src/test/java/eventually/test/client/scheduling/AlarmManagerExtensionsSpec.kt b/client-android/src/test/java/eventually/test/client/scheduling/AlarmManagerExtensionsSpec.kt index 898751a..71b63b2 100644 --- a/client-android/src/test/java/eventually/test/client/scheduling/AlarmManagerExtensionsSpec.kt +++ b/client-android/src/test/java/eventually/test/client/scheduling/AlarmManagerExtensionsSpec.kt @@ -4,18 +4,29 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.os.Build +import androidx.test.core.app.ApplicationProvider +import eventually.client.scheduling.AlarmManagerExtensions.deleteAlarmTimeout import eventually.client.scheduling.AlarmManagerExtensions.deleteEvaluationAlarm +import eventually.client.scheduling.AlarmManagerExtensions.putAlarmTimeout import eventually.client.scheduling.AlarmManagerExtensions.putEvaluationAlarm +import eventually.client.scheduling.SchedulerService +import eventually.client.serialization.Extras.requireInstanceId +import eventually.client.serialization.Extras.requireTaskId import io.mockk.confirmVerified import io.mockk.every import io.mockk.justRun import io.mockk.mockk +import io.mockk.slot import io.mockk.verify +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.time.Instant +import java.util.UUID @RunWith(RobolectricTestRunner::class) @Config(sdk = [Config.OLDEST_SDK]) @@ -87,6 +98,85 @@ class AlarmManagerExtensionsSpec { confirmVerified(manager) } + @Test + fun createAlarmTimeouts() { + val context = ApplicationProvider.getApplicationContext() + val task = 42 + val instance = UUID.randomUUID() + val instant = Instant.now().plusSeconds(600) + + val intent = slot() + + val manager = mockk() + justRun { manager.setExactAndAllowWhileIdle(any(), any(), capture(intent)) } + + manager.putAlarmTimeout(context, task, instance, instant) + + verify(exactly = 1) { + manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, instant.toEpochMilli(), any()) + } + confirmVerified(manager) + + val pending = shadowOf(intent.captured) + assertThat(pending.isServiceIntent, equalTo(true)) + assertThat(pending.savedIntent.component?.className, equalTo(SchedulerService::class.java.name)) + assertThat(pending.savedIntent.action, equalTo(SchedulerService.ActionAlarmTimeout)) + assertThat(pending.savedIntent.requireTaskId(SchedulerService.ActionAlarmTimeoutExtraTask), equalTo(task)) + assertThat(pending.savedIntent.requireInstanceId(SchedulerService.ActionAlarmTimeoutExtraInstance), equalTo(instance)) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + fun createInexactAlarmTimeoutsWhenExactAlarmsAreNotAllowed() { + val context = ApplicationProvider.getApplicationContext() + + val manager = mockk() + every { manager.canScheduleExactAlarms() } returns false + justRun { manager.setAndAllowWhileIdle(any(), any(), any()) } + + manager.putAlarmTimeout(context, 42, UUID.randomUUID(), Instant.now().plusSeconds(600)) + + verify(exactly = 1) { manager.canScheduleExactAlarms() } + verify(exactly = 1) { manager.setAndAllowWhileIdle(any(), any(), any()) } + + confirmVerified(manager) + } + + @Test + fun removeAlarmTimeouts() { + val context = ApplicationProvider.getApplicationContext() + val task = 42 + val instance = UUID.randomUUID() + + val created = slot() + val removed = slot() + + val manager = mockk() + justRun { manager.setExactAndAllowWhileIdle(any(), any(), capture(created)) } + justRun { manager.cancel(capture(removed)) } + + manager.putAlarmTimeout(context, task, instance, Instant.now().plusSeconds(600)) + manager.deleteAlarmTimeout(context, task, instance) + + verify(exactly = 1) { manager.setExactAndAllowWhileIdle(any(), any(), any()) } + verify(exactly = 1) { manager.cancel(any()) } + confirmVerified(manager) + + assertThat(removed.captured, equalTo(created.captured)) + assertThat(shadowOf(created.captured).isCanceled, equalTo(true)) + } + + @Test + fun ignoreMissingAlarmTimeouts() { + val context = ApplicationProvider.getApplicationContext() + + val manager = mockk() + + manager.deleteAlarmTimeout(context, 42, UUID.randomUUID()) + + confirmVerified(manager) + } + @Test fun removeEvaluationAlarms() { val context = mockk(relaxed = true) diff --git a/client-android/src/test/java/eventually/test/client/scheduling/NotificationManagerExtensionsSpec.kt b/client-android/src/test/java/eventually/test/client/scheduling/NotificationManagerExtensionsSpec.kt index ada5af3..4036268 100644 --- a/client-android/src/test/java/eventually/test/client/scheduling/NotificationManagerExtensionsSpec.kt +++ b/client-android/src/test/java/eventually/test/client/scheduling/NotificationManagerExtensionsSpec.kt @@ -6,18 +6,29 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.os.Build +import android.provider.Settings as AndroidSettings import android.service.notification.StatusBarNotification import androidx.test.core.app.ApplicationProvider +import eventually.client.activities.AlarmActivity +import eventually.client.activities.AlarmActivity.Companion.toAlarm import eventually.client.activities.MainActivity +import eventually.client.activities.receivers.DismissReceiver import eventually.client.activities.receivers.PostponeReceiver import eventually.client.persistence.notifications.NotificationEntity import eventually.client.persistence.notifications.NotificationRepository import eventually.client.scheduling.NotificationManagerExtensions +import eventually.client.scheduling.NotificationManagerExtensions.canUseFullScreenAlarms import eventually.client.scheduling.NotificationManagerExtensions.createInstanceNotificationChannels import eventually.client.scheduling.NotificationManagerExtensions.deleteInstanceNotifications import eventually.client.scheduling.NotificationManagerExtensions.pruneInstanceNotifications import eventually.client.scheduling.NotificationManagerExtensions.putInstanceContextSwitchNotification +import eventually.client.scheduling.NotificationManagerExtensions.putInstanceExecutionAlarm import eventually.client.scheduling.NotificationManagerExtensions.putInstanceExecutionNotification +import eventually.client.scheduling.NotificationManagerExtensions.putInstanceMissedAlarmNotification +import eventually.client.scheduling.NotificationManagerExtensions.replaceInstanceExecutionAlarm import eventually.client.serialization.Extras.requireDuration import eventually.client.serialization.Extras.requireTaskId import eventually.client.settings.Settings @@ -35,7 +46,9 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.CoreMatchers.endsWith import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.startsWith import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -47,6 +60,7 @@ import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneOffset +import java.time.temporal.ChronoUnit import java.util.Objects import java.util.UUID @@ -65,7 +79,185 @@ class NotificationManagerExtensionsSpec { manager.createInstanceNotificationChannels(context) - assertThat(channels.toList().size, equalTo(3)) + assertThat(channels.toList().size, equalTo(5)) + + val config = NotificationManagerExtensions.Config.Default + + val execution = channels.single { it.id == config.instanceExecutionChannel.id } + assertThat(execution.audioAttributes.usage, equalTo(AudioAttributes.USAGE_NOTIFICATION)) + + val alarm = channels.single { it.id == config.instanceExecutionAlarmChannel.id } + assertThat(alarm.importance, equalTo(NotificationManager.IMPORTANCE_HIGH)) + assertThat(alarm.sound, equalTo(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))) + assertThat(alarm.audioAttributes.usage, equalTo(AudioAttributes.USAGE_ALARM)) + assertThat(alarm.shouldVibrate(), equalTo(true)) + + val missed = channels.single { it.id == config.instanceMissedAlarmChannel.id } + assertThat(missed.importance, equalTo(NotificationManager.IMPORTANCE_DEFAULT)) + assertThat(missed.audioAttributes.usage, equalTo(AudioAttributes.USAGE_NOTIFICATION)) + } + + @Test + fun createInstanceExecutionAlarms() { + val id = slot() + val notification = slot() + + val context = ApplicationProvider.getApplicationContext() + + val manager = mockk() + justRun { manager.notify(capture(id), capture(notification)) } + + manager.putInstanceExecutionAlarm(context, task, instance, timeout = Duration.ofMinutes(10)) + + val alarm = notification.captured + + assertThat(id.captured, equalTo(Objects.hash(task.id, instance.id) and Int.MAX_VALUE)) + assertThat(alarm.channelId, equalTo(NotificationManagerExtensions.Config.Default.instanceExecutionAlarmChannel.id)) + assertThat(alarm.category, equalTo(Notification.CATEGORY_ALARM)) + assertThat(alarm.flags and Notification.FLAG_INSISTENT, equalTo(Notification.FLAG_INSISTENT)) + assertThat(alarm.flags and Notification.FLAG_ONGOING_EVENT, equalTo(Notification.FLAG_ONGOING_EVENT)) + assertThat(alarm.flags and Notification.FLAG_AUTO_CANCEL, equalTo(0)) + assertThat(alarm.timeoutAfter, equalTo(Duration.ofMinutes(11).toMillis())) + + assertThat(alarm.extras.getString(Notification.EXTRA_TITLE), containsString(task.name)) + assertThat(alarm.extras.getString(Notification.EXTRA_TEXT), containsString(task.goal)) + assertThat(alarm.extras.getString(Notification.EXTRA_TEXT), containsString(task.description)) + + assertThat(alarm.fullScreenIntent, equalTo(alarm.contentIntent)) + assertThat(shadowOf(alarm.fullScreenIntent).savedIntent.component?.className, equalTo(AlarmActivity::class.java.name)) + + assertThat(alarm.actions.size, equalTo(2)) + assertThat(alarm.actions[0].title, equalTo("Dismiss")) + assertThat(alarm.actions[1].title, equalTo("Postpone")) + } + + @Test + fun createInstanceMissedAlarmNotifications() { + val id = slot() + val notification = slot() + + val context = ApplicationProvider.getApplicationContext() + + val manager = mockk() + justRun { manager.notify(capture(id), capture(notification)) } + + manager.putInstanceMissedAlarmNotification(context, task, instance) + + val missed = notification.captured + + assertThat(id.captured, equalTo(Objects.hash(task.id, instance.id) and Int.MAX_VALUE)) + assertThat(missed.channelId, equalTo(NotificationManagerExtensions.Config.Default.instanceMissedAlarmChannel.id)) + assertThat(missed.extras.getString(Notification.EXTRA_TITLE), startsWith("Missed alarm: [")) + assertThat(missed.extras.getString(Notification.EXTRA_TITLE), endsWith("] - ${task.name}")) + assertThat(missed.extras.getString(Notification.EXTRA_TEXT), containsString(task.goal)) + assertThat(missed.flags and Notification.FLAG_AUTO_CANCEL, equalTo(Notification.FLAG_AUTO_CANCEL)) + assertThat(missed.flags and Notification.FLAG_INSISTENT, equalTo(0)) + assertThat(missed.fullScreenIntent, equalTo(null)) + + val pending = shadowOf(missed.contentIntent) + assertThat(pending.savedIntent.component?.className, equalTo(MainActivity::class.java.name)) + assertThat(pending.savedIntent.requireTaskId(MainActivity.ExtraTask), equalTo(task.id)) + + assertThat(missed.actions.size, equalTo(2)) + assertThat(missed.actions[0].title, equalTo("Dismiss")) + assertThat(missed.actions[1].title, equalTo("Postpone")) + } + + @Test + fun replaceRingingExecutionAlarmsWithMissedAlarmNotifications() { + val id = Objects.hash(task.id, instance.id) and Int.MAX_VALUE + val notification = slot() + + val context = ApplicationProvider.getApplicationContext() + + val manager = mockk() + every { manager.activeNotifications } returns arrayOf( + statusBarNotification(id = id, channel = NotificationManagerExtensions.Config.Default.instanceExecutionAlarmChannel.id) + ) + justRun { manager.cancel(any()) } + justRun { manager.notify(any(), capture(notification)) } + + manager.replaceInstanceExecutionAlarm(context, task, instance) + + verify(exactly = 1) { manager.activeNotifications } + verify(exactly = 1) { manager.cancel(id) } + verify(exactly = 1) { manager.notify(id, any()) } + confirmVerified(manager) + + assertThat(notification.captured.channelId, equalTo(NotificationManagerExtensions.Config.Default.instanceMissedAlarmChannel.id)) + } + + @Test + fun notReplaceExecutionAlarmsThatAreNoLongerRinging() { + val id = Objects.hash(task.id, instance.id) and Int.MAX_VALUE + + val context = ApplicationProvider.getApplicationContext() + val config = NotificationManagerExtensions.Config.Default + + listOf( + emptyArray(), + arrayOf(statusBarNotification(id = id, channel = config.instanceExecutionChannel.id)), + arrayOf(statusBarNotification(id = id, channel = config.instanceMissedAlarmChannel.id)), + arrayOf(statusBarNotification(id = id + 1, channel = config.instanceExecutionAlarmChannel.id)) + ).forEach { active -> + val manager = mockk() + every { manager.activeNotifications } returns active + + manager.replaceInstanceExecutionAlarm(context, task, instance) + + verify(exactly = 1) { manager.activeNotifications } + confirmVerified(manager) + } + } + + @Test + fun openAlarmScreenFromExecutionAlarms() { + val context = ApplicationProvider.getApplicationContext() + + val pending = shadowOf(NotificationManagerExtensions.Intents.createAlarmIntent(context, task, instance)) + + assertThat(pending.isActivityIntent, equalTo(true)) + assertThat(pending.savedIntent.component?.className, equalTo(AlarmActivity::class.java.name)) + assertThat(pending.savedIntent.flags and Intent.FLAG_ACTIVITY_NEW_TASK, equalTo(Intent.FLAG_ACTIVITY_NEW_TASK)) + assertThat( + pending.savedIntent.toAlarm(), + equalTo( + AlarmActivity.Alarm( + task = task, + instance = instance.id, + execution = instance.execution().truncatedTo(ChronoUnit.MILLIS) + ) + ) + ) + } + + @Test + fun allowFullScreenAlarmsBeforeAndroid14() { + val manager = mockk() + + assertThat(manager.canUseFullScreenAlarms(), equalTo(true)) + assertThat( + NotificationManagerExtensions.Intents.createFullScreenAlarmSettingsIntent(ApplicationProvider.getApplicationContext()), + equalTo(null) + ) + + confirmVerified(manager) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) + fun checkFullScreenAlarmsOnAndroid14() { + val manager = mockk() + every { manager.canUseFullScreenIntent() } returnsMany listOf(true, false) + + assertThat(manager.canUseFullScreenAlarms(), equalTo(true)) + assertThat(manager.canUseFullScreenAlarms(), equalTo(false)) + + val context = ApplicationProvider.getApplicationContext() + val intent = NotificationManagerExtensions.Intents.createFullScreenAlarmSettingsIntent(context) + + assertThat(intent?.action, equalTo(AndroidSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)) + assertThat(intent?.data?.schemeSpecificPart, equalTo(context.packageName)) } @Test @@ -126,6 +318,22 @@ class NotificationManagerExtensionsSpec { } } + @Test + fun deliverInstanceActionsOnlyToThisApp() { + val context = ApplicationProvider.getApplicationContext() + + val dismiss = shadowOf(NotificationManagerExtensions.Intents.createDismissIntent(context, task, instance)) + val postpone = shadowOf(NotificationManagerExtensions.Intents.createPostponeIntent(context, task, instance)) + + assertThat(dismiss.isBroadcastIntent, equalTo(true)) + assertThat(dismiss.savedIntent.action, equalTo(DismissReceiver.Action)) + assertThat(dismiss.savedIntent.`package`, equalTo(context.packageName)) + + assertThat(postpone.isBroadcastIntent, equalTo(true)) + assertThat(postpone.savedIntent.action, equalTo(PostponeReceiver.Action)) + assertThat(postpone.savedIntent.`package`, equalTo(context.packageName)) + } + @Test fun updateInstanceActionExtrasWhenRecreated() { val context = ApplicationProvider.getApplicationContext() @@ -244,6 +452,16 @@ class NotificationManagerExtensionsSpec { private fun statusBarNotification(id: Int): StatusBarNotification = mockk().also { every { it.id } returns id } + private fun statusBarNotification(id: Int, channel: String): StatusBarNotification { + val notification = mockk() + every { notification.channelId } returns channel + + return mockk().also { + every { it.id } returns id + every { it.notification } returns notification + } + } + companion object { private const val ExpectedActivityFlags: Int = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP diff --git a/client-android/src/test/java/eventually/test/client/scheduling/NotificationQueueSpec.kt b/client-android/src/test/java/eventually/test/client/scheduling/NotificationQueueSpec.kt index 1b60d26..03539b2 100644 --- a/client-android/src/test/java/eventually/test/client/scheduling/NotificationQueueSpec.kt +++ b/client-android/src/test/java/eventually/test/client/scheduling/NotificationQueueSpec.kt @@ -1,13 +1,17 @@ package eventually.test.client.scheduling import android.app.AlarmManager +import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import androidx.test.core.app.ApplicationProvider import eventually.client.persistence.notifications.NotificationEntity import eventually.client.persistence.notifications.NotificationRepository +import eventually.client.scheduling.NotificationManagerExtensions import eventually.client.scheduling.NotificationQueue +import eventually.client.settings.Settings +import eventually.client.settings.Settings.defaultPreferences import eventually.core.model.Task import eventually.core.model.Task.Schedule.Repeating.Interval.Companion.toInterval import eventually.core.model.TaskInstance @@ -17,6 +21,7 @@ import io.mockk.coVerify import io.mockk.confirmVerified import io.mockk.justRun import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.equalTo @@ -252,6 +257,100 @@ class NotificationQueueSpec { confirmVerified(notificationManager) } + @Test + fun releaseExecutionAlarms() { + val model = mockk() + coEvery { model.put(task = any(), instance = any(), type = any()) } returns 42 + coEvery { model.delete(task = any(), instance = any()) } returns Unit + + val context = ApplicationProvider.getApplicationContext() + context.defaultPreferences().edit().putString(Settings.Keys.AlarmTimeout, "7").commit() + + val alarmTask = task.copy(executionAlarm = true) + + val notification = slot() + val triggerAt = slot() + val created = slot() + val removed = slot() + + val alarmManager = mockk() + justRun { alarmManager.setExactAndAllowWhileIdle(any(), capture(triggerAt), capture(created)) } + justRun { alarmManager.cancel(capture(removed)) } + + val notificationManager = mockk() + justRun { notificationManager.notify(any(), capture(notification)) } + justRun { notificationManager.cancel(any()) } + + val before = Instant.now() + + runBlocking { + NotificationQueue() + .apply { enqueue(listOf(SchedulerOps.Notification.PutInstanceExecutionNotification(alarmTask, instance))) } + .release(context, alarmManager, notificationManager, model) + } + + val after = Instant.now() + + assertThat( + notification.captured.channelId, + equalTo(NotificationManagerExtensions.Config.Default.instanceExecutionAlarmChannel.id) + ) + assertThat(triggerAt.captured >= before.plus(Duration.ofMinutes(7)).toEpochMilli(), equalTo(true)) + assertThat(triggerAt.captured <= after.plus(Duration.ofMinutes(7)).toEpochMilli(), equalTo(true)) + + runBlocking { + NotificationQueue() + .apply { enqueue(listOf(SchedulerOps.Notification.DeleteInstanceNotifications(alarmTask.id, instance.id))) } + .release(context, alarmManager, notificationManager, model) + } + + assertThat(removed.captured, equalTo(created.captured)) + + coVerify(exactly = 1) { model.put(task = alarmTask, instance = instance, type = "execution") } + coVerify(exactly = 1) { model.delete(task = alarmTask.id, instance = instance.id) } + + verify(exactly = 1) { alarmManager.setExactAndAllowWhileIdle(any(), any(), any()) } + verify(exactly = 1) { alarmManager.cancel(any()) } + + verify(exactly = 1) { notificationManager.notify(any(), any()) } + verify(exactly = 2) { notificationManager.cancel(any()) } + + confirmVerified(model) + confirmVerified(alarmManager) + confirmVerified(notificationManager) + } + + @Test + fun releaseExecutionNotificationsForTasksWithoutAlarms() { + val model = mockk() + coEvery { model.put(task = any(), instance = any(), type = any()) } returns 42 + + val context = ApplicationProvider.getApplicationContext() + + val notification = slot() + + val alarmManager = mockk() + + val notificationManager = mockk() + justRun { notificationManager.notify(any(), capture(notification)) } + + runBlocking { + NotificationQueue() + .apply { enqueue(listOf(executionNotification)) } + .release(context, alarmManager, notificationManager, model) + } + + assertThat( + notification.captured.channelId, + equalTo(NotificationManagerExtensions.Config.Default.instanceExecutionChannel.id) + ) + + coVerify(exactly = 1) { model.put(task = task, instance = instance, type = "execution") } + + confirmVerified(model) + confirmVerified(alarmManager) + } + private val task = Task( id = 42, name = "test-task", diff --git a/client-android/src/test/java/eventually/test/client/scheduling/SchedulerServiceSpec.kt b/client-android/src/test/java/eventually/test/client/scheduling/SchedulerServiceSpec.kt index b125c7f..0e84cc5 100644 --- a/client-android/src/test/java/eventually/test/client/scheduling/SchedulerServiceSpec.kt +++ b/client-android/src/test/java/eventually/test/client/scheduling/SchedulerServiceSpec.kt @@ -1,12 +1,16 @@ package eventually.test.client.scheduling +import android.app.Notification +import android.app.NotificationManager import android.content.Context import android.content.Intent import android.os.Message +import android.service.notification.StatusBarNotification import androidx.test.core.app.ApplicationProvider import eventually.client.persistence.schedules.TaskScheduleEntity import eventually.client.persistence.schedules.TaskScheduleRepository import eventually.client.persistence.tasks.TaskRepository +import eventually.client.scheduling.NotificationManagerExtensions import eventually.client.scheduling.NotificationQueue import eventually.client.scheduling.SchedulerService import eventually.client.scheduling.SchedulerService.Companion.applyTo @@ -27,7 +31,11 @@ import eventually.core.scheduling.SchedulerOps import io.mockk.coEvery import io.mockk.coVerify import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.justRun import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat @@ -39,6 +47,7 @@ import org.robolectric.annotation.Config import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean @RunWith(RobolectricTestRunner::class) @@ -51,7 +60,7 @@ class SchedulerServiceSpec { assertThat( context.createIntent(SchedulerService.ActionPut) { it.putTask(SchedulerService.ActionPutExtraTask, task) - }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.Put(task) ) @@ -60,7 +69,7 @@ class SchedulerServiceSpec { assertThat( context.createIntent(SchedulerService.ActionDelete) { it.putTaskId(SchedulerService.ActionDeleteExtraTask, task.id) - }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.Delete(task.id) ) @@ -70,7 +79,7 @@ class SchedulerServiceSpec { context.createIntent(SchedulerService.ActionDismiss) { it.putTaskId(SchedulerService.ActionDismissExtraTask, task.id) it.putInstanceId(SchedulerService.ActionDismissExtraInstance, instance.id) - }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.Dismiss(task.id, instance.id) ) @@ -80,7 +89,7 @@ class SchedulerServiceSpec { context.createIntent(SchedulerService.ActionUndoDismiss) { it.putTaskId(SchedulerService.ActionUndoDismissExtraTask, task.id) it.putInstant(SchedulerService.ActionUndoDismissExtraInstant, instance.instant) - }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.UndoDismiss(task.id, instance.instant) ) @@ -91,14 +100,14 @@ class SchedulerServiceSpec { it.putTaskId(SchedulerService.ActionPostponeExtraTask, task.id) it.putInstanceId(SchedulerService.ActionPostponeExtraInstance, instance.id) it.putDuration(SchedulerService.ActionPostponeExtraBy, postponeBy) - }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.Postpone(task.id, instance.id, postponeBy) ) ) assertThat( - context.createIntent(SchedulerService.ActionEvaluate) { it }.toSchedulerMessage(::requireConfig, ::reload, ::prune), + context.createIntent(SchedulerService.ActionEvaluate) { it }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo( SchedulerOps.Message.Evaluate(config) ) @@ -107,7 +116,12 @@ class SchedulerServiceSpec { val reloaded = AtomicBoolean(false) assertThat( context.createIntent(SchedulerService.ActionReload) { it } - .toSchedulerMessage(::requireConfig, reloadData = { reloaded.set(true) }, pruneNotifications = ::prune), + .toSchedulerMessage( + ::requireConfig, + reloadData = { reloaded.set(true) }, + pruneNotifications = ::prune, + timeoutAlarm = ::timeout + ), equalTo(null) ) assertThat(reloaded.get(), equalTo(true)) @@ -115,18 +129,28 @@ class SchedulerServiceSpec { val pruned = AtomicBoolean(false) assertThat( context.createIntent(SchedulerService.ActionPruneNotifications) { it } - .toSchedulerMessage(::requireConfig, ::reload, pruneNotifications = { pruned.set(true) }), + .toSchedulerMessage(::requireConfig, ::reload, pruneNotifications = { pruned.set(true) }, timeoutAlarm = ::timeout), equalTo(null) ) assertThat(pruned.get(), equalTo(true)) + val timedOut = mutableListOf>() assertThat( - null.toSchedulerMessage(::requireConfig, ::reload, ::prune), + context.createIntent(SchedulerService.ActionAlarmTimeout) { + it.putTaskId(SchedulerService.ActionAlarmTimeoutExtraTask, task.id) + it.putInstanceId(SchedulerService.ActionAlarmTimeoutExtraInstance, instance.id) + }.toSchedulerMessage(::requireConfig, ::reload, ::prune, timeoutAlarm = { t, i -> timedOut.add(t to i) }), + equalTo(null) + ) + assertThat(timedOut.toList(), equalTo(listOf(task.id to instance.id))) + + assertThat( + null.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout), equalTo(null) ) try { - context.createIntent("other") { it }.toSchedulerMessage(::requireConfig, ::reload, ::prune) + context.createIntent("other") { it }.toSchedulerMessage(::requireConfig, ::reload, ::prune, ::timeout) fail("Excepted failure but none encountered") } catch (e: IllegalArgumentException) { assertThat(e.message, equalTo("Unexpected action encountered: [other]")) @@ -237,6 +261,12 @@ class SchedulerServiceSpec { equalTo(null) ) + assertThat( + Message().apply { obj = SchedulerService.AlarmTimeout(task.id, instance.id) } + .handle(mapOf(Pair(task.id, TaskSchedule(task, mapOf(Pair(instance.id, instance)), emptyList()))), repo, scheduleRepo), + equalTo(null) + ) + assertThat( Message().apply { obj = SchedulerOps.Message.Init(tasks = listOf(task), schedules = emptyList()) } .handle(emptyMap(), repo, scheduleRepo), @@ -379,6 +409,93 @@ class SchedulerServiceSpec { } } + @Test + fun resolveTimedOutAlarms() { + val other = TaskInstance(instant = instance.instant.plusSeconds(60)) + val schedules = mapOf(task.id to TaskSchedule(task, mapOf(instance.id to instance, other.id to other), emptyList())) + + assertThat(SchedulerService.AlarmTimeout(task.id, instance.id).resolve(schedules), equalTo(task to instance)) + assertThat(SchedulerService.AlarmTimeout(task.id, other.id).resolve(schedules), equalTo(task to other)) + assertThat(SchedulerService.AlarmTimeout(task.id, UUID.randomUUID()).resolve(schedules), equalTo(null)) + assertThat(SchedulerService.AlarmTimeout(task.id + 1, instance.id).resolve(schedules), equalTo(null)) + } + + @Test + fun replaceRingingAlarmsWithMissedAlarmsOnTimeout() { + val context = ApplicationProvider.getApplicationContext() + val id = NotificationManagerExtensions.getInstanceNotificationId(task.id, instance.id) + val schedules = mapOf(task.id to TaskSchedule(task, mapOf(instance.id to instance), emptyList())) + + val notification = slot() + + val manager = mockk() + every { manager.activeNotifications } returns arrayOf(ringingAlarm(id)) + justRun { manager.cancel(any()) } + justRun { manager.notify(any(), capture(notification)) } + + SchedulerService.AlarmTimeout(task.id, instance.id).applyTo(context, schedules, manager) + + verify(exactly = 1) { manager.activeNotifications } + verify(exactly = 1) { manager.cancel(id) } + verify(exactly = 1) { manager.notify(id, any()) } + confirmVerified(manager) + + assertThat( + notification.captured.channelId, + equalTo(NotificationManagerExtensions.Config.Default.instanceMissedAlarmChannel.id) + ) + } + + @Test + fun stopRingingAlarmsOfMissingInstancesOnTimeout() { + val context = ApplicationProvider.getApplicationContext() + val id = NotificationManagerExtensions.getInstanceNotificationId(task.id, instance.id) + + listOf( + emptyMap(), + mapOf(task.id to TaskSchedule(task, emptyMap(), emptyList())) + ).forEach { schedules -> + val manager = mockk() + every { manager.activeNotifications } returns arrayOf(ringingAlarm(id)) + justRun { manager.cancel(any()) } + + SchedulerService.AlarmTimeout(task.id, instance.id).applyTo(context, schedules, manager) + + verify(exactly = 1) { manager.activeNotifications } + verify(exactly = 1) { manager.cancel(id) } + verify(exactly = 1) { manager.cancel(task.id + instance.id.hashCode()) } + confirmVerified(manager) + } + } + + @Test + fun ignoreTimeoutsOfAlarmsThatAreNoLongerRinging() { + val context = ApplicationProvider.getApplicationContext() + + listOf( + emptyMap(), + mapOf(task.id to TaskSchedule(task, mapOf(instance.id to instance), emptyList())) + ).forEach { schedules -> + val manager = mockk() + every { manager.activeNotifications } returns emptyArray() + + SchedulerService.AlarmTimeout(task.id, instance.id).applyTo(context, schedules, manager) + + verify(exactly = 1) { manager.activeNotifications } + confirmVerified(manager) + } + } + + private fun ringingAlarm(id: Int): StatusBarNotification { + val notification = mockk() + every { notification.channelId } returns NotificationManagerExtensions.Config.Default.instanceExecutionAlarmChannel.id + + return mockk().also { + every { it.id } returns id + every { it.notification } returns notification + } + } + private fun Context.createIntent(withAction: String, apply: (Intent) -> Intent): Intent = Intent(this, SchedulerService::class.java).apply { action = withAction @@ -391,6 +508,9 @@ class SchedulerServiceSpec { private fun prune() = Unit + @Suppress("UNUSED_PARAMETER") + private fun timeout(task: Int, instance: UUID) = Unit + private val config = TaskSummaryConfig(summarySize = Duration.ofMinutes(15)) private val task = Task( diff --git a/client-android/src/test/java/eventually/test/client/settings/SettingsSpec.kt b/client-android/src/test/java/eventually/test/client/settings/SettingsSpec.kt index b48468f..058e341 100644 --- a/client-android/src/test/java/eventually/test/client/settings/SettingsSpec.kt +++ b/client-android/src/test/java/eventually/test/client/settings/SettingsSpec.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import androidx.test.core.app.ApplicationProvider import eventually.client.settings.Settings import eventually.client.settings.Settings.defaultPreferences +import eventually.client.settings.Settings.getAlarmTimeout import eventually.client.settings.Settings.getDateTimeFormat import eventually.client.settings.Settings.getDismissedLimit import eventually.client.settings.Settings.getDismissedRetention @@ -153,6 +154,23 @@ class SettingsSpec { assertThat(preferences.getPostponeLength(), equalTo((expectedPostponeLength))) } + @Test + fun supportRetrievingDefaultAlarmTimeout() { + val preferences = mockk() + every { preferences.getString(Settings.Keys.AlarmTimeout, Settings.Defaults.AlarmTimeout) } returns null + + assertThat(preferences.getAlarmTimeout(), equalTo(Duration.ofMinutes(10))) + assertThat(null.getAlarmTimeout(), equalTo(Duration.ofMinutes(10))) + } + + @Test + fun supportRetrievingUserDefinedAlarmTimeout() { + val preferences = mockk() + every { preferences.getString(Settings.Keys.AlarmTimeout, Settings.Defaults.AlarmTimeout) } returns "3" + + assertThat(preferences.getAlarmTimeout(), equalTo(Duration.ofMinutes(3))) + } + @Test fun supportRetrievingDefaultDismissedRetention() { val preferences = mockk() diff --git a/client-android/src/test/java/eventually/test/client/ui/components/TaskFormSpec.kt b/client-android/src/test/java/eventually/test/client/ui/components/TaskFormSpec.kt index d098bfb..dd1e8e8 100644 --- a/client-android/src/test/java/eventually/test/client/ui/components/TaskFormSpec.kt +++ b/client-android/src/test/java/eventually/test/client/ui/components/TaskFormSpec.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.getUnclippedBoundsInRoot @@ -37,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.height import eventually.client.R import eventually.client.settings.Settings +import eventually.client.ui.components.FullScreenAlarms import eventually.client.ui.components.ScheduleType import eventually.client.ui.components.TaskForm import eventually.client.ui.components.TaskFormError @@ -89,6 +91,7 @@ class TaskFormSpec { textField("15").assertExists() composeRule.onNodeWithText("minutes").assertExists() composeRule.onNodeWithText("Calendar Event Color").assertExists() + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).assertIsOff() } @Test @@ -330,6 +333,7 @@ class TaskFormSpec { click("minutes") click("seconds") composeRule.onNode(isToggleable() and hasText("Enabled")).performScrollTo().performClick() + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().performClick() click("Create") assertThat( @@ -354,7 +358,8 @@ class TaskFormSpec { ), contextSwitch = Duration.ofSeconds(30), isActive = false, - color = null + color = null, + executionAlarm = true ) ) ) @@ -438,6 +443,49 @@ class TaskFormSpec { assertThat(state.toTask(id = task.id, defaultColor = defaultColor).color, equalTo(null)) } + @Test + fun promptForFullScreenAlarmsWhenNotAllowed() { + var requested = 0 + val state = TaskFormState.of(task = null, default = default, defaultColor = defaultColor) + state.expanded = true + + showForm(state = state, fullScreenAlarms = FullScreenAlarms(allowed = false) { requested++ }) + + composeRule.onNodeWithText(FullScreenAlarmsDenied).assertDoesNotExist() + + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().performClick() + composeRule.onNodeWithText(FullScreenAlarmsDenied) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.LiveRegion, LiveRegionMode.Polite)) + composeRule.onNodeWithContentDescription("Allow full-screen alarms").assertExists() + + click("Allow") + assertThat(requested, equalTo(1)) + + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().performClick() + composeRule.onNodeWithText(FullScreenAlarmsDenied).assertDoesNotExist() + } + + @Test + fun notPromptForFullScreenAlarmsWhenAllowed() { + val state = TaskFormState.of(task = null, default = default, defaultColor = defaultColor) + state.expanded = true + state.executionAlarm = true + + showForm(state = state, fullScreenAlarms = FullScreenAlarms(allowed = true) {}) + + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().assertIsOn() + composeRule.onNodeWithText(FullScreenAlarmsDenied).assertDoesNotExist() + } + + @Test + fun allowFullScreenAlarmsBeforeAndroid14() { + showNewTask() + expand() + + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().performClick() + composeRule.onNodeWithText(FullScreenAlarmsDenied).assertDoesNotExist() + } + @Test fun keepInputWhenRecreated() { val restorationTester = StateRestorationTester(composeRule) @@ -458,12 +506,14 @@ class TaskFormSpec { textField("Task name").performTextReplacement("test-task") click("Repeating") expand() + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().performClick() restorationTester.emulateSavedInstanceStateRestore() textField("test-task").assertExists() composeRule.onNodeWithText("Repeating").assertIsSelected() composeRule.onNodeWithText("Description (optional)").assertExists() + composeRule.onNode(isToggleable() and hasText(ExecutionAlarm)).performScrollTo().assertIsOn() } @Test @@ -642,7 +692,8 @@ class TaskFormSpec { ), contextSwitch = Duration.ofHours(1), isActive = false, - color = null + color = null, + executionAlarm = true ), Task( id = 3, @@ -735,6 +786,24 @@ class TaskFormSpec { } } + private fun showForm(state: TaskFormState, fullScreenAlarms: FullScreenAlarms) { + composeRule.setContent { + EventuallyTheme { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + TaskForm( + state = state, + goals = emptyList(), + dateTimeFormat = Settings.DateTimeFormat.Iso, + firstDayOfWeek = DayOfWeek.MONDAY, + operation = R.string.existing_task_update_action, + onOperation = {}, + fullScreenAlarms = fullScreenAlarms + ) + } + } + } + } + private fun textField(text: String): SemanticsNodeInteraction = composeRule.onNode( hasSetTextAction() and (hasText(text) or hasAnyDescendant(hasText(text))), @@ -794,6 +863,9 @@ class TaskFormSpec { companion object { private const val Landscape: String = "w800dp-h360dp-land" + private const val ExecutionAlarm: String = "Alarm when due" + private const val FullScreenAlarmsDenied: String = + "Full-screen alarms are not allowed; alarms will only show as notifications" private val NarrowWidth = 360.dp } } diff --git a/client-android/src/test/java/eventually/test/client/ui/screens/AlarmScreenSpec.kt b/client-android/src/test/java/eventually/test/client/ui/screens/AlarmScreenSpec.kt new file mode 100644 index 0000000..2f843f2 --- /dev/null +++ b/client-android/src/test/java/eventually/test/client/ui/screens/AlarmScreenSpec.kt @@ -0,0 +1,126 @@ +package eventually.test.client.ui.screens + +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import eventually.client.settings.Settings +import eventually.client.ui.screens.AlarmScreen +import eventually.client.ui.theme.EventuallyTheme +import eventually.core.model.Task +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Duration +import java.time.LocalDateTime +import java.time.ZoneId + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Config.OLDEST_SDK]) +class AlarmScreenSpec { + @get:Rule + val composeRule = createComposeRule() + + private var postponed = 0 + private var dismissed = 0 + + @Test + fun showAlarmDetails() { + showAlarm(task) + + composeRule.onNodeWithText("10:30").assertExists() + composeRule.onNodeWithText("test-task").assertExists() + composeRule.onNodeWithText("test-goal").assertExists() + composeRule.onNodeWithText("test-description").assertExists() + composeRule.onNodeWithText("Postpone by 10 minutes").assertExists() + composeRule.onNodeWithText("Dismiss").assertExists() + } + + @Test + fun showAlarmsWithoutDescriptions() { + showAlarm(task.copy(description = ""), postponeLength = Duration.ofMinutes(1)) + + composeRule.onNodeWithText("test-task").assertExists() + composeRule.onNodeWithText("test-description").assertDoesNotExist() + composeRule.onNodeWithText("Postpone by 1 minute").assertExists() + } + + @Test + fun postponeAlarms() { + showAlarm(task) + + composeRule.onNodeWithText("Postpone by 10 minutes").performClick() + + assertThat(postponed, equalTo(1)) + assertThat(dismissed, equalTo(0)) + } + + @Test + fun dismissAlarms() { + showAlarm(task) + + composeRule.onNodeWithText("Dismiss").performClick() + + assertThat(postponed, equalTo(0)) + assertThat(dismissed, equalTo(1)) + } + + @Test + fun stackActionsBelowDetailsInPortrait() { + showAlarm(task) + + val name = composeRule.onNodeWithText("test-task").getUnclippedBoundsInRoot() + val postpone = composeRule.onNodeWithText("Postpone by 10 minutes").getUnclippedBoundsInRoot() + val dismiss = composeRule.onNodeWithText("Dismiss").getUnclippedBoundsInRoot() + + assertThat(postpone.top > name.bottom, equalTo(true)) + assertThat(dismiss.top > postpone.bottom, equalTo(true)) + } + + @Test + @Config(qualifiers = "w800dp-h360dp-land") + fun placeActionsNextToDetailsInLandscape() { + showAlarm(task) + + val name = composeRule.onNodeWithText("test-task").getUnclippedBoundsInRoot() + val postpone = composeRule.onNodeWithText("Postpone by 10 minutes").getUnclippedBoundsInRoot() + val dismiss = composeRule.onNodeWithText("Dismiss").getUnclippedBoundsInRoot() + + assertThat(postpone.left > name.right, equalTo(true)) + assertThat(dismiss.left > name.right, equalTo(true)) + assertThat(dismiss.top > postpone.bottom, equalTo(true)) + } + + private fun showAlarm(task: Task, postponeLength: Duration = Duration.ofMinutes(10)) { + composeRule.setContent { + EventuallyTheme { + AlarmScreen( + task = task, + execution = execution, + dateTimeFormat = Settings.DateTimeFormat.Iso, + postponeLength = postponeLength, + onPostpone = { postponed++ }, + onDismiss = { dismissed++ } + ) + } + } + } + + private val execution = LocalDateTime.of(2026, 1, 2, 10, 30).atZone(ZoneId.systemDefault()).toInstant() + + private val task = Task( + id = 42, + name = "test-task", + description = "test-description", + goal = "test-goal", + schedule = Task.Schedule.Once(instant = execution), + contextSwitch = Duration.ofMinutes(5), + isActive = true, + color = null, + executionAlarm = true + ) +} diff --git a/client-android/src/test/java/eventually/test/client/ui/screens/SettingsScreenSpec.kt b/client-android/src/test/java/eventually/test/client/ui/screens/SettingsScreenSpec.kt index 14a24b3..ba4dac4 100644 --- a/client-android/src/test/java/eventually/test/client/ui/screens/SettingsScreenSpec.kt +++ b/client-android/src/test/java/eventually/test/client/ui/screens/SettingsScreenSpec.kt @@ -47,6 +47,7 @@ class SettingsScreenSpec { showsText("Show at most 7 tasks") showsText("Sunday") showsText("Postpone a task by 10 minutes") + showsText("Stop ringing alarms after 10 minutes") showsText("Dismissed tasks") showsText("Keep dismissed instances from the last 30 days") showsText("Keep at most 100 dismissed instances per task") @@ -137,6 +138,45 @@ class SettingsScreenSpec { assertThat(preferences.all, equalTo(emptyMap())) } + @Test + fun updateAlarmTimeout() { + showSettings() + + updateNumber("Alarm timeout", "1") + showsText("Stop ringing alarms after 1 minute") + + assertThat(preferences.all, equalTo(mapOf(Settings.Keys.AlarmTimeout to "1"))) + } + + @Test + fun showStoredAlarmTimeout() { + preferences.edit { putString(Settings.Keys.AlarmTimeout, "15") } + + showSettings() + + showsText("Stop ringing alarms after 15 minutes") + } + + @Test + fun rejectInvalidAlarmTimeout() { + showSettings() + + scrollTo("Alarm timeout") + composeRule.onNodeWithText("Alarm timeout").performClick() + + listOf("0", "-5", "abc", "").forEach { invalid -> + composeRule.onNode(hasSetTextAction()).performTextReplacement(invalid) + composeRule.onNodeWithText("OK").performClick() + composeRule.onNode(hasSetTextAction()).assertExists() + } + + composeRule.onNodeWithText("Cancel").performClick() + + showsText("Stop ringing alarms after 10 minutes") + + assertThat(preferences.all, equalTo(emptyMap())) + } + @Test fun updateDismissedPreferences() { showSettings() diff --git a/core/src/main/kotlin/eventually/core/model/Task.kt b/core/src/main/kotlin/eventually/core/model/Task.kt index 7a61fdf..d9d69a3 100644 --- a/core/src/main/kotlin/eventually/core/model/Task.kt +++ b/core/src/main/kotlin/eventually/core/model/Task.kt @@ -17,7 +17,8 @@ data class Task( val schedule: Schedule, val contextSwitch: Duration, val isActive: Boolean, - val color: Int? + val color: Int?, + val executionAlarm: Boolean = false ) { companion object { val MaxDuration: Duration = ChronoUnit.YEARS.duration.multipliedBy(100) diff --git a/core/src/main/kotlin/eventually/core/model/TaskSchedule.kt b/core/src/main/kotlin/eventually/core/model/TaskSchedule.kt index c5f603a..9eafba5 100644 --- a/core/src/main/kotlin/eventually/core/model/TaskSchedule.kt +++ b/core/src/main/kotlin/eventually/core/model/TaskSchedule.kt @@ -18,18 +18,24 @@ data class TaskSchedule( return if (task.isActive) { val latestInstance = instances.values.maxByOrNull { it.instant } var nextInstants = task.schedule.next(after, within) + val repeating = task.schedule is Task.Schedule.Repeating val collected: List while (true) { val last = nextInstants.lastOrNull() val filtered = nextInstants.filter { next -> - !dismissed.contains(next) + val wasDismissed = if (repeating) { + dismissed.contains(next) + } else { + dismissed.any { !it.isBefore(next) } + } + + !wasDismissed && instances.values.none { it.instant == next } && latestInstance?.let { next.isAfter(it.instant) } ?: true } - val repeating = task.schedule is Task.Schedule.Repeating val searchFurther = repeating && instances.isEmpty() && filtered.isEmpty() if (searchFurther && last != null) { @@ -144,7 +150,13 @@ data class TaskSchedule( val scheduleChanged = task.schedule != newTask.schedule val stateChanged = task.isActive != newTask.isActive - return if (scheduleChanged || stateChanged) { + return if (scheduleChanged && newTask.schedule is Task.Schedule.Once) { + copy( + task = newTask, + instances = emptyMap(), + dismissed = emptyList() + ) + } else if (scheduleChanged || stateChanged) { val pastInstances = instances.filterValues { instance -> instance.execution().isBefore(now) } copy( task = newTask, diff --git a/core/src/test/kotlin/eventually/test/core/model/TaskScheduleSpec.kt b/core/src/test/kotlin/eventually/test/core/model/TaskScheduleSpec.kt index a309046..002beac 100644 --- a/core/src/test/kotlin/eventually/test/core/model/TaskScheduleSpec.kt +++ b/core/src/test/kotlin/eventually/test/core/model/TaskScheduleSpec.kt @@ -248,6 +248,164 @@ class TaskScheduleSpec : WordSpec({ newInstance.instant shouldBe (instance.instant) } + "not recreate once instances after dismissing them when they were postponed while overdue" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(30))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val instance = schedule.instances.values.single() + + val postponed = schedule.postpone(instance.id, by = Duration.ofMinutes(10)) + val postponedInstance = postponed.instances.values.single() + postponedInstance.instant.isAfter(instance.instant) shouldBe (true) + + val dismissed = postponed.dismiss(postponedInstance.id) + dismissed.dismissed shouldBe (listOf(postponedInstance.instant)) + + val updated = dismissed.update(after = Instant.now(), within = within) + updated.instances shouldBe (emptyMap()) + updated.dismissed shouldBe (listOf(postponedInstance.instant)) + } + + "restore once instances that were postponed while overdue and then dismissed" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(30))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val postponed = schedule.postpone(schedule.instances.keys.single(), by = Duration.ofMinutes(10)) + val postponedInstance = postponed.instances.values.single() + val dismissed = postponed.dismiss(postponedInstance.id) + + val restored = dismissed + .undoDismiss(postponedInstance.instant) + .update(after = Instant.now(), within = within) + restored.instances.values.map { it.instant } shouldBe (listOf(postponedInstance.instant)) + restored.dismissed shouldBe (emptyList()) + } + + "create once instances when rescheduled after an earlier dismissal" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(30))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val dismissed = schedule.dismiss(schedule.instances.keys.single()) + + val rescheduled = once.copy(schedule = Task.Schedule.Once(instant = now.plus(Duration.ofHours(1)))) + val updated = dismissed.withTask(rescheduled).update(after = Instant.now(), within = within) + + updated.instances.values.map { it.instant } shouldBe (listOf(now.plus(Duration.ofHours(1)))) + updated.dismissed shouldBe (emptyList()) + } + + "create once instances when rescheduled before an earlier dismissal" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(30))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val dismissed = schedule.dismiss(schedule.instances.keys.single()) + + val rescheduled = once.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(60))) + val updated = dismissed.withTask(rescheduled).update(after = Instant.now(), within = within) + + updated.instances.values.map { it.instant } shouldBe (listOf(now.minusSeconds(60))) + updated.dismissed shouldBe (emptyList()) + } + + "create once instances when a repeating task with upcoming dismissals becomes a once task" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val repeating = task.copy( + schedule = Task.Schedule.Repeating( + start = now.plus(Duration.ofHours(2)), + every = Duration.ofDays(1).toInterval() + ) + ) + + val schedule = TaskSchedule(repeating).update(after = now, within = within) + val dismissed = schedule.dismiss(schedule.instances.keys.single()) + dismissed.dismissed shouldBe (listOf(now.plus(Duration.ofHours(2)))) + + val once = repeating.copy(schedule = Task.Schedule.Once(instant = now.plus(Duration.ofHours(1)))) + val updated = dismissed.withTask(once).update(after = Instant.now(), within = within) + + updated.instances.values.map { it.instant } shouldBe (listOf(now.plus(Duration.ofHours(1)))) + updated.dismissed shouldBe (emptyList()) + } + + "keep instances when only the details of a once task are updated" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minus(Duration.ofMinutes(30)))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val postponed = schedule.postpone(schedule.instances.keys.single(), by = Duration.ofMinutes(10)) + + val updated = postponed.withTask(once.copy(name = "other-name", executionAlarm = true)) + + updated.instances shouldBe (postponed.instances) + updated.dismissed shouldBe (postponed.dismissed) + } + + "replace all instances when a once task is rescheduled" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minus(Duration.ofMinutes(30)))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val past = schedule.instances.values.single() + past.execution().isBefore(now) shouldBe (true) + + val rescheduled = once.copy(schedule = Task.Schedule.Once(instant = now.plus(Duration.ofHours(1)))) + val withTask = schedule.withTask(rescheduled) + withTask.instances shouldBe (emptyMap()) + + val updated = withTask.update(after = Instant.now(), within = within) + updated.instances.values.map { it.instant } shouldBe (listOf(now.plus(Duration.ofHours(1)))) + updated.instances.values.map { it.postponed } shouldBe (listOf(null)) + } + + "replace postponed instances when a once task is rescheduled" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minusSeconds(30))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val postponed = schedule.postpone(schedule.instances.keys.single(), by = Duration.ofMinutes(10)) + + val rescheduled = once.copy(schedule = Task.Schedule.Once(instant = now.plus(Duration.ofHours(1)))) + postponed.withTask(rescheduled).instances shouldBe (emptyMap()) + } + + "keep past instances when a once task is disabled" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minus(Duration.ofMinutes(30)))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + + schedule.withTask(once.copy(isActive = false)).instances shouldBe (schedule.instances) + } + + "keep past instances when a once task becomes repeating" { + val now = Instant.now() + val within = Duration.ofMinutes(5) + val once = task.copy(schedule = Task.Schedule.Once(instant = now.minus(Duration.ofMinutes(30)))) + + val schedule = TaskSchedule(once).update(after = now, within = within) + val repeating = once.copy( + schedule = Task.Schedule.Repeating( + start = now.plus(Duration.ofHours(1)), + every = Duration.ofDays(1).toInterval() + ) + ) + + schedule.withTask(repeating).instances shouldBe (schedule.instances) + } + "fail to undo dismissal of missing task instants (non-repeating schedules)" { val once = task.copy(schedule = Task.Schedule.Once(instant = after())) val schedule = TaskSchedule(once) @@ -568,7 +726,8 @@ class TaskScheduleSpec : WordSpec({ name = "other-name", description = "other-description", goal = "other-goal", - contextSwitch = Duration.ofMinutes(42) + contextSwitch = Duration.ofMinutes(42), + executionAlarm = true ) val scheduleWithUpdatedTask = updatedSchedule.withTask(updatedTask) diff --git a/core/src/test/kotlin/eventually/test/core/model/TaskSpec.kt b/core/src/test/kotlin/eventually/test/core/model/TaskSpec.kt index bff5019..42c3784 100644 --- a/core/src/test/kotlin/eventually/test/core/model/TaskSpec.kt +++ b/core/src/test/kotlin/eventually/test/core/model/TaskSpec.kt @@ -320,5 +320,20 @@ class TaskSpec : WordSpec({ e.message shouldBe ("At least one day of the week is required for a repeating schedule") } + + "not use an execution alarm by default" { + val task = Task( + id = 0, + name = "test-task", + description = "test-description", + goal = "test-goal", + schedule = Task.Schedule.Once(instant = Instant.now()), + contextSwitch = Duration.ofMinutes(5), + isActive = true, + color = null + ) + + task.executionAlarm shouldBe (false) + } } }) diff --git a/core/src/test/kotlin/eventually/test/core/scheduling/SchedulerOpsSpec.kt b/core/src/test/kotlin/eventually/test/core/scheduling/SchedulerOpsSpec.kt index 2d7a5dd..d6d1bc2 100644 --- a/core/src/test/kotlin/eventually/test/core/scheduling/SchedulerOpsSpec.kt +++ b/core/src/test/kotlin/eventually/test/core/scheduling/SchedulerOpsSpec.kt @@ -258,6 +258,23 @@ class SchedulerOpsSpec : WordSpec({ result.affectedSchedules shouldBe (listOf(task.id)) } + "replace past instances when rescheduling once tasks" { + val past = task.copy(schedule = Task.Schedule.Once(instant = Instant.now().minus(Duration.ofMinutes(30)))) + val schedules = evaluate(mapOf(Pair(past.id, TaskSchedule(past)))) + val instance = schedules.values.single().instances.values.single() + + val next = Instant.now().plus(Duration.ofHours(1)) + val rescheduled = past.copy(schedule = Task.Schedule.Once(instant = next)) + val result = SchedulerOps.put(schedules, SchedulerOps.Message.Put(rescheduled)) + + result.schedules.values.single().instances shouldBe (emptyMap()) + result.notifications shouldBe ( + listOf(SchedulerOps.Notification.DeleteInstanceNotifications(past.id, instance.id)) + ) + + evaluate(result.schedules).values.single().instances.values.map { it.instant } shouldBe (listOf(next)) + } + "support updating existing tasks when set to inactive" { val schedules = evaluate(mapOf(Pair(task.id, TaskSchedule(task)))) @@ -501,6 +518,23 @@ class SchedulerOpsSpec : WordSpec({ result.affectedSchedules shouldBe (listOf(task.id)) } + "not recreate once instances that were postponed while overdue and then dismissed" { + val overdue = task.copy(schedule = Task.Schedule.Once(instant = Instant.now().minusSeconds(30))) + val schedules = evaluate(mapOf(Pair(overdue.id, TaskSchedule(overdue)))) + val instance = schedules.values.single().instances.values.single() + + val postpone = SchedulerOps.Message.Postpone(overdue.id, instance.id, Duration.ofMinutes(10)) + val postponed = evaluate(SchedulerOps.postpone(schedules, postpone).schedules) + postponed.values.single().instances.keys shouldBe (setOf(instance.id)) + + val dismissed = evaluate( + SchedulerOps.dismiss(postponed, SchedulerOps.Message.Dismiss(overdue.id, instance.id)).schedules + ) + + dismissed.values.single().instances shouldBe (emptyMap()) + evaluate(dismissed).values.single().instances shouldBe (emptyMap()) + } + "do nothing when postponing missing tasks" { val schedules = emptyMap() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a92ba3..f94582d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-commo androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }