diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 302d5075..16ae9c7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") id("com.google.devtools.ksp") id("org.jetbrains.kotlin.plugin.serialization") version "1.9.23" } diff --git a/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt new file mode 100644 index 00000000..9316b832 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt @@ -0,0 +1,18 @@ +package com.bnyro.clock.domain.model + +import android.os.Parcelable +import androidx.compose.runtime.mutableStateOf +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TimerDescriptor( + var id: Int = 0, + var currentPosition: Int = 0, +) : Parcelable { + fun asScheduledObject(): TimerObject { + return TimerObject( + id = id, + currentPosition = mutableStateOf(currentPosition), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/domain/model/ScheduledObject.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt similarity index 94% rename from app/src/main/java/com/bnyro/clock/domain/model/ScheduledObject.kt rename to app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt index 2f4d0a9f..332df474 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/ScheduledObject.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt @@ -4,7 +4,7 @@ import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf -data class ScheduledObject( +data class TimerObject( var id: Int = 0, var label: MutableState = mutableStateOf(null), var currentPosition: MutableState = mutableStateOf(0), diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt index 9c459c9f..fb217420 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -33,7 +33,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,15 +65,14 @@ fun TimerScreen(onClickSettings: () -> Unit, timerModel: TimerModel) { val useScrollPicker = Preferences.instance.getBoolean(Preferences.timerUsePickerKey, false) val showExampleTimers = Preferences.instance.getBoolean(Preferences.timerShowExamplesKey, true) - LaunchedEffect(Unit) { - timerModel.tryConnect(context) - } var createNew by remember { mutableStateOf(false) } + val scheduledObjects by timerModel.scheduledObjects.collectAsState() + TopBarScaffold(title = stringResource(R.string.timer), onClickSettings, actions = { - if (timerModel.scheduledObjects.isEmpty()) { + if (scheduledObjects.isEmpty()) { ClickableIcon( imageVector = Icons.Rounded.AddAlarm, contentDescription = stringResource(R.string.add_preset_timer) @@ -89,7 +88,7 @@ fun TimerScreen(onClickSettings: () -> Unit, timerModel: TimerModel) { } } }) { paddingValues -> - if (timerModel.scheduledObjects.isEmpty()) { + if (scheduledObjects.isEmpty()) { Column( Modifier .padding(paddingValues) @@ -112,8 +111,8 @@ fun TimerScreen(onClickSettings: () -> Unit, timerModel: TimerModel) { .padding(paddingValues), verticalArrangement = Arrangement.Top ) { - itemsIndexed(timerModel.scheduledObjects) { index, obj -> - TimerItem(obj, index, timerModel) + items(scheduledObjects, key = { it.id }) { obj -> + TimerItem(obj, timerModel) } } KeepScreenOn() diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt index c37fd1d7..b611b1e9 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bnyro.clock.R -import com.bnyro.clock.domain.model.ScheduledObject +import com.bnyro.clock.domain.model.TimerObject import com.bnyro.clock.domain.model.WatchState import com.bnyro.clock.presentation.components.ClickableIcon import com.bnyro.clock.presentation.components.DialogButton @@ -44,7 +44,7 @@ import com.bnyro.clock.presentation.screens.timer.model.TimerModel import com.bnyro.clock.util.extensions.addZero @Composable -fun TimerItem(obj: ScheduledObject, index: Int, timerModel: TimerModel) { +fun TimerItem(obj: TimerObject, timerModel: TimerModel) { val context = LocalContext.current val hours = obj.currentPosition.value / 3600000 val minutes = (obj.currentPosition.value % 3600000) / 60000 @@ -94,15 +94,11 @@ fun TimerItem(obj: ScheduledObject, index: Int, timerModel: TimerModel) { showRingtoneEditor = true } ClickableIcon(imageVector = Icons.Default.Close) { - timerModel.stopTimer(context, index) + timerModel.stopTimer(context, obj.id) } FilledIconButton( onClick = { - when (obj.state.value) { - WatchState.PAUSED -> timerModel.resumeTimer(index) - WatchState.RUNNING -> timerModel.pauseTimer(index) - else -> timerModel.startTimer(context) - } + timerModel.pauseResumeTimer(context, obj.id) } ) { Icon( @@ -135,7 +131,7 @@ fun TimerItem(obj: ScheduledObject, index: Int, timerModel: TimerModel) { onDismissRequest = { showLabelEditor = false }, confirmButton = { DialogButton(android.R.string.ok) { - timerModel.service?.updateLabel(obj.id, newLabel) + timerModel.updateLabel(obj.id, newLabel) newLabel = "" showLabelEditor = false } @@ -173,14 +169,14 @@ fun TimerItem(obj: ScheduledObject, index: Int, timerModel: TimerModel) { Checkbox( checked = obj.vibrate, onCheckedChange = { - timerModel.service?.updateVibrate(obj.id, it) + timerModel.updateVibrate(obj.id, it) } ) Text(text = stringResource(R.string.vibrate)) } } ) { _, uri -> - timerModel.service?.updateRingtone(obj.id, uri) + timerModel.updateRingtone(obj.id, uri) } } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt index ab2e0207..d2890dc4 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt @@ -1,29 +1,28 @@ package com.bnyro.clock.presentation.screens.timer.model -import android.annotation.SuppressLint -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder +import android.net.Uri import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import com.bnyro.clock.domain.model.PersistentTimer -import com.bnyro.clock.domain.model.ScheduledObject -import com.bnyro.clock.util.services.ScheduleService +import com.bnyro.clock.domain.model.TimerDescriptor +import com.bnyro.clock.domain.model.TimerObject import com.bnyro.clock.util.services.TimerService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow class TimerModel : ViewModel() { - var scheduledObjects = mutableStateListOf() - private var objectToEnqueue: ScheduledObject? = null + val _timerObjects = MutableStateFlow(emptyList()) + val scheduledObjects = _timerObjects.asStateFlow() - @SuppressLint("StaticFieldLeak") - var service: TimerService? = null + var onEnqueue: ((timer: TimerObject) -> Unit)? = null + var updateLabel: (id: Int, newLabel: String) -> Unit = { _, _ -> } + var updateRingtone: (id: Int, newRingtoneUri: Uri?) -> Unit = { _, _ -> } + var updateVibrate: (id: Int, vibrate: Boolean) -> Unit = { _, _ -> } var persistentTimers by mutableStateOf( PersistentTimer.getTimers(), @@ -55,21 +54,9 @@ class TimerModel : ViewModel() { timePickerSeconds += (value - seconds) } - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(component: ComponentName, binder: IBinder) { - service = (binder as ScheduleService.LocalBinder).getService() as? TimerService - service?.changeListener = { objects -> - this@TimerModel.scheduledObjects.clear() - this@TimerModel.scheduledObjects.addAll(objects) - } - objectToEnqueue?.let { service?.enqueueNew(it) } - objectToEnqueue = null - } - override fun onServiceDisconnected(p0: ComponentName?) { - scheduledObjects.clear() - service = null - } + fun onChangeTimers(objects: Array) { + _timerObjects.value = listOf(*objects) } fun removePersistentTimer(index: Int) { @@ -84,60 +71,52 @@ class TimerModel : ViewModel() { val totalSeconds = delay ?: timePickerSeconds if (totalSeconds == 0) return - if (scheduledObjects.isEmpty()) { - runCatching { - context.unbindService(serviceConnection) - } - service = null - } - - val newTimer = ScheduledObject( - label = mutableStateOf(null), - id = System.currentTimeMillis().toInt(), - currentPosition = mutableStateOf(totalSeconds * 1000) + val newTimer = TimerDescriptor( + // id randomized by system current time; used modulo to compensate for integer overflow + id = (System.currentTimeMillis() % Int.MAX_VALUE).toInt(), + currentPosition = totalSeconds * 1000 ) timePickerSeconds = 0 timePickerFakeUnits = 0 - if (service == null) { - startService(context) - objectToEnqueue = newTimer + if (onEnqueue == null) { + startService(context, newTimer) } else { - service?.enqueueNew(newTimer) + onEnqueue?.invoke(newTimer.asScheduledObject()) } } - private fun startService(context: Context) { + private fun startService(context: Context, timerDescriptor: TimerDescriptor) { val intent = Intent(context, TimerService::class.java) - runCatching { - context.stopService(intent) - } - runCatching { - context.unbindService(serviceConnection) - } - ContextCompat.startForegroundService(context, intent) - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) - } - - fun tryConnect(context: Context) { - val intent = Intent(context, TimerService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_ABOVE_CLIENT) - } - - fun pauseTimer(index: Int) { - service?.pause(scheduledObjects[index]) + .putExtra(TimerService.INITIAL_TIMER_EXTRA_KEY, timerDescriptor) + context.startService(intent) } - fun resumeTimer(index: Int) { - service?.resume(scheduledObjects[index]) + fun pauseResumeTimer(context: Context, index: Int) { + val pauseResumeIntent = Intent(TimerService.UPDATE_STATE_ACTION) + .putExtra( + TimerService.ID_EXTRA_KEY, + index + ) + .putExtra( + TimerService.ACTION_EXTRA_KEY, + TimerService.ACTION_PAUSE_RESUME + ) + context.sendBroadcast(pauseResumeIntent) } fun stopTimer(context: Context, index: Int) { - val obj = scheduledObjects[index] - scheduledObjects.removeAt(index) - service?.stop(obj) - if (scheduledObjects.isEmpty()) context.unbindService(serviceConnection) + val stopIntent = Intent(TimerService.UPDATE_STATE_ACTION) + .putExtra( + TimerService.ID_EXTRA_KEY, + index + ) + .putExtra( + TimerService.ACTION_EXTRA_KEY, + TimerService.ACTION_STOP + ) + context.sendBroadcast(stopIntent) } /* =============== Numpad time picker ======================== */ diff --git a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt index c624f699..3f78d71f 100644 --- a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt +++ b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt @@ -30,17 +30,21 @@ import com.bnyro.clock.presentation.features.AlarmReceiverDialog import com.bnyro.clock.presentation.features.TimerReceiverDialog import com.bnyro.clock.presentation.screens.settings.model.SettingsModel import com.bnyro.clock.presentation.screens.stopwatch.model.StopwatchModel +import com.bnyro.clock.presentation.screens.timer.model.TimerModel import com.bnyro.clock.ui.theme.ClockYouTheme import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.ThemeUtil import com.bnyro.clock.util.services.StopwatchService +import com.bnyro.clock.util.services.TimerService class MainActivity : ComponentActivity() { val stopwatchModel by viewModels() + val timerModel by viewModels() private var initialTab: NavRoutes = NavRoutes.Alarm lateinit var stopwatchService: StopwatchService + private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { val binder = (service as StopwatchService.LocalBinder) @@ -62,6 +66,31 @@ class MainActivity : ComponentActivity() { } } + lateinit var timerService: TimerService + + private val timerServiceConnection = object : ServiceConnection { + override fun onServiceConnected(component: ComponentName, service: IBinder) { + val binder = (service as TimerService.LocalBinder) + timerService = binder.getService() + timerService.onChangeTimers = timerModel::onChangeTimers + + timerModel.onEnqueue = { + timerService.enqueueNew(it) + } + timerModel.updateLabel = timerService::updateLabel + timerModel.updateRingtone = timerService::updateRingtone + timerModel.updateVibrate = timerService::updateVibrate + } + + override fun onServiceDisconnected(p0: ComponentName?) { + timerService.onChangeTimers = {} + timerModel.onEnqueue = null + timerModel.updateLabel = { _, _ -> } + timerModel.updateRingtone = { _, _ -> } + timerModel.updateVibrate = { _, _ -> } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -119,11 +148,15 @@ class MainActivity : ComponentActivity() { Intent(this, StopwatchService::class.java).also { intent -> bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) } + Intent(this, TimerService::class.java).also { intent -> + bindService(intent, timerServiceConnection, Context.BIND_AUTO_CREATE) + } } override fun onStop() { super.onStop() unbindService(serviceConnection) + unbindService(timerServiceConnection) } diff --git a/app/src/main/java/com/bnyro/clock/util/services/ScheduleService.kt b/app/src/main/java/com/bnyro/clock/util/services/ScheduleService.kt deleted file mode 100644 index 6d0002c9..00000000 --- a/app/src/main/java/com/bnyro/clock/util/services/ScheduleService.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.bnyro.clock.util.services - -import android.Manifest -import android.annotation.SuppressLint -import android.app.Notification -import android.app.PendingIntent -import android.app.Service -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.pm.PackageManager -import android.os.Binder -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.annotation.StringRes -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat -import com.bnyro.clock.R -import com.bnyro.clock.domain.model.ScheduledObject -import com.bnyro.clock.domain.model.WatchState -import java.util.Timer -import java.util.TimerTask - -abstract class ScheduleService : Service() { - abstract val notificationId: Int - private val binder = LocalBinder() - private val timer = Timer() - private val handler = Handler(Looper.getMainLooper()) - - var scheduledObjects = mutableListOf() - val updateDelay = 10 - - var changeListener: (objects: List) -> Unit = {} - - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - Log.e("receive", intent.toString()) - val id = intent.getIntExtra(ID_EXTRA_KEY, 0) - val obj = scheduledObjects.find { it.id == id } ?: return - when (intent.getStringExtra(ACTION_EXTRA_KEY)) { - ACTION_STOP -> stop(obj) - ACTION_PAUSE_RESUME -> { - if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) - } - } - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - override fun onCreate() { - super.onCreate() - startForeground(notificationId, getStartNotification()) - timer.scheduleAtFixedRate( - object : TimerTask() { - override fun run() { - handler.post(this@ScheduleService::updateState) - } - }, - 0, - updateDelay.toLong() - ) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - receiver, - IntentFilter(UPDATE_STATE_ACTION), - RECEIVER_EXPORTED - ) - } else { - registerReceiver( - receiver, - IntentFilter(UPDATE_STATE_ACTION) - ) - } - } - - fun enqueueNew(scheduledObject: ScheduledObject) { - scheduledObject.state.value = WatchState.RUNNING - scheduledObjects.add(scheduledObject) - invokeChangeListener() - updateNotification(scheduledObject) - } - - fun invokeChangeListener() { - changeListener.invoke(scheduledObjects) - } - - fun pause(scheduledObject: ScheduledObject) { - scheduledObject.state.value = WatchState.PAUSED - invokeChangeListener() - updateNotification(scheduledObject) - } - - fun resume(scheduledObject: ScheduledObject) { - scheduledObject.state.value = WatchState.RUNNING - invokeChangeListener() - updateNotification(scheduledObject) - } - - fun stop(scheduledObject: ScheduledObject) { - scheduledObjects.removeAll { it.id == scheduledObject.id } - NotificationManagerCompat.from(this).cancel(scheduledObject.id) - invokeChangeListener() - if (scheduledObjects.isEmpty()) onDestroy() - } - - abstract fun updateState() - - fun updateNotification(scheduledObject: ScheduledObject) { - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) { - NotificationManagerCompat.from(this) - .notify(scheduledObject.id, getNotification(scheduledObject)) - } - } - - abstract fun getNotification(scheduledObject: ScheduledObject): Notification - - abstract fun getStartNotification(): Notification - - fun pauseResumeAction(scheduledObject: ScheduledObject): NotificationCompat.Action { - val text = - if (scheduledObject.state.value == WatchState.PAUSED) R.string.resume else R.string.pause - return getAction(text, ACTION_PAUSE_RESUME, 5, scheduledObject.id) - } - - fun stopAction(scheduledObject: ScheduledObject) = getAction( - R.string.stop, - ACTION_STOP, - 4, - scheduledObject.id - ) - - private fun getAction( - @StringRes stringRes: Int, - action: String, - requestCode: Int, - objectId: Int - ): NotificationCompat.Action { - val intent = Intent(UPDATE_STATE_ACTION) - .putExtra(ACTION_EXTRA_KEY, action) - .putExtra(ID_EXTRA_KEY, objectId) - val pendingIntent = PendingIntent.getBroadcast( - this, - requestCode + objectId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - return NotificationCompat.Action.Builder(null, getString(stringRes), pendingIntent).build() - } - - override fun onDestroy() { - runCatching { - unregisterReceiver(receiver) - } - timer.cancel() - changeListener = {} - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - stopSelf() - - super.onDestroy() - } - - override fun onBind(intent: Intent) = binder - - inner class LocalBinder : Binder() { - fun getService() = this@ScheduleService - } - - companion object { - const val UPDATE_STATE_ACTION = "com.bnyro.clock.UPDATE_STATE" - const val ACTION_EXTRA_KEY = "action" - const val ID_EXTRA_KEY = "id" - const val ACTION_PAUSE_RESUME = "pause_resume" - const val ACTION_STOP = "stop" - } -} diff --git a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt index 77e2f917..ab657254 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt @@ -1,51 +1,134 @@ package com.bnyro.clock.util.services import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.net.Uri +import android.os.Binder import android.os.Build +import android.os.Handler +import android.os.Looper import android.text.format.DateUtils +import android.util.Log +import androidx.annotation.StringRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat import com.bnyro.clock.R -import com.bnyro.clock.domain.model.ScheduledObject +import com.bnyro.clock.domain.model.TimerDescriptor +import com.bnyro.clock.domain.model.TimerObject import com.bnyro.clock.domain.model.WatchState import com.bnyro.clock.util.NotificationHelper import com.bnyro.clock.util.RingtoneHelper +import java.util.Timer +import java.util.TimerTask -class TimerService : ScheduleService() { - override val notificationId = 2 - private val finishedNotificationId = 3 +class TimerService : Service() { + private val notificationId = 2 + private val timer = Timer() + private val binder = LocalBinder() + private val handler = Handler(Looper.getMainLooper()) - override fun getNotification(scheduledObject: ScheduledObject) = NotificationCompat.Builder( + var onChangeTimers: (objects: Array) -> Unit = {} + + var timerObjects = mutableListOf() + val updateDelay = 100 + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.e("receive", intent.toString()) + val id = intent.getIntExtra(ID_EXTRA_KEY, 0) + val obj = timerObjects.find { it.id == id } ?: return + when (intent.getStringExtra(ACTION_EXTRA_KEY)) { + ACTION_STOP -> stop(obj) + ACTION_PAUSE_RESUME -> { + if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) + } + } + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onCreate() { + super.onCreate() + timer.scheduleAtFixedRate( + object : TimerTask() { + override fun run() { + handler.post(this@TimerService::updateState) + } + }, + 0, + updateDelay.toLong() + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + receiver, + IntentFilter(UPDATE_STATE_ACTION), + RECEIVER_EXPORTED + ) + } else { + registerReceiver( + receiver, + IntentFilter(UPDATE_STATE_ACTION) + ) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val timer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(INITIAL_TIMER_EXTRA_KEY, TimerDescriptor::class.java) + } else { + intent?.getParcelableExtra(INITIAL_TIMER_EXTRA_KEY) as TimerDescriptor? + } + if (timer != null) { + val scheduledObject = timer.asScheduledObject() + startForeground(scheduledObject.id, getStartNotification()) + enqueueNew(scheduledObject) + } else { + startForeground(notificationId, getStartNotification()) + } + return START_STICKY + } + + fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( this, NotificationHelper.TIMER_CHANNEL ) .setContentTitle(getText(R.string.timer)) - .setUsesChronometer(scheduledObject.state.value == WatchState.RUNNING) + .setUsesChronometer(timerObject.state.value == WatchState.RUNNING) .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { setChronometerCountDown(true) } else { setContentText( DateUtils.formatElapsedTime( - (scheduledObject.currentPosition.value / 1000).toLong() + (timerObject.currentPosition.value / 1000).toLong() ) ) } } - .setWhen(System.currentTimeMillis() + scheduledObject.currentPosition.value) - .addAction(stopAction(scheduledObject)) - .addAction(pauseResumeAction(scheduledObject)) + .setWhen(System.currentTimeMillis() + timerObject.currentPosition.value) + .addAction(stopAction(timerObject)) + .addAction(pauseResumeAction(timerObject)) .setSmallIcon(R.drawable.ic_notification) .build() - override fun updateState() { - scheduledObjects.forEach { + fun invokeChangeListener() { + onChangeTimers.invoke(timerObjects.toTypedArray()) + } + + fun updateState() { + val stopped = mutableListOf() + timerObjects.forEach { if (it.state.value == WatchState.RUNNING) { it.currentPosition.value -= updateDelay - invokeChangeListener() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { updateNotification(it) @@ -54,14 +137,51 @@ class TimerService : ScheduleService() { if (it.currentPosition.value <= 0) { it.state.value = WatchState.IDLE - invokeChangeListener() showFinishedNotification(it) - stop(it) + stopped.add(it) } } + stopped.forEach { + stop(it) + } + } + + fun enqueueNew(timerObject: TimerObject) { + timerObject.state.value = WatchState.RUNNING + timerObjects.add(timerObject) + invokeChangeListener() + updateNotification(timerObject) + } + + + fun pause(timerObject: TimerObject) { + timerObject.state.value = WatchState.PAUSED + updateNotification(timerObject) + } + + fun resume(timerObject: TimerObject) { + timerObject.state.value = WatchState.RUNNING + updateNotification(timerObject) } - private fun showFinishedNotification(scheduledObject: ScheduledObject) { + fun updateNotification(timerObject: TimerObject) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + ) { + NotificationManagerCompat.from(this) + .notify(timerObject.id, getNotification(timerObject)) + } + } + + fun stop(timerObject: TimerObject) { + timerObjects.remove(timerObject) + invokeChangeListener() + if (timerObjects.isEmpty()) stopSelf() + } + + private fun showFinishedNotification(timerObject: TimerObject) { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS @@ -72,43 +192,97 @@ class TimerService : ScheduleService() { NotificationHelper.TIMER_FINISHED_CHANNEL ) .setSmallIcon(R.drawable.ic_notification) - .setSound(scheduledObject.ringtone ?: RingtoneHelper.getDefault(this)) - .setVibrate(NotificationHelper.vibrationPattern.takeIf { scheduledObject.vibrate }) + .setSound(timerObject.ringtone ?: RingtoneHelper.getDefault(this)) + .setVibrate(NotificationHelper.vibrationPattern.takeIf { timerObject.vibrate }) .setContentTitle(getString(R.string.timer_finished)) - .setContentText(scheduledObject.label.value) + .setContentText(timerObject.label.value) .build() NotificationManagerCompat.from(this) - .notify(finishedNotificationId, notification) + .notify(timerObject.id, notification) } } + fun pauseResumeAction(timerObject: TimerObject): NotificationCompat.Action { + val text = + if (timerObject.state.value == WatchState.PAUSED) R.string.resume else R.string.pause + return getAction(text, ACTION_PAUSE_RESUME, 5, timerObject.id) + } + + private fun getAction( + @StringRes stringRes: Int, + action: String, + requestCode: Int, + objectId: Int + ): NotificationCompat.Action { + val intent = Intent(UPDATE_STATE_ACTION) + .putExtra(ACTION_EXTRA_KEY, action) + .putExtra(ID_EXTRA_KEY, objectId) + val pendingIntent = PendingIntent.getBroadcast( + this, + requestCode + objectId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action.Builder(null, getString(stringRes), pendingIntent).build() + } + + fun stopAction(timerObject: TimerObject) = getAction( + R.string.stop, + ACTION_STOP, + 4, + timerObject.id + ) + fun updateLabel(id: Int, newLabel: String) { - scheduledObjects.firstOrNull { it.id == id }?.let { + timerObjects.firstOrNull { it.id == id }?.let { it.label.value = newLabel - invokeChangeListener() } } fun updateRingtone(id: Int, newRingtoneUri: Uri?) { - scheduledObjects.firstOrNull { it.id == id }?.let { + timerObjects.firstOrNull { it.id == id }?.let { it.ringtone = newRingtoneUri - invokeChangeListener() } } fun updateVibrate(id: Int, vibrate: Boolean) { - scheduledObjects.firstOrNull { it.id == id }?.let { + timerObjects.firstOrNull { it.id == id }?.let { it.vibrate = vibrate - invokeChangeListener() } } - override fun getStartNotification() = NotificationCompat.Builder( + override fun onDestroy() { + runCatching { + unregisterReceiver(receiver) + } + timer.cancel() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + + super.onDestroy() + } + + fun getStartNotification() = NotificationCompat.Builder( this, NotificationHelper.TIMER_SERVICE_CHANNEL ) .setContentTitle(getString(R.string.timer_service)) .setSmallIcon(R.drawable.ic_notification) .build() + + override fun onBind(intent: Intent) = binder + + inner class LocalBinder : Binder() { + fun getService() = this@TimerService + } + + companion object { + const val UPDATE_STATE_ACTION = "com.bnyro.clock.UPDATE_STATE" + const val ACTION_EXTRA_KEY = "action" + const val ID_EXTRA_KEY = "id" + const val INITIAL_TIMER_EXTRA_KEY = "timer" + const val ACTION_PAUSE_RESUME = "pause_resume" + const val ACTION_STOP = "stop" + } }