Introduce PushHistoryService to store data about the received push (#4573)

* Introduce PushHistoryService to store data about the received push

Add a push database.

* Update screenshots

* Improve preview.

* Update screenshots

* Add missing test.

* Add test for PushHistoryView

* Fix configuration issue.

Was: w: /libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/history/PushHistoryPresenterTest.kt:35:27 Cannot access class 'PushProvider' in the expression type. While it may work, this case indicates a configuration mistake and can lead to avoidable compilation errors, so it may be forbidden soon. Check your module classpath for missing or conflicting dependencies.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-04-11 12:56:54 +02:00 committed by GitHub
parent a0b619007a
commit a1d8322738
60 changed files with 1656 additions and 214 deletions

View file

@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPushHistoryEntryPoint @Inject constructor() : PushHistoryEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PushHistoryEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : PushHistoryEntryPoint.NodeBuilder {
override fun callback(callback: PushHistoryEntryPoint.Callback): PushHistoryEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<PushHistoryNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,12 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
sealed interface PushHistoryEvents {
data object Reset : PushHistoryEvents
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.services.analytics.api.ScreenTracker
@ContributesNode(SessionScope::class)
class PushHistoryNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PushHistoryPresenter,
private val screenTracker: ScreenTracker,
) : Node(buildContext, plugins = plugins) {
private fun onDone() {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onDone()
}
}
private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onItemClick(sessionId, roomId, eventId)
}
}
@Composable
override fun View(modifier: Modifier) {
screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot)
val state = presenter.present()
PushHistoryView(
state = state,
onBackClick = ::onDone,
onItemClick = ::onItemClick,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.push.api.PushService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import javax.inject.Inject
class PushHistoryPresenter @Inject constructor(
private val pushService: PushService,
) : Presenter<PushHistoryState> {
@Composable
override fun present(): PushHistoryState {
val coroutineScope = rememberCoroutineScope()
val pushCounter by pushService.pushCounter.collectAsState(0)
val pushHistory by remember {
pushService.getPushHistoryItemsFlow()
}.collectAsState(emptyList())
fun handleEvents(event: PushHistoryEvents) {
when (event) {
PushHistoryEvents.Reset -> coroutineScope.launch {
pushService.resetPushHistory()
}
}
}
return PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistory.toImmutableList(),
eventSink = ::handleEvents
)
}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import io.element.android.libraries.push.api.history.PushHistoryItem
import kotlinx.collections.immutable.ImmutableList
data class PushHistoryState(
val pushCounter: Int,
val pushHistoryItems: ImmutableList<PushHistoryItem>,
val eventSink: (PushHistoryEvents) -> Unit,
)

View file

@ -0,0 +1,72 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.history.PushHistoryItem
import kotlinx.collections.immutable.toImmutableList
open class PushHistoryStateProvider : PreviewParameterProvider<PushHistoryState> {
override val values: Sequence<PushHistoryState>
get() = sequenceOf(
aPushHistoryState(),
aPushHistoryState(
pushCounter = 123,
pushHistoryItems = listOf(
aPushHistoryItem(
hasBeenResolved = false,
comment = "An error description"
),
aPushHistoryItem(
pushDate = 1,
providerInfo = "providerInfo2",
eventId = EventId("\$anEventId"),
roomId = RoomId("!roomId:domain"),
sessionId = SessionId("@alice:server.org"),
hasBeenResolved = true,
comment = "A comment"
)
)
),
)
}
fun aPushHistoryState(
pushCounter: Int = 0,
pushHistoryItems: List<PushHistoryItem> = emptyList(),
eventSink: (PushHistoryEvents) -> Unit = {},
) = PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistoryItems.toImmutableList(),
eventSink = eventSink,
)
fun aPushHistoryItem(
pushDate: Long = 0,
formattedDate: String = "formattedDate",
providerInfo: String = "providerInfo",
eventId: EventId? = null,
roomId: RoomId? = null,
sessionId: SessionId? = null,
hasBeenResolved: Boolean = false,
comment: String? = null,
): PushHistoryItem {
return PushHistoryItem(
pushDate = pushDate,
formattedDate = formattedDate,
providerInfo = providerInfo,
eventId = eventId,
roomId = roomId,
sessionId = sessionId,
hasBeenResolved = hasBeenResolved,
comment = comment
)
}

View file

