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-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..7039cfab
--- /dev/null
+++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastConstants.kt
@@ -0,0 +1,24 @@
+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"
+ 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"
+ const val EXTRA_TIMER_REMAINING = "timer_remaining"
+}
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..01202ce5
--- /dev/null
+++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/BroadcastPresenter.kt
@@ -0,0 +1,102 @@
+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()
+ }
+
+ 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
+ )
+ 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..415497a3
--- /dev/null
+++ b/app-broadcast/src/main/java/xyz/aprildown/timer/app/broadcast/TimerBroadcastReceiver.kt
@@ -0,0 +1,81 @@
+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 xyz.aprildown.timer.domain.utils.Constants
+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
+
+ val command = intent.getStringExtra(BroadcastConstants.EXTRA_COMMAND) ?: return
+
+ try {
+ if (command == BroadcastConstants.COMMAND_LIST) {
+ handleList(context)
+ return
+ }
+
+ 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)
+ }
+ }
+
+ 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 notifMap = nm.activeNotifications
+ .filter { it.id !in systemNotifIds }
+ .associateBy { it.id }
+
+ 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)
+ }
+
+ 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-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/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/gradlew b/gradlew
old mode 100644
new mode 100755
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")