From bd3850fa270046c0831a1efbefb0951e159fb841 Mon Sep 17 00:00:00 2001 From: Keith Vassallo Date: Wed, 18 Feb 2026 12:21:06 +0000 Subject: [PATCH 1/4] Add broadcast intent API for external timer control Allows external apps (e.g. voice assistants) to create, start, pause, resume, reset, and dismiss timers via broadcast intents. Includes handling for ForegroundServiceStartNotAllowedException on Android 12+. --- app-broadcast/build.gradle.kts | 12 +++ app-broadcast/src/main/AndroidManifest.xml | 14 +++ .../timer/app/broadcast/BroadcastConstants.kt | 18 ++++ .../timer/app/broadcast/BroadcastPresenter.kt | 96 +++++++++++++++++++ .../app/broadcast/TimerBroadcastReceiver.kt | 47 +++++++++ .../timer/app/broadcast/TimerFactory.kt | 37 +++++++ app/build.gradle.kts | 1 + .../github/deweyreed/timer/di/OtherModule.kt | 4 + .../java/xyz/aprildown/timer/data/db/Daos.kt | 3 + .../data/repositories/TimerRepositoryImpl.kt | 4 + .../domain/repositories/TimerRepository.kt | 1 + .../usecases/timer/FindTimerInfoByName.kt | 20 ++++ .../StreamMachineIntentProvider.kt | 1 + settings.gradle.kts | 1 + 14 files changed, 259 insertions(+) create mode 100644 app-broadcast/build.gradle.kts create mode 100644 app-broadcast/src/main/AndroidManifest.xml create mode 100644 app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt create mode 100644 app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt create mode 100644 app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt create mode 100644 app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerFactory.kt create mode 100644 domain/src/main/java/xyz/aprildown/timer/domain/usecases/timer/FindTimerInfoByName.kt diff --git a/app-broadcast/build.gradle.kts b/app-broadcast/build.gradle.kts new file mode 100644 index 00000000..9c75afcc --- /dev/null +++ b/app-broadcast/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.convention.android.library) + alias(libs.plugins.convention.hilt) +} + +android { + namespace = "xyz.aprildown.timer.app.broadcast" +} + +dependencies { + implementation(project(":app-base")) +} diff --git a/app-broadcast/src/main/AndroidManifest.xml b/app-broadcast/src/main/AndroidManifest.xml new file mode 100644 index 00000000..9e4680b6 --- /dev/null +++ b/app-broadcast/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt new file mode 100644 index 00000000..632f8954 --- /dev/null +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt @@ -0,0 +1,18 @@ +package xyz.aprildown.timer.app.broadcast + +internal object BroadcastConstants { + const val ACTION_TIMER_CONTROL = "io.github.deweyreed.timer.BROADCAST_TIMER_CONTROL" + + const val EXTRA_COMMAND = "command" + const val EXTRA_TIMER_ID = "timer_id" + const val EXTRA_TIMER_NAME = "timer_name" + const val EXTRA_DURATION_SECONDS = "duration_seconds" + const val EXTRA_NAME = "name" + + const val COMMAND_CREATE = "create" + const val COMMAND_START = "start" + const val COMMAND_PAUSE = "pause" + const val COMMAND_RESUME = "resume" + const val COMMAND_RESET = "reset" + const val COMMAND_DISMISS = "dismiss" +} diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt new file mode 100644 index 00000000..8577791e --- /dev/null +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt @@ -0,0 +1,96 @@ +package xyz.aprildown.timer.app.broadcast + +import android.content.Intent +import dagger.Reusable +import xyz.aprildown.timer.domain.entities.TimerEntity +import xyz.aprildown.timer.domain.usecases.timer.AddTimer +import xyz.aprildown.timer.domain.usecases.timer.FindTimerInfo +import xyz.aprildown.timer.domain.usecases.timer.FindTimerInfoByName +import xyz.aprildown.timer.presentation.StreamMachineIntentProvider +import javax.inject.Inject + +@Reusable +class BroadcastPresenter @Inject constructor( + private val findTimerInfo: FindTimerInfo, + private val findTimerInfoByName: FindTimerInfoByName, + private val addTimer: AddTimer, + private val streamMachineIntentProvider: StreamMachineIntentProvider, +) { + + suspend fun handleIntent(intent: Intent): Intent? { + val command = intent.getStringExtra(BroadcastConstants.EXTRA_COMMAND) + ?: return null + + return when (command) { + BroadcastConstants.COMMAND_CREATE -> handleCreate(intent) + BroadcastConstants.COMMAND_START -> handleStart(intent) + BroadcastConstants.COMMAND_PAUSE -> handlePause(intent) + BroadcastConstants.COMMAND_RESUME -> handleResume(intent) + BroadcastConstants.COMMAND_RESET -> handleReset(intent) + BroadcastConstants.COMMAND_DISMISS -> handleDismiss(intent) + else -> null + } + } + + fun requiresRunningService(intent: Intent): Boolean { + return when (intent.getStringExtra(BroadcastConstants.EXTRA_COMMAND)) { + BroadcastConstants.COMMAND_PAUSE, + BroadcastConstants.COMMAND_RESET, + BroadcastConstants.COMMAND_DISMISS -> true + else -> false + } + } + + private suspend fun handleCreate(intent: Intent): Intent? { + val durationSeconds = intent.getLongExtra( + BroadcastConstants.EXTRA_DURATION_SECONDS, -1L + ) + if (durationSeconds <= 0) return null + + val name = intent.getStringExtra(BroadcastConstants.EXTRA_NAME) ?: "Timer" + val timer = TimerFactory.createCountdownTimer(durationSeconds, name) + val newId = addTimer(timer) + return streamMachineIntentProvider.startIntent(newId) + } + + private suspend fun handleStart(intent: Intent): Intent? { + val id = resolveTimerId(intent) ?: return null + return streamMachineIntentProvider.startIntent(id) + } + + private suspend fun handlePause(intent: Intent): Intent? { + val id = resolveTimerId(intent) ?: return null + return streamMachineIntentProvider.pauseIntent(id) + } + + private suspend fun handleResume(intent: Intent): Intent? { + val id = resolveTimerId(intent) ?: return null + return streamMachineIntentProvider.startIntent(id) + } + + private suspend fun handleReset(intent: Intent): Intent? { + val id = resolveTimerId(intent) ?: return null + return streamMachineIntentProvider.resetIntent(id) + } + + private suspend fun handleDismiss(intent: Intent): Intent? { + val id = resolveTimerId(intent) + if (id != null) { + return streamMachineIntentProvider.resetIntent(id) + } + // No specific timer targeted — dismiss all + return streamMachineIntentProvider.stopAllIntent() + } + + private suspend fun resolveTimerId(intent: Intent): Int? { + val id = intent.getIntExtra( + BroadcastConstants.EXTRA_TIMER_ID, TimerEntity.NULL_ID + ) + if (id != TimerEntity.NULL_ID) { + return if (findTimerInfo(id) != null) id else null + } + val name = intent.getStringExtra(BroadcastConstants.EXTRA_TIMER_NAME) + ?: return null + return findTimerInfoByName(name)?.id + } +} diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt new file mode 100644 index 00000000..6a573c9d --- /dev/null +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt @@ -0,0 +1,47 @@ +package xyz.aprildown.timer.app.broadcast + +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@AndroidEntryPoint +class TimerBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var presenter: BroadcastPresenter + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != BroadcastConstants.ACTION_TIMER_CONTROL) return + + try { + val serviceIntent = runBlocking { presenter.handleIntent(intent) } + ?: return + + if (presenter.requiresRunningService(intent)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = context.getSystemService() + if (nm == null || nm.activeNotifications.isEmpty()) return + } + } + + // On Android 12+, startForegroundService from the background requires + // battery optimization to be disabled for this app. Same limitation + // as the Tasker integration (see BroadcastReceiverActionTweak). + ContextCompat.startForegroundService(context, serviceIntent) + } catch (e: Exception) { + Log.e(TAG, "Failed to handle broadcast timer control", e) + } + } + + companion object { + private const val TAG = "TimerBroadcastReceiver" + } +} diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerFactory.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerFactory.kt new file mode 100644 index 00000000..9555c66f --- /dev/null +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerFactory.kt @@ -0,0 +1,37 @@ +package xyz.aprildown.timer.app.broadcast + +import xyz.aprildown.timer.domain.entities.BehaviourEntity +import xyz.aprildown.timer.domain.entities.BehaviourType +import xyz.aprildown.timer.domain.entities.StepEntity +import xyz.aprildown.timer.domain.entities.StepType +import xyz.aprildown.timer.domain.entities.TimerEntity + +internal object TimerFactory { + + fun createCountdownTimer(durationSeconds: Long, name: String): TimerEntity { + val durationMillis = durationSeconds * 1000L + + return TimerEntity( + id = TimerEntity.NEW_ID, + name = name, + loop = 1, + steps = listOf( + StepEntity.Step( + label = name, + length = durationMillis + ), + StepEntity.Step( + label = name, + length = 10_000L, + behaviour = listOf( + BehaviourEntity(type = BehaviourType.MUSIC), + BehaviourEntity(type = BehaviourType.VIBRATION), + BehaviourEntity(type = BehaviourType.SCREEN), + BehaviourEntity(type = BehaviourType.HALT), + ), + type = StepType.NOTIFIER + ) + ) + ) + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c98feb4..e027aa49 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { implementation(project(":app-backup")) implementation(project(":app-settings")) implementation(project(":app-tasker")) + implementation(project(":app-broadcast")) implementation(project(":app-intro")) implementation(project(":app-timer-edit")) implementation(project(":app-timer-run")) diff --git a/app/src/main/java/io/github/deweyreed/timer/di/OtherModule.kt b/app/src/main/java/io/github/deweyreed/timer/di/OtherModule.kt index 11aa92c7..211e265c 100644 --- a/app/src/main/java/io/github/deweyreed/timer/di/OtherModule.kt +++ b/app/src/main/java/io/github/deweyreed/timer/di/OtherModule.kt @@ -71,6 +71,10 @@ abstract class OtherModule { override fun adjustTimeIntent(id: Int, amount: Long): Intent { return MachineService.adjustAmountIntent(context, id, amount) } + + override fun stopAllIntent(): Intent { + return MachineService.stopAllIntent(context) + } } } diff --git a/data/src/main/java/xyz/aprildown/timer/data/db/Daos.kt b/data/src/main/java/xyz/aprildown/timer/data/db/Daos.kt index 54d8482b..5f5d2cdf 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/db/Daos.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/db/Daos.kt @@ -24,6 +24,9 @@ internal interface TimerDao { @Query("SELECT id, name, folderId FROM TimerItem WHERE id = :timerId") suspend fun findTimerInfo(timerId: Int): TimerInfoData? + @Query("SELECT id, name, folderId FROM TimerItem WHERE name = :name COLLATE NOCASE LIMIT 1") + suspend fun findTimerInfoByName(name: String): TimerInfoData? + @Query("SELECT id, name, folderId FROM TimerItem WHERE folderId = :folderId") suspend fun getTimerInfo(folderId: Long): List diff --git a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt index d01932ca..e5ccd220 100644 --- a/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt +++ b/data/src/main/java/xyz/aprildown/timer/data/repositories/TimerRepositoryImpl.kt @@ -90,6 +90,10 @@ internal class TimerRepositoryImpl @Inject constructor( return timerDao.findTimerInfo(timerId)?.fromWithMapper(timerInfoMapper) } + override suspend fun getTimerInfoByName(name: String): TimerInfo? { + return timerDao.findTimerInfoByName(name)?.fromWithMapper(timerInfoMapper) + } + override suspend fun getTimerInfo(folderId: Long): List { return timerDao.getTimerInfo(folderId).fromWithMapper(timerInfoMapper) } diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt b/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt index 39fd01c6..d5582738 100644 --- a/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt +++ b/domain/src/main/java/xyz/aprildown/timer/domain/repositories/TimerRepository.kt @@ -11,6 +11,7 @@ interface TimerRepository { suspend fun save(item: TimerEntity): Boolean suspend fun delete(id: Int) suspend fun getTimerInfoByTimerId(timerId: Int): TimerInfo? + suspend fun getTimerInfoByName(name: String): TimerInfo? fun getTimerInfoFlow(folderId: Long): Flow> suspend fun getTimerInfo(folderId: Long): List suspend fun changeTimerFolder(timerId: Int, folderId: Long) diff --git a/domain/src/main/java/xyz/aprildown/timer/domain/usecases/timer/FindTimerInfoByName.kt b/domain/src/main/java/xyz/aprildown/timer/domain/usecases/timer/FindTimerInfoByName.kt new file mode 100644 index 00000000..10de11ed --- /dev/null +++ b/domain/src/main/java/xyz/aprildown/timer/domain/usecases/timer/FindTimerInfoByName.kt @@ -0,0 +1,20 @@ +package xyz.aprildown.timer.domain.usecases.timer + +import dagger.Reusable +import kotlinx.coroutines.CoroutineDispatcher +import xyz.aprildown.timer.domain.di.IoDispatcher +import xyz.aprildown.timer.domain.entities.TimerInfo +import xyz.aprildown.timer.domain.repositories.TimerRepository +import xyz.aprildown.timer.domain.usecases.CoroutinesUseCase +import javax.inject.Inject + +@Reusable +class FindTimerInfoByName @Inject constructor( + @IoDispatcher dispatcher: CoroutineDispatcher, + private val repository: TimerRepository +) : CoroutinesUseCase(dispatcher) { + override suspend fun create(params: String): TimerInfo? { + if (params.isBlank()) return null + return repository.getTimerInfoByName(params) + } +} diff --git a/presentation/src/main/java/xyz/aprildown/timer/presentation/StreamMachineIntentProvider.kt b/presentation/src/main/java/xyz/aprildown/timer/presentation/StreamMachineIntentProvider.kt index 5cda79f1..3223ee45 100644 --- a/presentation/src/main/java/xyz/aprildown/timer/presentation/StreamMachineIntentProvider.kt +++ b/presentation/src/main/java/xyz/aprildown/timer/presentation/StreamMachineIntentProvider.kt @@ -12,4 +12,5 @@ interface StreamMachineIntentProvider { fun moveIntent(id: Int, index: TimerIndex): Intent fun resetIntent(id: Int): Intent fun adjustTimeIntent(id: Int, amount: Long): Intent + fun stopAllIntent(): Intent } diff --git a/settings.gradle.kts b/settings.gradle.kts index 732934fe..1051a6f6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include(":app-scheduler") include(":app-settings") include(":app-backup") include(":app-tasker") +include(":app-broadcast") include(":app-timer-run") include(":app-timer-edit") include(":app-timer-list") From 307f7678fdc9161c5f49692ed55ad9a5a1f1bb14 Mon Sep 17 00:00:00 2001 From: Keith Vassallo Date: Wed, 18 Feb 2026 12:39:10 +0000 Subject: [PATCH 2/4] Add list command to broadcast API for querying running timers Sends a response broadcast with timer IDs and names of all currently running timers, identified via active notifications. --- .../timer/app/broadcast/BroadcastConstants.kt | 5 ++++ .../timer/app/broadcast/BroadcastPresenter.kt | 6 ++++ .../app/broadcast/TimerBroadcastReceiver.kt | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt index 632f8954..75145b0d 100644 --- a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt @@ -15,4 +15,9 @@ internal object BroadcastConstants { const val COMMAND_RESUME = "resume" const val COMMAND_RESET = "reset" const val COMMAND_DISMISS = "dismiss" + const val COMMAND_LIST = "list" + + const val ACTION_TIMER_LIST_RESPONSE = "io.github.deweyreed.timer.BROADCAST_TIMER_LIST" + const val EXTRA_TIMER_IDS = "timer_ids" + const val EXTRA_TIMER_NAMES = "timer_names" } diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt index 8577791e..01202ce5 100644 --- a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt @@ -82,6 +82,12 @@ class BroadcastPresenter @Inject constructor( return streamMachineIntentProvider.stopAllIntent() } + suspend fun getTimerInfoForIds(ids: List): List> { + return ids.mapNotNull { id -> + findTimerInfo(id)?.let { info -> id to info.name } + } + } + private suspend fun resolveTimerId(intent: Intent): Int? { val id = intent.getIntExtra( BroadcastConstants.EXTRA_TIMER_ID, TimerEntity.NULL_ID diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt index 6a573c9d..0e76bfcf 100644 --- a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking +import xyz.aprildown.timer.domain.utils.Constants import javax.inject.Inject @AndroidEntryPoint @@ -21,7 +22,14 @@ class TimerBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != BroadcastConstants.ACTION_TIMER_CONTROL) return + val command = intent.getStringExtra(BroadcastConstants.EXTRA_COMMAND) ?: return + try { + if (command == BroadcastConstants.COMMAND_LIST) { + handleList(context) + return + } + val serviceIntent = runBlocking { presenter.handleIntent(intent) } ?: return @@ -41,6 +49,26 @@ class TimerBroadcastReceiver : BroadcastReceiver() { } } + private fun handleList(context: Context) { + val nm = context.getSystemService() ?: return + val systemNotifIds = setOf( + Constants.NOTIF_ID_SERVICE, + Constants.NOTIF_ID_SCREEN, + Constants.NOTIF_ID_NOTIFICATION + ) + val timerIds = nm.activeNotifications + .map { it.id } + .filter { it !in systemNotifIds } + + val timerInfos = runBlocking { presenter.getTimerInfoForIds(timerIds) } + + val responseIntent = Intent(BroadcastConstants.ACTION_TIMER_LIST_RESPONSE).apply { + putExtra(BroadcastConstants.EXTRA_TIMER_IDS, timerInfos.map { it.first }.toIntArray()) + putExtra(BroadcastConstants.EXTRA_TIMER_NAMES, timerInfos.map { it.second }.toTypedArray()) + } + context.sendBroadcast(responseIntent) + } + companion object { private const val TAG = "TimerBroadcastReceiver" } From 31d6fc52da8417bf3b830e7d27dace3ee120305a Mon Sep 17 00:00:00 2001 From: Keith Vassallo Date: Wed, 18 Feb 2026 13:58:27 +0000 Subject: [PATCH 3/4] Add remaining time to list command response Extracts the formatted remaining time (e.g. "04:58") from each timer's notification and includes it in the response broadcast as the timer_remaining string array. --- .../timer/app/broadcast/BroadcastConstants.kt | 1 + .../timer/app/broadcast/TimerBroadcastReceiver.kt | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt index 75145b0d..7039cfab 100644 --- a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt @@ -20,4 +20,5 @@ internal object BroadcastConstants { const val ACTION_TIMER_LIST_RESPONSE = "io.github.deweyreed.timer.BROADCAST_TIMER_LIST" const val EXTRA_TIMER_IDS = "timer_ids" const val EXTRA_TIMER_NAMES = "timer_names" + const val EXTRA_TIMER_REMAINING = "timer_remaining" } diff --git a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt index 0e76bfcf..415497a3 100644 --- a/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt +++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt @@ -56,15 +56,21 @@ class TimerBroadcastReceiver : BroadcastReceiver() { Constants.NOTIF_ID_SCREEN, Constants.NOTIF_ID_NOTIFICATION ) - val timerIds = nm.activeNotifications - .map { it.id } - .filter { it !in systemNotifIds } + val notifMap = nm.activeNotifications + .filter { it.id !in systemNotifIds } + .associateBy { it.id } - val timerInfos = runBlocking { presenter.getTimerInfoForIds(timerIds) } + val timerInfos = runBlocking { presenter.getTimerInfoForIds(notifMap.keys.toList()) } + + val remaining = timerInfos.map { (id, _) -> + notifMap[id]?.notification?.extras + ?.getCharSequence("android.text")?.toString() ?: "" + } val responseIntent = Intent(BroadcastConstants.ACTION_TIMER_LIST_RESPONSE).apply { putExtra(BroadcastConstants.EXTRA_TIMER_IDS, timerInfos.map { it.first }.toIntArray()) putExtra(BroadcastConstants.EXTRA_TIMER_NAMES, timerInfos.map { it.second }.toTypedArray()) + putExtra(BroadcastConstants.EXTRA_TIMER_REMAINING, remaining.toTypedArray()) } context.sendBroadcast(responseIntent) } From dfa66f2bb1252737bae9b37c64f545bf2e53c65e Mon Sep 17 00:00:00 2001 From: Keith Vassallo Date: Sat, 21 Feb 2026 09:49:37 +0000 Subject: [PATCH 4/4] Add full-screen intent permission setting for Android 14+ Shows a settings entry under Notifications that takes the user to the system full-screen intent permission page. Auto-hides once permission is granted. This is needed because the permission is not granted by default on Android 14+ and there is no natural in-flow moment to request it. --- app-base/src/main/res/values/strings.xml | 4 +++ .../timer/app/settings/SettingsFragment.kt | 33 +++++++++++++++++++ .../src/main/res/xml/pref_settings.xml | 8 +++++ gradlew | 0 4 files changed, 45 insertions(+) mode change 100644 => 100755 gradlew diff --git a/app-base/src/main/res/values/strings.xml b/app-base/src/main/res/values/strings.xml index 69e609a4..bc9f8e76 100644 --- a/app-base/src/main/res/values/strings.xml +++ b/app-base/src/main/res/values/strings.xml @@ -830,6 +830,10 @@ System setting %d + + Full-screen notifications + Allow timer alerts to display over other apps + Sign out? Sign Out diff --git a/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt b/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt index a94e37c3..c4823bf3 100644 --- a/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt +++ b/app-settings/src/main/java/xyz/aprildown/timer/app/settings/SettingsFragment.kt @@ -1,12 +1,14 @@ package xyz.aprildown.timer.app.settings import android.Manifest +import android.app.NotificationManager import android.content.Intent import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.provider.Settings import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.navigation.fragment.NavHostFragment import androidx.preference.ListPreference @@ -148,6 +150,26 @@ class SettingsFragment : ) } } + KEY_FULL_SCREEN_NOTIFICATIONS -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val settingsIntent = Intent( + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + "package:${context.packageName}".toUri() + ) + startActivityOrNothing( + settingsIntent.createChooserIntentIfDead(context), + wrongMessageRes = RBase.string.no_action_found + ) + } else { + val settingsIntent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + startActivityOrNothing( + settingsIntent.createChooserIntentIfDead(context), + wrongMessageRes = RBase.string.no_action_found + ) + } + } KEY_AUDIO_VOLUME -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startActivityOrNothing(Intent(Settings.Panel.ACTION_VOLUME)) @@ -222,6 +244,16 @@ class SettingsFragment : findPreference(KEY_NOTIF_SETTING)?.onPreferenceClickListener = this + findPreference(KEY_FULL_SCREEN_NOTIFICATIONS)?.run { + onPreferenceClickListener = this@SettingsFragment + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val nm = requireContext().getSystemService() + isVisible = nm != null && !nm.canUseFullScreenIntent() + } else { + isVisible = false + } + } + findPreference(KEY_AUDIO_VOLUME)?.run { onPreferenceClickListener = this@SettingsFragment isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q @@ -330,6 +362,7 @@ private const val KEY_PHONE_CALL = PreferenceData.KEY_PHONE_CALL private const val KEY_WEEK_START = PreferenceData.KEY_WEEK_START private const val KEY_NOTIF_SETTING = "key_notif_setting" +private const val KEY_FULL_SCREEN_NOTIFICATIONS = "key_full_screen_notifications" private const val KEY_AUDIO_VOLUME = "key_audio_volume" diff --git a/app-settings/src/main/res/xml/pref_settings.xml b/app-settings/src/main/res/xml/pref_settings.xml index ffafd2b3..f9c9cebd 100644 --- a/app-settings/src/main/res/xml/pref_settings.xml +++ b/app-settings/src/main/res/xml/pref_settings.xml @@ -136,6 +136,14 @@ app:key="key_media_style_notification" app:title="@string/pref_media_style_notification" /> + + diff --git a/gradlew b/gradlew old mode 100644 new mode 100755