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)

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