Push: improve Push history screen, log and stored data (#4601)

* Add adb tools to help with doze mode and app standby

* Add info about the device state when an error occurs in push.

* Keep more events in the DB.

* Push history: add confirmation dialog when resetting the data

* Push history: add a filter to see only the errors

* Update screenshots

* Push history: print out invalid/ignored data received.

* Increase log level for push, to make such log more visible.
It also appears that sometimes Timber.d are not present in the rageshakes.

* Log priority

* Do not include device state for invalid/ignored event.

* Fix tests.

* Fix format issue.

* Fix mistake in code blocks and do not filter when not necessary.

* Improve formatting and add missing unit test.

* Reduce nesting of blocks.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-04-16 16:37:32 +02:00
committed by GitHub
parent 505d0b411d
commit f916e4e3d4
30 changed files with 388 additions and 58 deletions

View File

@@ -7,8 +7,13 @@
package io.element.android.libraries.push.impl.history
import android.content.Context
import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
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
@@ -21,15 +26,38 @@ import javax.inject.Inject
class DefaultPushHistoryService @Inject constructor(
private val pushDatabase: PushDatabase,
private val systemClock: SystemClock,
@ApplicationContext context: Context,
) : PushHistoryService {
private val powerManager = context.getSystemService<PowerManager>()
private val packageName = context.packageName
override fun onPushReceived(
providerInfo: String,
eventId: EventId?,
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
includeDeviceState: Boolean,
comment: String?,
) {
val finalComment = buildString {
append(comment.orEmpty())
if (includeDeviceState && powerManager != null) {
// Add info about device state
append("\n")
append(" - Idle: ${powerManager.isDeviceIdleMode}\n")
append(" - Power Save Mode: ${powerManager.isPowerSaveMode}\n")
append(" - Ignoring Battery Optimizations: ${powerManager.isIgnoringBatteryOptimizations(packageName)}\n")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
append(" - Device Light Idle Mode: ${powerManager.isDeviceLightIdleMode}\n")
append(" - Low Power Standby Enabled: ${powerManager.isLowPowerStandbyEnabled}\n")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
append(" - Exempt from Low Power Standby: ${powerManager.isExemptFromLowPowerStandby}\n")
}
}
}.takeIf { it.isNotEmpty() }
pushDatabase.pushHistoryQueries.insertPushHistory(
PushHistory(
pushDate = systemClock.epochMillis(),
@@ -38,11 +66,11 @@ class DefaultPushHistoryService @Inject constructor(
roomId = roomId?.value,
sessionId = sessionId?.value,
hasBeenResolved = if (hasBeenResolved) 1 else 0,
comment = comment,
comment = finalComment,
)
)
// Keep only the last 100 events
pushDatabase.pushHistoryQueries.removeOldest(100)
// Keep only the last 1_000 events
pushDatabase.pushHistoryQueries.removeOldest(1_000)
}
}

View File

@@ -22,19 +22,22 @@ interface PushHistoryService {
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
includeDeviceState: Boolean,
comment: String?,
)
}
fun PushHistoryService.onInvalidPushReceived(
providerInfo: String,
data: String,
) = onPushReceived(
providerInfo = providerInfo,
eventId = null,
roomId = null,
sessionId = null,
hasBeenResolved = false,
comment = "Invalid push data",
includeDeviceState = false,
comment = "Invalid or ignored push data:\n$data",
)
fun PushHistoryService.onUnableToRetrieveSession(
@@ -48,6 +51,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
roomId = roomId,
sessionId = null,
hasBeenResolved = false,
includeDeviceState = true,
comment = "Unable to retrieve session: $reason",
)
@@ -63,6 +67,7 @@ fun PushHistoryService.onUnableToResolveEvent(
roomId = roomId,
sessionId = sessionId,
hasBeenResolved = false,
includeDeviceState = true,
comment = "Unable to resolve event: $reason",
)
@@ -78,6 +83,7 @@ fun PushHistoryService.onSuccess(
roomId = roomId,
sessionId = sessionId,
hasBeenResolved = true,
includeDeviceState = false,
comment = buildString {
append("Success")
if (comment.isNullOrBlank().not()) {
@@ -94,5 +100,6 @@ fun PushHistoryService.onDiagnosticPush(
roomId = null,
sessionId = null,
hasBeenResolved = true,
includeDeviceState = false,
comment = "Diagnostic push",
)

View File

@@ -72,9 +72,9 @@ class DefaultPushHandler @Inject constructor(
}
}
override suspend fun handleInvalid(providerInfo: String) {
override suspend fun handleInvalid(providerInfo: String, data: String) {
incrementPushDataStore.incrementPushCounter()
pushHistoryService.onInvalidPushReceived(providerInfo)
pushHistoryService.onInvalidPushReceived(providerInfo, data)
}
/**

View File

@@ -19,8 +19,9 @@ class FakePushHistoryService(
RoomId?,
SessionId?,
Boolean,
Boolean,
String?
) -> Unit = { _, _, _, _, _, _ -> lambdaError() }
) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() }
) : PushHistoryService {
override fun onPushReceived(
providerInfo: String,
@@ -28,6 +29,7 @@ class FakePushHistoryService(
roomId: RoomId?,
sessionId: SessionId?,
hasBeenResolved: Boolean,
includeDeviceState: Boolean,
comment: String?,
) {
onPushReceivedResult(
@@ -36,6 +38,7 @@ class FakePushHistoryService(
roomId,
sessionId,
hasBeenResolved,
includeDeviceState,
comment
)
}

View File

@@ -60,7 +60,7 @@ class DefaultPushHandlerTest {
@Test
fun `check handleInvalid behavior`() = runTest {
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -68,12 +68,12 @@ class DefaultPushHandlerTest {
incrementPushCounterResult = incrementPushCounterResult,
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handleInvalid(A_PUSHER_INFO)
defaultPushHandler.handleInvalid(A_PUSHER_INFO, "data")
incrementPushCounterResult.assertions()
.isCalledOnce()
onPushReceivedResult.assertions()
.isCalledOnce()
.with(value(A_PUSHER_INFO), value(null), value(null), value(null), value(false), value("Invalid push data"))
.with(value(A_PUSHER_INFO), value(null), value(null), value(null), value(false), value(false), value("Invalid or ignored push data:\ndata"))
}
@Test
@@ -85,7 +85,7 @@ class DefaultPushHandlerTest {
}
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -133,7 +133,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -176,7 +176,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -221,7 +221,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -263,7 +263,7 @@ class DefaultPushHandlerTest {
unread = 0,
clientSecret = A_SECRET,
)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -290,7 +290,7 @@ class DefaultPushHandlerTest {
.isNeverCalled()
onPushReceivedResult.assertions()
.isCalledOnce()
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), any())
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
}
@Test
@@ -314,7 +314,7 @@ class DefaultPushHandlerTest {
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -359,7 +359,7 @@ class DefaultPushHandlerTest {
Unit,
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -403,7 +403,7 @@ class DefaultPushHandlerTest {
Unit,
> { _, _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -444,7 +444,7 @@ class DefaultPushHandlerTest {
)
val onRedactedEventReceived = lambdaRecorder<ResolvedPushEvent.Redaction, Unit> { }
val incrementPushCounterResult = lambdaRecorder<Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
@@ -476,7 +476,7 @@ class DefaultPushHandlerTest {
clientSecret = A_SECRET,
)
val diagnosticPushHandler = DiagnosticPushHandler()
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, String?, Unit> { _, _, _, _, _, _ -> }
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)

View File

@@ -89,7 +89,7 @@ class FakePushService(
pushCounterFlow.value = counter
}
override suspend fun resetPushHistory() {
override suspend fun resetPushHistory() = simulateLongTask {
resetPushHistoryResult()
}
}

View File

@@ -13,13 +13,13 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePushHandler(
private val handleResult: (PushData, String) -> Unit = { _, _ -> lambdaError() },
private val handleInvalidResult: (String) -> Unit = { lambdaError() },
private val handleInvalidResult: (String, String) -> Unit = { _, _ -> lambdaError() },
) : PushHandler {
override suspend fun handle(pushData: PushData, providerInfo: String) {
handleResult(pushData, providerInfo)
}
override suspend fun handleInvalid(providerInfo: String) {
handleInvalidResult(providerInfo)
override suspend fun handleInvalid(providerInfo: String, data: String) {
handleInvalidResult(providerInfo, data)
}
}

View File

@@ -15,5 +15,6 @@ interface PushHandler {
suspend fun handleInvalid(
providerInfo: String,
data: String,
)
}

View File

@@ -31,20 +31,23 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
}
override fun onNewToken(token: String) {
Timber.tag(loggerTag.value).d("New Firebase token")
Timber.tag(loggerTag.value).w("New Firebase token")
coroutineScope.launch {
firebaseNewTokenHandler.handle(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).d("New Firebase message")
Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}")
coroutineScope.launch {
val pushData = pushParser.parse(message.data)
if (pushData == null) {
Timber.tag(loggerTag.value).w("Invalid data received from Firebase")
pushHandler.handleInvalid(
providerInfo = FirebaseConfig.NAME,
data = message.data.keys.joinToString("\n") {
"$it: ${message.data[it]}"
},
)
} else {
pushHandler.handle(

View File

@@ -32,13 +32,24 @@ import org.robolectric.RobolectricTestRunner
class VectorFirebaseMessagingServiceTest {
@Test
fun `test receiving invalid data`() = runTest {
val lambda = lambdaRecorder<String, Unit> {}
val lambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
pushHandler = FakePushHandler(handleInvalidResult = lambda)
)
vectorFirebaseMessagingService.onMessageReceived(RemoteMessage(Bundle()))
vectorFirebaseMessagingService.onMessageReceived(
message = RemoteMessage(
Bundle().apply {
putString("a", "A")
putString("b", "B")
}
)
)
runCurrent()
lambda.assertions().isCalledOnce()
.with(
value(FirebaseConfig.NAME),
value("a: A\nb: B"),
)
}
@Test

View File

@@ -46,13 +46,14 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
* @param instance connection, for multi-account
*/
override fun onMessage(context: Context, message: ByteArray, instance: String) {
Timber.tag(loggerTag.value).d("New message")
Timber.tag(loggerTag.value).w("New message")
coroutineScope.launch {
val pushData = pushParser.parse(message, instance)
if (pushData == null) {
Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush")
pushHandler.handleInvalid(
providerInfo = "${UnifiedPushConfig.NAME} - $instance",
data = String(message),
)
} else {
pushHandler.handle(
@@ -68,7 +69,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
* You should send the endpoint to your application server and sync for missing notifications.
*/
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint")
Timber.tag(loggerTag.value).w("onNewEndpoint: $endpoint")
coroutineScope.launch {
val gateway = unifiedPushGatewayResolver.getGateway(endpoint)
.let { gatewayResult ->
@@ -109,7 +110,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
* Called when this application is unregistered from receiving push messages.
*/
override fun onUnregistered(context: Context, instance: String) {
Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered")
Timber.tag(loggerTag.value).w("Unifiedpush: Unregistered")
/*
val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME
pushDataStore.setFdroidSyncBackgroundMode(mode)

View File

@@ -90,7 +90,7 @@ class VectorUnifiedPushMessagingReceiverTest {
@Test
fun `onMessage invalid invokes the push handler invalid method`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val handleInvalidResult = lambdaRecorder<String, Unit> { }
val handleInvalidResult = lambdaRecorder<String, String, Unit> { _, _ -> }
val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver(
pushHandler = FakePushHandler(
handleInvalidResult = handleInvalidResult,

View File

@@ -8,5 +8,7 @@
package io.element.android.libraries.troubleshoot.impl.history
sealed interface PushHistoryEvents {
data object Reset : PushHistoryEvents
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
data object ClearDialog : PushHistoryEvents
}

View File

@@ -10,11 +10,15 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.push.api.PushService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -25,14 +29,36 @@ class PushHistoryPresenter @Inject constructor(
override fun present(): PushHistoryState {
val coroutineScope = rememberCoroutineScope()
val pushCounter by pushService.pushCounter.collectAsState(0)
val pushHistory by remember {
pushService.getPushHistoryItemsFlow()
var showOnlyErrors: Boolean by remember { mutableStateOf(false) }
val pushHistory by remember(showOnlyErrors) {
pushService.getPushHistoryItemsFlow().map {
if (showOnlyErrors) {
it.filter { item -> item.hasBeenResolved.not() }
} else {
it
}
}
}.collectAsState(emptyList())
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvents(event: PushHistoryEvents) {
when (event) {
PushHistoryEvents.Reset -> coroutineScope.launch {
pushService.resetPushHistory()
is PushHistoryEvents.SetShowOnlyErrors -> {
showOnlyErrors = event.showOnlyErrors
}
is PushHistoryEvents.Reset -> {
if (event.requiresConfirmation) {
resetAction = AsyncAction.ConfirmingNoParams
} else {
resetAction = AsyncAction.Loading
coroutineScope.launch {
pushService.resetPushHistory()
resetAction = AsyncAction.Uninitialized
}
}
}
PushHistoryEvents.ClearDialog -> {
resetAction = AsyncAction.Uninitialized
}
}
}
@@ -40,6 +66,8 @@ class PushHistoryPresenter @Inject constructor(
return PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistory.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
eventSink = ::handleEvents
)
}

View File

@@ -7,11 +7,14 @@
package io.element.android.libraries.troubleshoot.impl.history
import io.element.android.libraries.architecture.AsyncAction
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 showOnlyErrors: Boolean,
val resetAction: AsyncAction<Unit>,
val eventSink: (PushHistoryEvents) -> Unit,
)

View File

@@ -8,6 +8,7 @@
package io.element.android.libraries.troubleshoot.impl.history
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
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
@@ -36,16 +37,23 @@ open class PushHistoryStateProvider : PreviewParameterProvider<PushHistoryState>
)
)
),
aPushHistoryState(
resetAction = AsyncAction.ConfirmingNoParams,
),
)
}
fun aPushHistoryState(
pushCounter: Int = 0,
pushHistoryItems: List<PushHistoryItem> = emptyList(),
showOnlyErrors: Boolean = false,
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (PushHistoryEvents) -> Unit = {},
) = PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistoryItems.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
eventSink = eventSink,
)

View File

@@ -24,6 +24,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -31,16 +35,20 @@ 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.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
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.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
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
@@ -57,6 +65,8 @@ fun PushHistoryView(
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier
.fillMaxSize()
@@ -77,12 +87,42 @@ fun PushHistoryView(
)
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_reset),
onClick = {
state.eventSink(PushHistoryEvents.Reset)
},
)
IconButton(onClick = { showMenu = !showMenu }) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = stringResource(id = CommonStrings.a11y_user_menu),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Show only errors") },
trailingIcon = if (state.showOnlyErrors) {
{
Icon(
imageVector = CompoundIcons.CheckCircleSolid(),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
} else {
null
},
onClick = {
showMenu = false
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(state.showOnlyErrors.not()))
},
)
DropdownMenuItem(
text = { Text(stringResource(id = CommonStrings.action_reset)) },
onClick = {
showMenu = false
state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
},
)
}
}
)
},
@@ -95,6 +135,22 @@ fun PushHistoryView(
onItemClick = onItemClick,
)
}
AsyncActionView(
async = state.resetAction,
onSuccess = {},
confirmationDialog = {
ConfirmationDialog(
content = "",
title = stringResource(CommonStrings.dialog_title_confirmation),
submitText = stringResource(CommonStrings.action_reset),
cancelText = stringResource(CommonStrings.action_cancel),
onSubmitClick = { state.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false)) },
onDismiss = { state.eventSink(PushHistoryEvents.ClearDialog) },
)
},
onErrorDismiss = {},
)
}
@Composable

View File

@@ -10,12 +10,12 @@
package io.element.android.libraries.troubleshoot.impl.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
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
@@ -27,6 +27,8 @@ class PushHistoryPresenterTest {
val initialState = awaitItem()
assertThat(initialState.pushCounter).isEqualTo(0)
assertThat(initialState.pushHistoryItems).isEmpty()
assertThat(initialState.showOnlyErrors).isFalse()
assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized)
}
}
@@ -49,7 +51,7 @@ class PushHistoryPresenterTest {
}
@Test
fun `present - reset`() = runTest {
fun `present - reset and cancel`() = runTest {
val resetPushHistoryResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
resetPushHistoryResult = resetPushHistoryResult,
@@ -59,12 +61,64 @@ class PushHistoryPresenterTest {
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PushHistoryEvents.Reset)
runCurrent()
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(PushHistoryEvents.ClearDialog)
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized)
resetPushHistoryResult.assertions().isNeverCalled()
}
}
@Test
fun `present - reset and confirm`() = runTest {
val resetPushHistoryResult = lambdaRecorder<Unit> { }
val pushService = FakePushService(
resetPushHistoryResult = resetPushHistoryResult,
)
val presenter = createPushHistoryPresenter(
pushService = pushService,
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = true))
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.ConfirmingNoParams)
initialState.eventSink(PushHistoryEvents.Reset(requiresConfirmation = false))
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Loading)
assertThat(awaitItem().resetAction).isEqualTo(AsyncAction.Uninitialized)
resetPushHistoryResult.assertions().isCalledOnce()
}
}
@Test
fun `present - set show only errors`() = runTest {
val pushService = FakePushService()
val presenter = createPushHistoryPresenter(
pushService = pushService,
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.showOnlyErrors).isFalse()
val item = aPushHistoryItem(hasBeenResolved = true)
val itemError = aPushHistoryItem(hasBeenResolved = false)
pushService.emitPushHistoryItems(listOf(item, itemError))
awaitItem().let { state ->
assertThat(state.pushHistoryItems).containsExactly(item, itemError)
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true))
}
skipItems(1)
awaitItem().let { state ->
assertThat(state.showOnlyErrors).isTrue()
assertThat(state.pushHistoryItems).containsExactly(itemError)
state.eventSink(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false))
}
skipItems(1)
awaitItem().let { state ->
assertThat(state.showOnlyErrors).isFalse()
assertThat(state.pushHistoryItems).containsExactly(item, itemError)
}
}
}
private fun createPushHistoryPresenter(
pushService: PushService = FakePushService(),
): PushHistoryPresenter {

View File

@@ -10,6 +10,7 @@ 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.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -46,12 +47,44 @@ class PushHistoryViewTest {
eventSink = eventsRecorder,
),
)
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
rule.onNodeWithContentDescription(menuContentDescription).performClick()
rule.clickOn(CommonStrings.action_reset)
eventsRecorder.assertSingle(PushHistoryEvents.Reset)
eventsRecorder.assertSingle(PushHistoryEvents.Reset(requiresConfirmation = true))
// Also check that the push counter is rendered
rule.onNodeWithText("123").assertExists()
}
@Test
fun `clicking on show only errors sends a PushHistoryEvents(true)`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
rule.setPushHistoryView(
aPushHistoryState(
showOnlyErrors = false,
eventSink = eventsRecorder,
),
)
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
rule.onNodeWithContentDescription(menuContentDescription).performClick()
rule.onNodeWithText("Show only errors").performClick()
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = true))
}
@Test
fun `clicking on show only errors sends a PushHistoryEvents(false)`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
rule.setPushHistoryView(
aPushHistoryState(
showOnlyErrors = true,
eventSink = eventsRecorder,
),
)
val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu)
rule.onNodeWithContentDescription(menuContentDescription).performClick()
rule.onNodeWithText("Show only errors").performClick()
eventsRecorder.assertSingle(PushHistoryEvents.SetShowOnlyErrors(showOnlyErrors = false))
}
@Test
fun `clicking on an invalid event has no effect`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)