From 4b7b6550ef00ec12b0fc8e125b91427b74d92535 Mon Sep 17 00:00:00 2001 From: Suhas Dissanayake Date: Tue, 20 Feb 2024 10:30:18 +0530 Subject: [PATCH] fix: improved stopwatch service and notification --- .../bnyro/clock/services/StopwatchService.kt | 217 +++++++++++++++--- .../java/com/bnyro/clock/ui/MainActivity.kt | 52 ++++- .../bnyro/clock/ui/model/StopwatchModel.kt | 70 ++---- .../bnyro/clock/ui/screens/StopwatchScreen.kt | 33 +-- .../com/bnyro/clock/util/PermissionHelper.kt | 2 +- 5 files changed, 280 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/com/bnyro/clock/services/StopwatchService.kt b/app/src/main/java/com/bnyro/clock/services/StopwatchService.kt index 50d52a45..871098f3 100644 --- a/app/src/main/java/com/bnyro/clock/services/StopwatchService.kt +++ b/app/src/main/java/com/bnyro/clock/services/StopwatchService.kt @@ -1,45 +1,210 @@ package com.bnyro.clock.services -import androidx.compose.runtime.mutableStateOf +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.util.Log +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.bnyro.clock.R -import com.bnyro.clock.obj.ScheduledObject import com.bnyro.clock.obj.WatchState +import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.util.NotificationHelper +import java.util.Timer +import java.util.TimerTask -class StopwatchService : ScheduleService() { - override val notificationId = 1 +class StopwatchService : Service() { + private val notificationId = 1 + var currentPosition = 0 + private set + var state = WatchState.IDLE + private set + + private val timer = Timer() + + private lateinit var contentIntent: PendingIntent + private lateinit var notificationManager: NotificationManagerCompat + private var notificationPermission = true + + var onPositionChange: (Int) -> Unit = {} + var onStateChange: (WatchState) -> Unit = {} + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val extra = intent.getStringExtra(ACTION_EXTRA_KEY) + Log.d("Stopwatch Actions", extra.toString()) + when (extra) { + ACTION_START -> start() + ACTION_STOP -> stop() + ACTION_PAUSE_RESUME -> { + if (state == WatchState.PAUSED) resume() else pause() + } + } + } + } override fun onCreate() { super.onCreate() - scheduledObjects.add( - ScheduledObject( - state = mutableStateOf(WatchState.RUNNING), - id = notificationId - ) + notificationManager = NotificationManagerCompat.from(this) + + contentIntent = PendingIntent.getActivity( + this, + 8, + Intent(this, MainActivity::class.java).setAction(MainActivity.SHOW_STOPWATCH_ACTION), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermission = ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + registerReceiver(receiver, IntentFilter(STOPWATCH_INTENT_ACTION), RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(receiver, IntentFilter(STOPWATCH_INTENT_ACTION)) + } } - override fun updateState() { - scheduledObjects.forEach { - if (it.state.value == WatchState.RUNNING) { - it.currentPosition.value += updateDelay - invokeChangeListener() + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(receiver) + counterTask?.cancel() + counterTask = null + timer.cancel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(notificationId, getNotification()) + return START_STICKY + } + + private var counterTask: TimerTask? = null + + private fun start() { + currentPosition = 0 + updateState(WatchState.RUNNING) + counterTask = object : TimerTask() { + override fun run() { + if (state != WatchState.PAUSED) { + currentPosition += UPDATE_DELAY + onPositionChange(currentPosition) + } } } + timer.scheduleAtFixedRate(counterTask, 0, UPDATE_DELAY.toLong()) + } + + private fun stop() { + updateState(WatchState.IDLE) + notificationManager.cancel(notificationId) + currentPosition = 0 + onPositionChange(currentPosition) + counterTask?.cancel() + counterTask = null + stopSelf() + } + + @SuppressLint("MissingPermission") + private fun updateNotification() { + if (notificationPermission) { + notificationManager.notify(notificationId, getNotification()) + } + } + + @SuppressLint("DefaultLocale") + private fun getNotification(): Notification { + return NotificationCompat.Builder( + this, + NotificationHelper.STOPWATCH_CHANNEL + ) + .setContentTitle(getText(R.string.stopwatch)) + .setSmallIcon(R.drawable.ic_notification) + .setContentIntent(contentIntent) + .apply { + if (state != WatchState.IDLE) { + addAction(stopAction()) + addAction(pauseResumeAction()) + } + } + .setUsesChronometer(state == WatchState.RUNNING) + .setWhen(System.currentTimeMillis() - currentPosition) + .build() + } + + private fun pause() { + updateState(WatchState.PAUSED) + } + + private fun resume() { + updateState(WatchState.RUNNING) + } + + private fun updateState(newState: WatchState) { + state = newState + updateNotification() + onStateChange(newState) + } + + private fun getAction(title: String, requestCode: Int, action: String) = + NotificationCompat.Action.Builder( + null, + title, + getPendingIntent( + Intent(STOPWATCH_INTENT_ACTION).putExtra( + ACTION_EXTRA_KEY, + action + ), requestCode + ) + ).build() + + private fun stopAction() = + getAction(getString(R.string.stop), STOP_ACTION_REQUEST_CODE, ACTION_STOP) + + private fun pauseResumeAction(): NotificationCompat.Action { + val text = + if (state == WatchState.RUNNING) getString(R.string.pause) else getString(R.string.resume) + return getAction( + text, + PAUSE_RESUME_ACTION_REQUEST_CODE, + ACTION_PAUSE_RESUME + ) } - override fun getNotification(scheduledObject: ScheduledObject) = NotificationCompat.Builder( - this, - NotificationHelper.STOPWATCH_CHANNEL - ) - .setContentTitle(getText(R.string.stopwatch)) - .setUsesChronometer(scheduledObject.state.value == WatchState.RUNNING) - .setWhen(System.currentTimeMillis() - scheduledObject.currentPosition.value) - .addAction(stopAction(scheduledObject)) - .addAction(pauseResumeAction(scheduledObject)) - .setSmallIcon(R.drawable.ic_notification) - .build() + private fun getPendingIntent(intent: Intent, requestCode: Int): PendingIntent = + PendingIntent.getBroadcast( + this, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + private val binder = LocalBinder() + override fun onBind(intent: Intent) = binder - override fun getStartNotification() = getNotification(ScheduledObject()) + inner class LocalBinder : Binder() { + fun getService() = this@StopwatchService + } + + companion object { + private const val UPDATE_DELAY = 10 + + const val STOPWATCH_INTENT_ACTION = "com.bnyro.clock.STOPWATCH_ACTION" + const val ACTION_EXTRA_KEY = "action" + const val ACTION_PAUSE_RESUME = "pause_resume" + const val ACTION_STOP = "stop" + const val ACTION_START = "start" + + const val STOP_ACTION_REQUEST_CODE = 6 + const val PAUSE_RESUME_ACTION_REQUEST_CODE = 7 + } } 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 655e33ac..296c6d67 100644 --- a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt +++ b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt @@ -1,13 +1,18 @@ package com.bnyro.clock.ui import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.os.IBinder import android.provider.AlarmClock -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme @@ -17,9 +22,11 @@ import androidx.compose.ui.Modifier import androidx.core.app.ActivityCompat import androidx.lifecycle.viewmodel.compose.viewModel import com.bnyro.clock.obj.Alarm +import com.bnyro.clock.services.StopwatchService import com.bnyro.clock.ui.dialog.AlarmReceiverDialog import com.bnyro.clock.ui.dialog.TimerReceiverDialog import com.bnyro.clock.ui.model.SettingsModel +import com.bnyro.clock.ui.model.StopwatchModel import com.bnyro.clock.ui.nav.NavContainer import com.bnyro.clock.ui.nav.NavRoutes import com.bnyro.clock.ui.nav.bottomNavItems @@ -28,12 +35,38 @@ import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.ThemeUtil class MainActivity : ComponentActivity() { + + val stopwatchModel by viewModels() + + lateinit var stopwatchService: StopwatchService + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = (service as StopwatchService.LocalBinder) + stopwatchService = binder.getService() + stopwatchModel.state = stopwatchService.state + stopwatchModel.currentPosition = stopwatchService.currentPosition + + stopwatchService.onStateChange = { + stopwatchModel.state = it + } + stopwatchService.onPositionChange = { + stopwatchModel.currentPosition = it + } + } + + override fun onServiceDisconnected(p0: ComponentName?) { + stopwatchService.onStateChange = {} + stopwatchService.onPositionChange = {} + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setContent { val settingsModel: SettingsModel = viewModel() val initialTab = when (intent?.action) { + SHOW_STOPWATCH_ACTION -> NavRoutes.Stopwatch AlarmClock.ACTION_SET_ALARM, AlarmClock.ACTION_SHOW_ALARMS -> NavRoutes.Alarm AlarmClock.ACTION_SET_TIMER, AlarmClock.ACTION_SHOW_TIMERS -> NavRoutes.Timer else -> bottomNavItems.first { @@ -75,6 +108,19 @@ class MainActivity : ComponentActivity() { } } + override fun onStart() { + super.onStart() + Intent(this, StopwatchService::class.java).also { intent -> + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + unbindService(serviceConnection) + } + + private fun getInitialAlarm(): Alarm? { if (intent?.action != AlarmClock.ACTION_SET_ALARM) return null @@ -116,4 +162,8 @@ class MainActivity : ComponentActivity() { ) } } + + companion object { + const val SHOW_STOPWATCH_ACTION = "com.bnyro.clock.SHOW_STOPWATCH_ACTION" + } } diff --git a/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt b/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt index 28759aa8..519eb58b 100644 --- a/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt +++ b/app/src/main/java/com/bnyro/clock/ui/model/StopwatchModel.kt @@ -1,71 +1,51 @@ package com.bnyro.clock.ui.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 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.obj.ScheduledObject import com.bnyro.clock.obj.WatchState -import com.bnyro.clock.services.ScheduleService import com.bnyro.clock.services.StopwatchService class StopwatchModel : ViewModel() { - var scheduledObject by mutableStateOf(ScheduledObject()) val rememberedTimeStamps = mutableStateListOf() + var currentPosition by mutableStateOf(0) + var state: WatchState by mutableStateOf(WatchState.IDLE) - @SuppressLint("StaticFieldLeak") - private var service: StopwatchService? = null - - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(component: ComponentName, binder: IBinder) { - service = (binder as ScheduleService.LocalBinder).getService() as? StopwatchService - service?.changeListener = { - this@StopwatchModel.scheduledObject = it.firstOrNull() ?: ScheduledObject() - } - } - - override fun onServiceDisconnected(p0: ComponentName?) { - scheduledObject.state.value = WatchState.IDLE - service = null - } - } - - fun startStopwatch(context: Context) { - rememberedTimeStamps.clear() + private fun startStopwatch(context: Context) { val intent = Intent(context, StopwatchService::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, StopwatchService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_ABOVE_CLIENT) - } - fun pauseStopwatch() { - service?.pause(scheduledObject) + rememberedTimeStamps.clear() + val startIntent = Intent(StopwatchService.STOPWATCH_INTENT_ACTION).putExtra( + StopwatchService.ACTION_EXTRA_KEY, + StopwatchService.ACTION_START + ) + context.sendBroadcast(startIntent) } - fun resumeStopwatch() { - service?.resume(scheduledObject) + fun pauseResumeStopwatch(context: Context) { + when (state) { + WatchState.IDLE -> startStopwatch(context) + else -> { + val pauseResumeIntent = Intent(StopwatchService.STOPWATCH_INTENT_ACTION).putExtra( + StopwatchService.ACTION_EXTRA_KEY, + StopwatchService.ACTION_PAUSE_RESUME + ) + context.sendBroadcast(pauseResumeIntent) + } + } } fun stopStopwatch(context: Context) { - service?.stop(scheduledObject) - context.unbindService(serviceConnection) + val stopIntent = Intent(StopwatchService.STOPWATCH_INTENT_ACTION).putExtra( + StopwatchService.ACTION_EXTRA_KEY, + StopwatchService.ACTION_STOP + ) + context.sendBroadcast(stopIntent) } } diff --git a/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt b/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt index 54051d25..9d0cbdf0 100644 --- a/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt +++ b/app/src/main/java/com/bnyro/clock/ui/screens/StopwatchScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,10 +50,6 @@ import kotlinx.coroutines.launch fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) { val context = LocalContext.current - LaunchedEffect(Unit) { - stopwatchModel.tryConnect(context) - } - val scope = rememberCoroutineScope() val timeStampsState = rememberLazyListState() TopBarScaffold(title = stringResource(R.string.stopwatch), onClickSettings) { pv -> @@ -76,11 +71,11 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) Row( verticalAlignment = Alignment.Bottom ) { - val minutes = stopwatchModel.scheduledObject.currentPosition.value / 60000 + val minutes = stopwatchModel.currentPosition / 60000 val seconds = - (stopwatchModel.scheduledObject.currentPosition.value % 60000) / 1000 + (stopwatchModel.currentPosition % 60000) / 1000 val hundreds = - stopwatchModel.scheduledObject.currentPosition.value % 1000 / 10 + stopwatchModel.currentPosition % 1000 / 10 Text( text = minutes.toString(), @@ -99,7 +94,7 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) } } CircularProgressIndicator( - progress = (stopwatchModel.scheduledObject.currentPosition.value % 60000) / 60000f, + progress = (stopwatchModel.currentPosition % 60000) / 60000f, modifier = Modifier.size(320.dp), trackColor = MaterialTheme.colorScheme.surfaceVariant, strokeWidth = 12.dp, @@ -133,13 +128,13 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - AnimatedVisibility(stopwatchModel.scheduledObject.state.value == WatchState.RUNNING) { + AnimatedVisibility(stopwatchModel.state == WatchState.RUNNING) { Row { FloatingActionButton( containerColor = MaterialTheme.colorScheme.tertiaryContainer, onClick = { stopwatchModel.rememberedTimeStamps.add( - stopwatchModel.scheduledObject.currentPosition.value + stopwatchModel.currentPosition ) scope.launch { timeStampsState.scrollToItem( @@ -156,15 +151,11 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) LargeFloatingActionButton( shape = CircleShape, onClick = { - when (stopwatchModel.scheduledObject.state.value) { - WatchState.PAUSED -> stopwatchModel.resumeStopwatch() - WatchState.RUNNING -> stopwatchModel.pauseStopwatch() - else -> stopwatchModel.startStopwatch(context) - } + stopwatchModel.pauseResumeStopwatch(context) } ) { Icon( - imageVector = if (stopwatchModel.scheduledObject.state.value == WatchState.RUNNING) { + imageVector = if (stopwatchModel.state == WatchState.RUNNING) { Icons.Default.Pause } else { Icons.Default.PlayArrow @@ -172,10 +163,10 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) contentDescription = null ) } - AnimatedVisibility(stopwatchModel.scheduledObject.currentPosition.value != 0) { + AnimatedVisibility(stopwatchModel.currentPosition != 0) { Row { Spacer(modifier = Modifier.width(20.dp)) - if (stopwatchModel.scheduledObject.state.value != WatchState.PAUSED) { + if (stopwatchModel.state != WatchState.PAUSED) { FloatingActionButton( containerColor = MaterialTheme.colorScheme.tertiaryContainer, onClick = { stopwatchModel.stopStopwatch(context) } @@ -186,7 +177,7 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) FloatingActionButton( containerColor = MaterialTheme.colorScheme.tertiaryContainer, onClick = { - stopwatchModel.scheduledObject.currentPosition.value = 0 + stopwatchModel.stopStopwatch(context) stopwatchModel.rememberedTimeStamps.clear() } ) { @@ -198,7 +189,7 @@ fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) } } } - if (stopwatchModel.scheduledObject.state.value == WatchState.RUNNING) { + if (stopwatchModel.state == WatchState.RUNNING) { KeepScreenOn() } } diff --git a/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt b/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt index f9ace530..8685ca02 100644 --- a/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/PermissionHelper.kt @@ -16,7 +16,7 @@ object PermissionHelper { return true } - private fun hasPermission(context: Context, permission: String): Boolean { + fun hasPermission(context: Context, permission: String): Boolean { return ActivityCompat.checkSelfPermission( context, permission