I'm building an app which times an activity that is being currently carried out. When user wants to time an activity, he starts a stopwatch. This stopwatch is launched by a Service. The user has an option to pause the activity. When an activity is paused, a so called sub-activity is started, i.e. the Service is supposed to pause the activity's stopwatch and launch a second stopwatch for the sub-activity. And when the user resumes the original activity, the original activity's stopwatch should be resumed and the sub-activity's stopwatch should be stopped. I would like to display a notification for the activity's stopwatch alongside a notification for the sub-activity's stopwatch (if it had been started) (like messages in GMail).
I tried working it out in the following way:
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import com.eternalfairy.lotus.R
import com.eternalfairy.lotus.model.repository.ActivityRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@AndroidEntryPoint
class StopwatchService: Service() {
companion object {
// Service actions
// - start the main activity
const val START = "START"
// - stop both activities
const val STOP = "STOP"
// - pause the main activity and start the sub-activity
const val PAUSE = "PAUSE"
// - resume the main activity and pause the sub-activity
const val RESUME = "RESUME"
const val STOPWATCH_STATE = "STOPWATCH_STATE"
const val NOTIFICATION_CHANNEL_ID = "Stopwatch_Notifications"
const val NOTIFICATION_CHANNEL_NAME = "STOPWATCH_NOTIFICATION"
const val MAIN_ACTIVITY_NOTIFICATION_ID = 1
const val SUB_ACTIVITY_NOTIFICATION_ID = 2
const val GROUP_KEY = "activities"
const val CLICK_REQUEST_CODE = 100
const val CANCEL_REQUEST_CODE = 101
const val STOP_REQUEST_CODE = 102
const val RESUME_REQUEST_CODE = 103
}
@Inject
lateinit var activityRepository: ActivityRepository
private lateinit var notificationManager: NotificationManager
private val binder = StopwatchBinder()
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.Default)
private var stopwatches = ConcurrentHashMap<Int, Stopwatch>()
private fun buildNotification(): Notification {
val title = "Activity"//TODO: Read the activity's title from the database
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(title)
.setOngoing(true)
.setContentText("00:00:00")
.setColorized(true)
.setColor("#BEAEE2".toColorInt())
.setSmallIcon(R.drawable.calendar_success_svgrepo_com)//TODO: Change to my logo
.setOngoing(true)
.setContentIntent(ServiceHelper.clickPendingIntent(this))
.setGroup(GROUP_KEY)
.build()
}
private fun createNotificationChannel() {
// Create the NotificationChannel, but only on API 26+ because the NotificationChannel class is not in the Support Library
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
)
notificationChannel.setShowBadge(true)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(notificationChannel)
}
}
fun getStopwatch(id: Int): Stopwatch? {
return stopwatches.get(id)
}
private fun getNotificationManager() {
notificationManager = ContextCompat.getSystemService(
this,
NotificationManager::class.java,
) as NotificationManager
}
override fun onBind(intent: Intent?) = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Thread(Runnable {
createNotificationChannel()
getNotificationManager()
startForegroundService()
intent?.action.let {
when (it) {
START -> startStopwatch { contentText->
updateNotification(contentText)
}
PAUSE -> pauseStopwatch({ contentText ->
updateNotification(contentText, SUB_ACTIVITY_NOTIFICATION_ID)
})
RESUME -> resumeStopwatch({ contentText ->
updateNotification(contentText)
})
STOP -> stopStopwatch()
}
}
}).start()
return super.onStartCommand(intent, flags, startId)
}
private fun pauseStopwatch(onTick: (contentText: String) -> Unit) {
// Pause the main activity stopwatch
stopwatches.get(MAIN_ACTIVITY_NOTIFICATION_ID)?.pause()
// Create a stopwatch for the sub-activity
stopwatches.getOrPut(SUB_ACTIVITY_NOTIFICATION_ID) {
Stopwatch()
}
job = scope.launch {
// Start the sub-activity stopwatch
val stopwatch = stopwatches.get(SUB_ACTIVITY_NOTIFICATION_ID)
stopwatch?.start()
while (true) {
runStopwatch(stopwatch, onTick)
delay(20)// OPTIMIZE Why 20 not 1000 millis?
}
}
}
private fun resumeStopwatch(onTick: (contentText: String) -> Unit) {
// Cancel the coroutine which updates the sub-activity stopwatch
job?.cancel()
// Cancel the sub-activity notification
notificationManager.cancel(SUB_ACTIVITY_NOTIFICATION_ID)
// Stop the sub activity stopwatch
stopwatches.get(SUB_ACTIVITY_NOTIFICATION_ID)?.stop()
job = scope.launch {
// Resume the main activity stopwatch
val stopwatch = stopwatches.get(MAIN_ACTIVITY_NOTIFICATION_ID)
stopwatch?.resume()
while (true) {
runStopwatch(stopwatch, onTick)
delay(20)// OPTIMIZE Why 20 not 1000 millis?
}
}
}
private fun runStopwatch(stopwatch: Stopwatch?, callback: (contentText: String) -> Unit) {
if (stopwatch == null) return
// Update the stopwatch
stopwatch.updateRunningState()
// Read the time values from the stopwatch
val hours = stopwatch.hours
val minutes = stopwatch.minutes
val seconds = stopwatch.seconds
val formattedText = stopwatch.format()
// Update the notification with the new time values
callback(formattedText)
}
@SuppressLint("ForegroundServiceType")
private fun startForegroundService() {
startForeground(MAIN_ACTIVITY_NOTIFICATION_ID, buildNotification())
}
private fun startStopwatch(onTick: (contentText: String) -> Unit) {
// Create a stopwatch for the main activity
stopwatches.getOrPut(MAIN_ACTIVITY_NOTIFICATION_ID) {
Stopwatch()
}
if (job == null) {
job = scope.launch {
// Start the stopwatch
val stopwatch = stopwatches.get(MAIN_ACTIVITY_NOTIFICATION_ID)
stopwatch?.start()
while (true) {
runStopwatch(stopwatch, onTick)
delay(1000)
}
}
}
}
private fun stopForegroundService() {
notificationManager.cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
// Stop the service
stopSelf()
}
private fun stopStopwatch() {
// Stop both stopwatches
stopwatches.forEach { entry -> entry.value.stop() }
// Cancel coroutine
job?.cancel()
stopForegroundService()
}
private fun updateNotification(text: String, notificationId: Int = MAIN_ACTIVITY_NOTIFICATION_ID) {
notificationManager.notify(
notificationId,
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setOngoing(true)
// For devices running Android 7.1 (API level 25) or lower
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentText(text)
.setColorized(true)
.setColor("#BEAEE2".toColorInt())
.setSmallIcon(R.drawable.calendar_success_svgrepo_com)
.setOngoing(true)
.setContentIntent(ServiceHelper.clickPendingIntent(this))
.setGroup(GROUP_KEY)
.build()
)
}
inner class StopwatchBinder : Binder() {
fun getService(): StopwatchService = this@StopwatchService
}
}
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import com.eternalfairy.lotus.MainActivity
import com.eternalfairy.lotus.view.service.StopwatchService.Companion.CLICK_REQUEST_CODE
// Specifies the behaviour for the notification
//@AndroidEntryPoint
object ServiceHelper {
private val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
fun clickPendingIntent(context: Context): PendingIntent {
// When the notification is clicked, the MainActivity should be opened
val clickIntent = Intent(context, MainActivity::class.java).apply {
}
return PendingIntent.getActivity(
context,
CLICK_REQUEST_CODE,
clickIntent,
flag
)
}
fun triggerForegroundService(context: Context, action: String) {
Intent(context, StopwatchService::class.java).apply {
this.action = action
context.startService(this)
}
}
}
sealed class StopwatchState {
data class Running(val startTime: Long, val elapsedTime: Long) : StopwatchState()
data class Paused(val elapsedTime: Long) : StopwatchState()
}
class Stopwatch () {
companion object {
const val SECOND_IN_MILLISECONDS = 1000
const val MINUTE_IN_MILLISECONDS = SECOND_IN_MILLISECONDS * 60
const val HOUR_IN_MILLISECONDS = MINUTE_IN_MILLISECONDS * 60
}
var currentState: StopwatchState = StopwatchState.Paused(0L)
private set
var hours = 0L
var minutes = 0L
var seconds = 0L
fun calculateTime(timestamp: Long) {
hours = timestamp / HOUR_IN_MILLISECONDS
minutes = (timestamp % HOUR_IN_MILLISECONDS) / MINUTE_IN_MILLISECONDS
seconds = (timestamp % MINUTE_IN_MILLISECONDS) / SECOND_IN_MILLISECONDS
}
fun pause() {
val oldState = currentState as StopwatchState.Running
val elapsedTime = if (oldState.startTime < System.currentTimeMillis()) System.currentTimeMillis() - oldState.startTime else 0L
currentState = StopwatchState.Paused(elapsedTime)
}
fun resume() {
val oldState = currentState as StopwatchState.Paused
val elapsedTime = oldState.elapsedTime
currentState = StopwatchState.Running(System.currentTimeMillis(), elapsedTime)
}
fun updateRunningState() {
val oldState = currentState as StopwatchState.Running
val elapsedTime = if (oldState.startTime < System.currentTimeMillis()) System.currentTimeMillis() - oldState.startTime else 0L
currentState = StopwatchState.Running(oldState.startTime, elapsedTime)
calculateTime(elapsedTime)
}
fun start() {
currentState = StopwatchState.Running(System.currentTimeMillis(), 0L)
}
fun stop() {
currentState = StopwatchState.Paused(0L)
}
fun format(): String {
val secondsFormatted = (seconds % 60).pad(2)
val minutesFormatted = (minutes % 60).pad(2)
val hoursFormatted = (hours / 60).pad(2)
return "$hoursFormatted:$minutesFormatted:$secondsFormatted"
}
private fun Long.pad(desiredLength: Int) = this.toString().padStart(desiredLength, '0')
}
To be honest, I'm not wrapping my head around how I should use jobs/threads or the notifications.
The notification sound is triggered every second (I want the sound to appear only when the notification is first created, not every time it is updated); and I'm getting two notifications but with different settings and in different channels (I would like them to be displayed in one channel, one directly beneath the other).
I would really appreciate any tips and explanations as I feel like I don't have a good idea of what I'm doing here.