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")