@ -0,0 +1,229 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.history.PushHistoryItem
import io.element.android.libraries.troubleshoot.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
contentWindowInsets = WindowInsets.statusBars,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
Text(
text = stringResource(R.string.screen_push_history_title),
style = ElementTheme.typography.aliasScreenTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_reset),
onClick = {
state.eventSink(PushHistoryEvents.Reset)
},
)
}
)
},
) { padding ->
PushHistoryContent(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
state = state,
onItemClick = onItemClick,
)
}
}
@Composable
private fun PushHistoryContent(
state: PushHistoryState,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth()
) {
ListItem(
headlineContent = { Text("Total number of received push") },
trailingContent = ListItemContent.Text(state.pushCounter.toString()),
)
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(
items = state.pushHistoryItems,
key = {
it.pushDate.toString() + it.sessionId + it.roomId + it.eventId
},
) { pushHistory ->
PushHistoryItem(
pushHistory,
onClick = {
val sessionId = pushHistory.sessionId
val roomId = pushHistory.roomId
val eventId = pushHistory.eventId
if (sessionId != null && roomId != null && eventId != null) {
onItemClick(sessionId, roomId, eventId)
}
}
)
}
}
}
}
@Composable
private fun PushHistoryItem(
pushHistoryItem: PushHistoryItem,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
onClick()
},
) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
) {
Text(
text = pushHistoryItem.formattedDate,
color = ElementTheme.colors.textPrimary,
)
Text(
text = pushHistoryItem.providerInfo,
color = ElementTheme.colors.textPrimary,
)
Text(
modifier = Modifier.padding(start = 8.dp, top = 8.dp),
text = pushHistoryItem.sessionId?.value ?: "No sessionId",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = pushHistoryItem.roomId?.value ?: "No roomId",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = pushHistoryItem.eventId?.value ?: "No eventId",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
)
pushHistoryItem.comment?.let {
Text(
modifier = Modifier.padding(top = 8.dp),
text = it,
color = if (pushHistoryItem.hasBeenResolved) {
ElementTheme.colors.textSecondary
} else {
ElementTheme.colors.textCriticalPrimary
},
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
if (pushHistoryItem.hasBeenResolved) {
Icon(
imageVector = CompoundIcons.CheckCircleSolid(),
modifier = Modifier.size(24.dp),
tint = ElementTheme.colors.iconSuccessPrimary,
contentDescription = null,
)
} else {
Icon(
imageVector = CompoundIcons.Error(),
modifier = Modifier.size(24.dp),
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun PushHistoryViewPreview(
@PreviewParameter(PushHistoryStateProvider::class) state: PushHistoryState,
) = ElementPreview {
PushHistoryView(
state = state,
onBackClick = {},
onItemClick = { _, _, _ -> },
)
}

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_push_history_title">"Push history"</string>
<string name="troubleshoot_notifications_screen_action">"Run tests"</string>
<string name="troubleshoot_notifications_screen_action_again">"Run tests again"</string>
<string name="troubleshoot_notifications_screen_failure">"Some tests failed. Please check the details."</string>

View file

@ -0,0 +1,75 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.troubleshoot.impl.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PushHistoryPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPushHistoryPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.pushCounter).isEqualTo(0)
assertThat(initialState.pushHistoryItems).isEmpty()
}
}
@Test
fun `present - updating state`() = runTest {
val pushService = FakePushService()
val presenter = createPushHistoryPresenter(
pushService = pushService,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.pushCounter).isEqualTo(0)
assertThat(initialState.pushHistoryItems).isEmpty()
pushService.emitPushCounter(1)
assertThat(awaitItem().pushCounter).isEqualTo(1)
val item = aPushHistoryItem()
pushService.emitPushHistoryItems(listOf(item))
assertThat(awaitItem().pushHistoryItems).containsExactly(item)
}
}
@Test
fun `present - reset`() = runTest {
val resetPushHistoryResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
resetPushHistoryResult = resetPushHistoryResult,
)
val presenter = createPushHistoryPresenter(
pushService = pushService,
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PushHistoryEvents.Reset)
runCurrent()
resetPushHistoryResult.assertions().isCalledOnce()
}
}
private fun createPushHistoryPresenter(
pushService: PushService = FakePushService(),
): PushHistoryPresenter {
return PushHistoryPresenter(
pushService = pushService,
)
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.troubleshoot.impl.history
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PushHistoryViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on Reset sends a PushHistoryEvents`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
rule.setPushHistoryView(
aPushHistoryState(
pushCounter = 123,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_reset)
eventsRecorder.assertSingle(PushHistoryEvents.Reset)
// Also check that the push counter is rendered
rule.onNodeWithText("123").assertExists()
}
@Test
fun `clicking on an invalid event has no effect`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
rule.setPushHistoryView(
aPushHistoryState(
pushHistoryItems = listOf(
aPushHistoryItem(
formattedDate = A_FORMATTED_DATE,
)
),
eventSink = eventsRecorder,
),
)
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
// No callback invoked
}
@Test
fun `clicking on a valid event invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
val onItemClick = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
rule.setPushHistoryView(
aPushHistoryState(
pushHistoryItems = listOf(
aPushHistoryItem(
formattedDate = A_FORMATTED_DATE,
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
sessionId = A_SESSION_ID,
)
),
eventSink = eventsRecorder,
),
onItemClick = onItemClick,
)
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
onItemClick.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(),
) {
setContent {
PushHistoryView(
state = state,
onBackClick = onBackClick,
onItemClick = onItemClick,
)
}
}