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 653416fa34
commit 7ed362b9db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04cc3a21bceeee5727df5728d34eb7618a5a90949352cd02f4eaa2be77c47492
size 13565
oid sha256:673fc1787251cbd1f0d6ea6971c1c7a35b4a990e47adbf5b3e2ac442cf74f809
size 12656

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7a8c0a12277b4335b1d01d3f5f206704b37c4cfb3693ec7d9a26ce15215ba800
size 45566
oid sha256:5a778dc2f98f1ad4ee901e4ca0d9b593cb78f44ab67337350349869f4b211a51
size 44773

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b32c39383aef9ed41ba65109d4ee5e97f057221474aeeb90522c161d3b0c2409
size 21014

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d877e4d537fa0990dd0f2a5e14f2d00cf8dcd45a4c27fac6c0188ef2b441756
size 13163
oid sha256:97c2af47763d65926e407c94901e1b0a691218b1cf152203fc4fd2f396c17e43
size 12270

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f110d1684003cdd1d6e47f765bbf4f55e2b0f3ba732926df08d06297b36bb7b
size 44157
oid sha256:bfb8a1f9bd060141b0f20490d4066724f34d1560296235f5e45f98de9d3d7aad
size 43310

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:af4e8fb83c87caccd084c0321a5e47451c1a8bba3eed1e948e95d6184667f710
size 19339

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 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.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_your_app_with_app_standby
echo " => Standby OFF"
set -x
package_name="io.element.android.x.debug"
adb shell dumpsys battery reset
adb shell am set-inactive "${package_name}" false
adb shell am get-inactive "${package_name}"
tools/adb/print_device_state.sh

16
tools/adb/disable_doze_mode.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 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.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Disable doze mode"
set -x
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
tools/adb/print_device_state.sh

18
tools/adb/enable_app_standby.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 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.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_your_app_with_app_standby
echo " => Standby ON"
set -x
package_name="io.element.android.x.debug"
adb shell dumpsys battery unplug
adb shell am set-inactive "${package_name}" true
adb shell am get-inactive "${package_name}"
tools/adb/print_device_state.sh

16
tools/adb/enable_doze_mode.sh Executable file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# 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.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Enable doze mode"
set -x
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
tools/adb/print_device_state.sh

18
tools/adb/print_device_state.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 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.
# Ref: https://developer.android.com/training/monitoring-device-state/doze-standby#testing_doze
echo " => Device state"
set -x
adb shell dumpsys deviceidle get light
adb shell dumpsys deviceidle get deep
adb shell dumpsys deviceidle get force
adb shell dumpsys deviceidle get screen
adb shell dumpsys deviceidle get charging
adb shell dumpsys deviceidle get network