Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app-base/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,10 @@

<string name="whitelist_settings_template">System setting %d</string>

<!--Permission settings-->
<string name="pref_full_screen_notifications">Full-screen notifications</string>
<string name="pref_full_screen_notifications_summary">Allow timer alerts to display over other apps</string>

<!--Account-->
<string name="account_sign_out_confirmation">Sign out?</string>
<string name="account_sign_out">Sign Out</string>
Expand Down
12 changes: 12 additions & 0 deletions app-broadcast/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
}
14 changes: 14 additions & 0 deletions app-broadcast/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
<receiver
android:name=".TimerBroadcastReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="io.github.deweyreed.timer.BROADCAST_TIMER_CONTROL" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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<Int>): List<Pair<Int, String>> {
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
}
}
Original file line number Diff line number Diff line change
@@ -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<NotificationManager>()
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<NotificationManager>() ?: 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"
}
}
Original file line number Diff line number Diff line change
@@ -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
)
)
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -222,6 +244,16 @@ class SettingsFragment :

findPreference<Preference>(KEY_NOTIF_SETTING)?.onPreferenceClickListener = this

findPreference<Preference>(KEY_FULL_SCREEN_NOTIFICATIONS)?.run {
onPreferenceClickListener = this@SettingsFragment
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val nm = requireContext().getSystemService<NotificationManager>()
isVisible = nm != null && !nm.canUseFullScreenIntent()
} else {
isVisible = false
}
}

findPreference<Preference>(KEY_AUDIO_VOLUME)?.run {
onPreferenceClickListener = this@SettingsFragment
isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
Expand Down Expand Up @@ -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"

Expand Down
8 changes: 8 additions & 0 deletions app-settings/src/main/res/xml/pref_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@
app:key="key_media_style_notification"
app:title="@string/pref_media_style_notification" />

<Preference
app:icon="@drawable/settings_notifications"
app:key="key_full_screen_notifications"
app:persistent="false"
app:singleLineTitle="false"
app:title="@string/pref_full_screen_notifications"
app:summary="@string/pref_full_screen_notifications_summary" />

</PreferenceCategory>

<PreferenceCategory app:title="@string/pref_category_title_audio">
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/io/github/deweyreed/timer/di/OtherModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions data/src/main/java/xyz/aprildown/timer/data/db/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimerInfoData>

Expand Down
Loading