Add a Push rules entry to the developer options

Add a "Push rules" item in the Notifications section of the developer options,
above "Push history". It displays the raw push rules of the account, which were
only available as a rageshake attachment so far.

The content is read using NotificationSettingsService.getRawPushRules(), then
rendered by the new FileViewerEntryPoint, so that the screen looks like the media
viewer rendering a file, with Share and Download actions on a
push_rules<userId>.json file. The user id contains a colon, which is not a valid
file name character on all the file systems, so it is replaced by an underscore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-08-26 12:11:16 +02:00
parent 0801a415b5
commit 373d2f1eb5
12 changed files with 200 additions and 7 deletions
@@ -64,6 +64,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
@@ -108,6 +109,7 @@ dependencies {
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.libraries.roomselect.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.libraries.troubleshoot.test)
testImplementation(projects.features.deactivation.test)
testImplementation(projects.features.enterprise.test)
@@ -40,10 +40,12 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.canPop
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import kotlinx.parcelize.Parcelize
@@ -56,6 +58,7 @@ class PreferencesFlowNode(
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint,
private val pushHistoryEntryPoint: PushHistoryEntryPoint,
private val fileViewerEntryPoint: FileViewerEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint,
private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint,
@@ -95,6 +98,9 @@ class PreferencesFlowNode(
@Parcelize
data object PushHistory : NavTarget
@Parcelize
data class PushRules(val filename: String, val content: String) : NavTarget
@Parcelize
data object LockScreenSettings : NavTarget
@@ -187,6 +193,10 @@ class PreferencesFlowNode(
}
NavTarget.DeveloperSettings -> {
val developerSettingsCallback = object : DeveloperSettingsNode.Callback {
override fun navigateToPushRules(filename: String, content: String) {
backstack.push(NavTarget.PushRules(filename = filename, content = content))
}
override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory)
}
@@ -270,6 +280,26 @@ class PreferencesFlowNode(
},
)
}
is NavTarget.PushRules -> {
fileViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = FileViewerEntryPoint.Params(
filename = navTarget.filename,
mimeType = MimeTypes.Json,
content = navTarget.content,
),
callback = object : FileViewerEntryPoint.Callback {
override fun onDone() {
if (backstack.canPop()) {
backstack.pop()
} else {
navigateUp()
}
}
},
)
}
is NavTarget.EditDefaultNotificationSetting -> {
val callback = object : EditDefaultNotificationSettingNode.Callback {
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
@@ -17,4 +17,6 @@ sealed interface DeveloperSettingsEvent {
data object VacuumStores : DeveloperSettingsEvent
data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvent
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
data object OpenPushRules : DeveloperSettingsEvent
data object DismissPushRulesError : DeveloperSettingsEvent
}
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.developer
/**
* Navigation performed by the developer settings screen, when data has to be loaded first.
*/
fun interface DeveloperSettingsNavigator {
fun openPushRules(filename: String, content: String)
}
@@ -28,15 +28,22 @@ import io.element.android.libraries.di.SessionScope
class DeveloperSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: DeveloperSettingsPresenter,
presenterFactory: DeveloperSettingsPresenter.Factory,
private val extraDeveloperOptionsRenderer: ExtraDeveloperOptionsRenderer,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
DeveloperSettingsNavigator {
interface Callback : Plugin {
fun navigateToPushRules(filename: String, content: String)
fun navigateToPushHistory()
fun onDone()
}
private val callback: Callback = callback()
private val presenter = presenterFactory.create(navigator = this)
override fun openPushRules(filename: String, content: String) {
callback.navigateToPushRules(filename = filename, content = content)
}
@Composable
override fun View(modifier: Modifier) {
@@ -17,7 +17,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.toArgb
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState
import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
@@ -34,13 +36,15 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Inject
@AssistedInject
class DeveloperSettingsPresenter(
@Assisted private val navigator: DeveloperSettingsNavigator,
private val appDeveloperSettingsPresenter: Presenter<AppDeveloperSettingsState>,
private val sessionId: SessionId,
private val deviceId: DeviceId,
@@ -52,7 +56,13 @@ class DeveloperSettingsPresenter(
private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta,
private val notificationSettingsService: NotificationSettingsService,
) : Presenter<DeveloperSettingsState> {
@AssistedFactory
fun interface Factory {
fun create(navigator: DeveloperSettingsNavigator): DeveloperSettingsPresenter
}
@Composable
override fun present(): DeveloperSettingsState {
val cacheSize = remember {
@@ -67,6 +77,9 @@ class DeveloperSettingsPresenter(
val markAllRoomsAsReadAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val pushRulesAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
var showColorPicker by remember {
mutableStateOf(false)
}
@@ -109,6 +122,10 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation -> {
markAllRoomsAsReadAction.value = AsyncAction.Uninitialized
}
DeveloperSettingsEvent.OpenPushRules -> coroutineScope.openPushRules(pushRulesAction)
DeveloperSettingsEvent.DismissPushRulesError -> {
pushRulesAction.value = AsyncAction.Uninitialized
}
}
}
@@ -119,6 +136,7 @@ class DeveloperSettingsPresenter(
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
pushRulesAction = pushRulesAction.value,
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -164,4 +182,25 @@ class DeveloperSettingsPresenter(
markAllRoomsAsRead().getOrThrow()
}.runCatchingUpdatingState(state = markAllRoomsAsReadAction)
}
private fun CoroutineScope.openPushRules(pushRulesAction: MutableState<AsyncAction<Unit>>) = launch {
pushRulesAction.value = AsyncAction.Loading
notificationSettingsService.getRawPushRules()
.onSuccess { content ->
pushRulesAction.value = AsyncAction.Uninitialized
navigator.openPushRules(
filename = pushRulesFilename(),
content = content.orEmpty(),
)
}
.onFailure {
pushRulesAction.value = AsyncAction.Failure(it)
}
}
/**
* The user id contains a colon, which is not a valid character for a file name on all the file systems,
* so replace it by an underscore.
*/
private fun pushRulesFilename() = "push_rules${sessionId.value.replace(':', '_')}.json"
}
@@ -20,10 +20,13 @@ data class DeveloperSettingsState(
val databaseSizes: AsyncData<ImmutableMap<String, String>>,
val clearCacheAction: AsyncAction<Unit>,
val markAllRoomsAsReadAction: AsyncAction<Unit>,
val pushRulesAction: AsyncAction<Unit>,
val isEnterpriseBuild: Boolean,
val showColorPicker: Boolean,
val deviceId: DeviceId,
val eventSink: (DeveloperSettingsEvent) -> Unit
) {
val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading
val showLoader = clearCacheAction is AsyncAction.Loading ||
markAllRoomsAsReadAction is AsyncAction.Loading ||
pushRulesAction is AsyncAction.Loading
}
@@ -32,6 +32,9 @@ open class DeveloperSettingsStatePreviewParam : PreviewParameterProvider<Develop
// at io.mhssn.colorpicker.pickers.ClassicColorPickerKt$ClassicColorPicker$1$1.invokeSuspend(ClassicColorPicker.kt:53)
showColorPicker = false,
),
aDeveloperSettingsState(
pushRulesAction = AsyncAction.Failure(Exception("A failure"))
),
)
}
@@ -39,6 +42,7 @@ fun aDeveloperSettingsState(
appDeveloperSettingsState: AppDeveloperSettingsState = anAppDeveloperSettingsState(),
clearCacheAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
markAllRoomsAsReadAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
pushRulesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
isEnterpriseBuild: Boolean = false,
showColorPicker: Boolean = false,
deviceId: DeviceId = DeviceId("ILAKNDNASDLK"),
@@ -49,6 +53,7 @@ fun aDeveloperSettingsState(
databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")),
clearCacheAction = clearCacheAction,
markAllRoomsAsReadAction = markAllRoomsAsReadAction,
pushRulesAction = pushRulesAction,
isEnterpriseBuild = isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
@@ -23,8 +23,10 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
@@ -60,6 +62,12 @@ fun DeveloperSettingsView(
onDismiss = { state.eventSink(DeveloperSettingsEvent.DismissMarkAllRoomsAsReadConfirmation) },
)
}
(state.pushRulesAction as? AsyncAction.Failure)?.let { failure ->
ErrorDialog(
content = failure.error.message ?: stringResource(CommonStrings.error_unknown),
onSubmit = { state.eventSink(DeveloperSettingsEvent.DismissPushRulesError) },
)
}
BackHandler(
enabled = !state.showLoader,
onBack = onBackClick,
@@ -79,7 +87,10 @@ fun DeveloperSettingsView(
onOpenShowkase = onOpenShowkase,
)
SessionCategory(deviceId = state.deviceId)
NotificationCategory(onPushHistoryClick)
NotificationCategory(
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
onPushHistoryClick = onPushHistoryClick,
)
MarkAllRoomsAsReadCategory(state)
if (state.isEnterpriseBuild) {
@@ -217,8 +228,17 @@ private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) {
}
@Composable
private fun NotificationCategory(onPushHistoryClick: () -> Unit) {
private fun NotificationCategory(
onPushRulesClick: () -> Unit,
onPushHistoryClick: () -> Unit,
) {
PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) {
ListItem(
content = {
Text("Push rules")
},
onClick = onPushRulesClick,
)
ListItem(
content = {
Text(stringResource(R.string.troubleshoot_notifications_entry_point_push_history_title))
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
@@ -43,6 +44,7 @@ class DefaultPreferencesEntryPointTest {
lockScreenEntryPoint = FakeLockScreenEntryPoint(),
notificationTroubleShootEntryPoint = FakeNotificationTroubleShootEntryPoint(),
pushHistoryEntryPoint = FakePushHistoryEntryPoint(),
fileViewerEntryPoint = FakeFileViewerEntryPoint(),
logoutEntryPoint = FakeLogoutEntryPoint(),
openSourceLicensesEntryPoint = FakeOpenSourceLicensesEntryPoint(),
accountDeactivationEntryPoint = FakeAccountDeactivationEntryPoint(),
@@ -28,10 +28,13 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
@@ -41,6 +44,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
private const val A_PUSH_RULES_CONTENT = "{\"global\":{}}"
private val AN_EXCEPTION = Exception("A failure")
class DeveloperSettingsPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -170,7 +176,53 @@ class DeveloperSettingsPresenterTest {
}
}
@Test
fun `present - OpenPushRules event navigates to the push rules content`() = runTest {
val openPushRulesLambda = lambdaRecorder<String, String, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
navigator = DeveloperSettingsNavigator(openPushRulesLambda),
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.success(A_PUSH_RULES_CONTENT) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
openPushRulesLambda.assertions().isCalledOnce().with(
value("push_rules@alice_server.org.json"),
value(A_PUSH_RULES_CONTENT),
)
}
}
@Test
fun `present - OpenPushRules event failure can be dismissed`() = runTest {
val presenter = createDeveloperSettingsPresenter(
notificationSettingsService = FakeNotificationSettingsService(
getRawPushRulesResult = { Result.failure(AN_EXCEPTION) },
),
)
presenter.test {
val state = awaitItem()
state.eventSink(DeveloperSettingsEvent.OpenPushRules)
skipItems(1)
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
it.eventSink(DeveloperSettingsEvent.DismissPushRulesError)
}
awaitItem().also {
assertThat(it.pushRulesAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
private fun createDeveloperSettingsPresenter(
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
sessionId: SessionId = A_SESSION_ID,
deviceId: DeviceId = A_DEVICE_ID,
cacheSizeUseCase: FakeComputeCacheSizeUseCase = FakeComputeCacheSizeUseCase(),
@@ -180,8 +232,10 @@ class DeveloperSettingsPresenterTest {
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
navigator = navigator,
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
sessionId = sessionId,
deviceId = deviceId,
@@ -193,6 +247,7 @@ class DeveloperSettingsPresenterTest {
fileSizeFormatter = FakeFileSizeFormatter(),
markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta,
notificationSettingsService = notificationSettingsService,
)
}
}
@@ -57,6 +57,19 @@ class DeveloperSettingsViewTest : RobolectricTest() {
}
}
@Config(qualifiers = "h2400dp")
@Test
fun `clicking on push rules emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<DeveloperSettingsEvent>()
setDeveloperSettingsView(
state = aDeveloperSettingsState(
eventSink = eventsRecorder
),
)
onNodeWithText("Push rules").performScrollTo().performClick()
eventsRecorder.assertSingle(DeveloperSettingsEvent.OpenPushRules)
}
@Config(qualifiers = "h2000dp")
@Test
fun `clicking on open showkase invokes the expected callback`() = runAndroidComposeUiTest {