The first part focused on getting the stopwatch working, this one will focus on changing the implementation to support multiple stopwatches.
Part 1 Reminder
Here's how the orchestrator for handling one stopwatch looked like:
internal class StopwatchListOrchestrator(
private val stopwatchStateHolder: StopwatchStateHolder,
private val scope: CoroutineScope,
) {
private var job: Job? = null
private val mutableTicker = MutableStateFlow("")
val ticker: StateFlow<String> = mutableTicker
fun start() {
if (job == null) startJob()
stopwatchStateHolder.start()
}
private fun startJob() {
scope.launch {
while (isActive) {
mutableTicker.value = stopwatchStateHolder.getStringTimeRepresentation()
delay(20)
}
}
}
fun pause() {
stopwatchStateHolder.pause()
stopJob()
}
fun stop() {
stopwatchStateHolder.stop()
stopJob()
clearValue()
}
private fun stopJob() {
scope.coroutineContext.cancelChildren()
job = null
}
private fun clearValue() {
mutableTicker.value = ""
}
}
The StopwatchStateHolder represents one stopwatch instance, it has the methods for changing the stopwatch state and for getting its string representation. The implementation of it will be omitted as it remains the same as in the first part of this article series.
A little background
The app I'm building (Timi Compose) revolves around so-called tasks, the idea is that the user will use the stopwatch to track the time spent on doing a task. In this article, the tasks will only be used as an identifier without going into details: one task can only have one stopwatch, and a stopwatch cannot exist without a task.
What has to change
Currently, the public API of the orchestrator only assumes one stopwatch:
class StopwatchListOrchestrator(...) {
val ticker: StateFlow<String>
fun start()
fun pause()
fun stop()
}
Every stopwatch action will require a task in order to differentiate between multiple stopwatches:
fun start(task: Task)
fun pause(task: Task)
fun stop(task: Task)
The ticker StateFlow which is collected in the UI only emits a string, in order to correctly bind tasks to their stopwatches it needs to emit a data structure which allows for that. For this I've used a Map which is then iterated on in the UI to produce a list of stopwatches:
val ticker: StateFlow<Map<Task, String>>
The last thing that needs to change is the fact that the Orchestrator only uses one StopwatchStateHolder for all stopwatch actions. Currently, a single Holder is injected through the constructor, but this only allows for a single stopwatch to exist.
To allow multiple stopwatches, the Holder needs to be created when a stopwatch is first started. However, instead of creating the Holder inside the Orchestrator it's better to move this responsibility to a separate class which will be a factory for the Holders:
internal class StopwatchStateHolderFactory(
private val stopwatchStateCalculator: StopwatchStateCalculator,
private val elapsedTimeCalculator: ElapsedTimeCalculator,
private val timestampMillisecondsFormatter: TimestampMillisecondsFormatter,
) {
fun create(): StopwatchStateHolder {
return StopwatchStateHolder(
stopwatchStateCalculator = stopwatchStateCalculator,
elapsedTimeCalculator = elapsedTimeCalculator,
timestampMillisecondsFormatter = timestampMillisecondsFormatter
)
}
}
Applying the changes
The constructor
internal class StopwatchListOrchestrator(
private val stopwatchStateHolderFactory: StopwatchStateHolderFactory,
private val scope: CoroutineScope,
)
StateFlow ticker
private var job: Job? = null
private var stopwatchStateHolders = ConcurrentHashMap<Task, StopwatchStateHolder>()
private val mutableTicker = MutableStateFlow<Map<Task, String>>(mapOf())
val ticker: StateFlow<Map<Task, String>> = mutableTicker
The reasoning behind the ticker type was explained earlier however one additional property was introduced to keep track of the existing stopwatches: stopwatchStateHolders.
A ConcurrentHashMap was used to safely modify the Map without causing a ConcurrentModificationException. This exception could happen because one thread is reading the value (updating the ticker) and a different thread modifies it (changing the stopwatch state).
Stopwatch start
fun start(task: Task) {
if (job == null) startJob()
val stopwatchForTask = stopwatchStateHolders.getOrPut(task) {
stopwatchStateHolderFactory.create()
}
stopwatchForTask.start()
}
private fun startJob() {
job = scope.launch {
while (isActive) {
val newValues = stopwatchStateHolders
.toSortedMap(compareBy { task -> task.id })
.map { (task, stateHolder) ->
task to stateHolder.getStringTimeRepresentation()
}
.toMap()
mutableTicker.value = newValues
delay(20)
}
}
}
The job is only null when there are no existing stopwatches.
The stopwatchStateHolders is used to update the ticker and map the tasks to their stopwatch time.
The stopwatch is created with the getOrPut extension function, which calls the passed in lambda in order to create the element (only if it doesn't exist).
I've done performance tests to the ticker update loop by providing 10000 stopwatches, and it seemed to work fairly well. The mapping lasted well over 20 milliseconds, but to the end user there was nothing out of the ordinary. Looking at this from the perspective of real world usage, I don't believe there will be a lot of situations when over 5 stopwatches are running at the same time.
Stopwatch pause
fun pause(task: Task) {
val stopwatchForTask = stopwatchStateHolders[task] ?: return
stopwatchForTask.pause()
if (areAllStopwatchesPaused()) stopJob()
}
private fun areAllStopwatchesPaused(): Boolean =
stopwatchStateHolders.values.all { stateHolder ->
stateHolder.currentState is StopwatchState.Paused
}
The pausing is self-explanatory however when all existing stopwatches are paused then the ticker update job is stopped. The reasoning for this is that when no stopwatches are running, the ticker value won't change either way.
Stopwatch stop
fun stop(task: Task) {
stopwatchStateHolders.remove(task)
if (stopwatchStateHolders.isEmpty()) {
stopJob()
resetTicker()
}
}
In case no stopwatches exist, the ticker update job is also stopped with the addition of resetting the ticker value.
Helpers
private fun stopJob() {
scope.coroutineContext.cancelChildren()
job = null
}
private fun resetTicker() {
mutableTicker.value = mapOf()
}
The difference between canceling the coroutine scope and its children was already outlined in the first part, but I'll try to briefly explain it. Cancelling the scope prevents any new stopwatches from starting in the future. Cancelling only the children does not block new stopwatches from starting.
The full Orchestrator implementation
internal class StopwatchListOrchestrator(
private val stopwatchStateHolderFactory: StopwatchStateHolderFactory,
private val scope: CoroutineScope,
) {
private var job: Job? = null
private var stopwatchStateHolders = ConcurrentHashMap<Task, StopwatchStateHolder>()
private val mutableTicker = MutableStateFlow<Map<Task, String>>(mapOf())
val ticker: StateFlow<Map<Task, String>> = mutableTicker
fun start(task: Task) {
if (job == null) startJob()
val stopwatchForTask = stopwatchStateHolders.getOrPut(task) {
stopwatchStateHolderFactory.create()
}
stopwatchForTask.start()
}
private fun startJob() {
job = scope.launch {
while (isActive) {
val newValues = stopwatchStateHolders
.toSortedMap(compareBy { task -> task.id })
.map { (task, stateHolder) ->
task to stateHolder.getStringTimeRepresentation()
}
.toMap()
mutableTicker.value = newValues
delay(20)
}
}
}
fun pause(task: Task) {
val stopwatchForTask = stopwatchStateHolders[task] ?: return
stopwatchForTask.pause()
if (areAllStopwatchesPaused()) stopJob()
}
private fun areAllStopwatchesPaused(): Boolean =
stopwatchStateHolders.values.all { stateHolder ->
stateHolder.currentState is StopwatchState.Paused
}
fun stop(task: Task) {
stopwatchStateHolders.remove(task)
if (stopwatchStateHolders.isEmpty()) {
stopJob()
resetTicker()
}
}
private fun stopJob() {
scope.coroutineContext.cancelChildren()
job = null
}
private fun resetTicker() {
mutableTicker.value = mapOf()
}
}
Unit tests
I won't show all the tests because it would create too much noise, if you're interested checkout the GitHub repository. It contains tests for the classes from the first part and of course this new orchestrator.
The test framework I'm using is JUnit5, strikt for assertions and mockk.
Test scaffolding
companion object {
private val TASK1 = Task(
id = 1,
name = "First task"
)
private val TASK2 = Task(
id = 2,
name = "Second the cooler task"
)
}
private val stopwatchStateHolder: StopwatchStateHolder = mockk(relaxed = true) {
every { getStringTimeRepresentation() } returns ""
}
private val stopwatchStateHolderFactory: StopwatchStateHolderFactory = mockk {
every { create() } returns stopwatchStateHolder
}
private val coroutineDispatcher = TestCoroutineDispatcher()
private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher)
private lateinit var systemUnderTest: StopwatchListOrchestrator
@BeforeEach
fun setUp() {
systemUnderTest = StopwatchListOrchestrator(
stopwatchStateHolderFactory = stopwatchStateHolderFactory,
scope = coroutineScope
)
}
Stopwatch start
@Test
fun `When a stopwatch is started then it should be present in the list`() = runBlockingTest {
givenStateHolderReturnsTime("0")
systemUnderTest.start(task = TASK1)
coroutineDispatcher.advanceTimeBy(1000)
val result = systemUnderTest.ticker.first()
expect {
that(result).hasSize(1)
val timeString = result[TASK1]
that(timeString).isEqualTo("0")
}
}
private fun givenStateHolderReturnsTime(vararg timeString: String) {
every { stopwatchStateHolder.getStringTimeRepresentation() } returnsMany listOf(*timeString)
}
@Test
fun `When a stopwatch is running then its value should be updated`() = runBlockingTest {
givenStateHolderReturnsTime("0", "1", "2", "3", "4", "5")
systemUnderTest.start(task = TASK1)
coroutineDispatcher.advanceTimeBy(1000)
val result = systemUnderTest.ticker.first()
expectThat(result[TASK1]).isEqualTo("5")
}
@Test
fun `Multiple stopwatches can run in parallel`() = runBlockingTest {
givenStateHolderReturnsTime("0", "1", "2")
systemUnderTest.start(task = TASK1)
systemUnderTest.start(task = TASK2)
coroutineDispatcher.advanceTimeBy(1000)
val result = systemUnderTest.ticker.first()
expectThat(result[TASK1]).isEqualTo("2")
expectThat(result[TASK2]).isEqualTo("2")
}
Stopwatch pause
@Test
fun `When a stopwatch is paused then its value should not change`() = runBlockingTest {
givenStateHolderReturnsTime("0", "1", "2", "3", "4", "5")
systemUnderTest.start(task = TASK1)
coroutineDispatcher.advanceTimeBy(50)
systemUnderTest.pause(task = TASK1)
val result = systemUnderTest.ticker.first()
expectThat(result[TASK1]).isEqualTo("1")
}
Coroutines
@Test
fun `Initially the scope is inactive`() {
coroutineDispatcher.advanceTimeBy(1000)
expectThat(coroutineScope).hasChildrenCount(0)
}
private fun DescribeableBuilder<CoroutineScope>.hasChildrenCount(count: Int) {
val job = coroutineScope.coroutineContext.job
expectThat(job.children.count()).isEqualTo(count)
}
@Test
fun `When the first stopwatch is started then scope should become active`() {
systemUnderTest.start(task = TASK1)
coroutineDispatcher.advanceTimeBy(1000)
expectThat(coroutineScope).hasChildrenCount(1)
}
@Test
fun `When every stopwatch is paused then the scope should become inactive`() {
every { stopwatchStateHolder.currentState }
.returns(StopwatchState.Paused(0.toTimestampMilliseconds()))
systemUnderTest.start(task = TASK1)
systemUnderTest.start(task = TASK2)
systemUnderTest.pause(task = TASK2)
systemUnderTest.pause(task = TASK1)
expectThat(coroutineScope).hasChildrenCount(0)
}
@Test
fun `When a paused stopwatch is started then the scope should become active`() {
every { stopwatchStateHolder.currentState }
.returns(StopwatchState.Paused(0.toTimestampMilliseconds()))
systemUnderTest.start(task = TASK1)
systemUnderTest.pause(task = TASK1)
systemUnderTest.start(task = TASK1)
expectThat(coroutineScope).hasChildrenCount(1)
}
UI tests
Disclaimer: I'm not that experienced with UI testing, so these tests might not be the best examples. The implementation of the test infrastructure is hidden because it's not that important for this article. All the tests are available in the repository, they use the Jetpack Compose testing framework.
The most important part is that for testing the TimestampProvider is replaced with a stub in order to avoid test flakiness. Normally the provider returns the current system timestamp, but for testing it returns a prepared value.
class TimestampProviderStub : TimestampProvider {
var currentMilliseconds: Long = 0
override fun getMilliseconds(): Long = currentMilliseconds
}
Stopwatch start
@Test
fun addingAStopwatchStartsTheStopwatchInTheList() {
timestampProviderStub.currentMilliseconds = 0
stopwatchScreenRobot.clickAddButton()
addStopwatchDialogRobot.selectTaskWithName(FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 22000
stopwatchScreenVerifier.confirmStopwatchForTaskExists(FIRST_TASK_NAME)
stopwatchScreenVerifier.confirmStopwatchForTaskHasTime(
taskName = FIRST_TASK_NAME,
timestamp = "00:22:000"
)
}
@Test
fun multipleStopwatchesCanBeStartedAndAreUpdated() {
timestampProviderStub.currentMilliseconds = 0
stopwatchScreenRobot.clickAddButton()
addStopwatchDialogRobot.selectTaskWithName(FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 30000
stopwatchScreenRobot.clickAddButton()
addStopwatchDialogRobot.selectTaskWithName(SECOND_TASK_NAME)
timestampProviderStub.currentMilliseconds = 60000
Thread.sleep(50)
stopwatchScreenVerifier.confirmStopwatchForTaskExists(FIRST_TASK_NAME)
stopwatchScreenVerifier.confirmStopwatchForTaskHasTime(
taskName = FIRST_TASK_NAME,
timestamp = "01:00:000"
)
stopwatchScreenVerifier.confirmStopwatchForTaskExists(SECOND_TASK_NAME)
stopwatchScreenVerifier.confirmStopwatchForTaskHasTime(
taskName = SECOND_TASK_NAME,
timestamp = "00:30:000"
)
}
Stopwatch pause
@Test
fun pausingAStopwatchPreventsItFromUpdatingTheTime() {
timestampProviderStub.currentMilliseconds = 0
stopwatchScreenRobot.clickAddButton()
addStopwatchDialogRobot.selectTaskWithName(FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 30000
stopwatchScreenRobot.pauseStopwatchForTask(taskName = FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 60000
Thread.sleep(50)
stopwatchScreenVerifier.confirmStopwatchForTaskHasTime(
taskName = FIRST_TASK_NAME,
timestamp = "00:30:000"
)
}
@Test
fun pausingAndResumingTheStopwatchWorksCorrectly() {
timestampProviderStub.currentMilliseconds = 0
stopwatchScreenRobot.clickAddButton()
addStopwatchDialogRobot.selectTaskWithName(FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 30000
stopwatchScreenRobot.pauseStopwatchForTask(taskName = FIRST_TASK_NAME)
stopwatchScreenRobot.resumeStopwatchForTask(taskName = FIRST_TASK_NAME)
timestampProviderStub.currentMilliseconds = 60000
Thread.sleep(50)
stopwatchScreenVerifier.confirmStopwatchForTaskHasTime(
taskName = FIRST_TASK_NAME,
timestamp = "01:00:000"
)
}
From what I experienced when creating Compose UI tests is that they are flaky, probably because the Jetpack Compose testing framework is still under development. However, I'm sure that with time it will be more reliable.
Summary
I tried my best to keep the process of adding support for multiple stopwatches simple and hope that it was at least somewhat insightful.
The current implementation only keeps the stopwatch in the memory, meaning that if the app is closed/killed the stopwatches are gone. In the future, I'm planning on creating an Android Service which will keep the stopwatch running in the background. The process of creating such a service might also result in a part 3 of this article series.