How to show status of multiple stopwatches in Android notifications


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.

1
Sep 1 at 5:26 PM
User AvatarJoyce Opio
#android#service#notifications

No answer found for this question yet.