diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 313f4aafe08..6362695772d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.push.api.PushService import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject class LoggedInPresenter @Inject constructor( @@ -55,10 +56,26 @@ class LoggedInPresenter @Inject constructor( LaunchedEffect(isVerified) { if (isVerified) { // Ensure pusher is registered - // TODO Manually select push provider for now - val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect - val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect - pushService.registerWith(matrixClient, pushProvider, distributor) + val currentPushProvider = pushService.getCurrentPushProvider() + val result = if (currentPushProvider == null) { + // Register with the first available push provider + val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect + val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, pushProvider, distributor) + } else { + val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient) + if (currentPushDistributor == null) { + // Register with the first available distributor + val distributor = currentPushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, currentPushProvider, distributor) + } else { + // Re-register with the current distributor + pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor) + } + } + result.onFailure { + Timber.e(it, "Failed to register pusher") + } } } diff --git a/changelog.d/2340.misc b/changelog.d/2340.misc new file mode 100644 index 00000000000..a289fc2a252 --- /dev/null +++ b/changelog.d/2340.misc @@ -0,0 +1 @@ +Allow configuring push notification provider diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index edfb275f17e..44616d0566a 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) @@ -90,6 +91,7 @@ dependencies { testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) testImplementation(projects.libraries.indicator.impl) + testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.features.logout.impl) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index ab4987f9d90..4ed42775998 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -24,4 +24,7 @@ sealed interface AdvancedSettingsEvents { data object ChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents + data object ChangePushProvider : AdvancedSettingsEvents + data object CancelChangePushProvider : AdvancedSettingsEvents + data class SetPushProvider(val index: Int) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 67532574b00..21d84567e16 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -17,8 +17,10 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -27,13 +29,22 @@ import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class AdvancedSettingsPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, + private val matrixClient: MatrixClient, + private val pushService: PushService, ) : Presenter { @Composable override fun present(): AdvancedSettingsState { @@ -49,6 +60,62 @@ class AdvancedSettingsPresenter @Inject constructor( } .collectAsState(initial = Theme.System) var showChangeThemeDialog by remember { mutableStateOf(false) } + + // List of PushProvider -> Distributor + val distributors = remember { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } + } + } + // List of Distributor names + val distributorNames = remember { + distributors.map { it.second.name } + } + + var currentDistributorName by remember { mutableStateOf>(AsyncAction.Uninitialized) } + var refreshPushProvider by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshPushProvider) { + val p = pushService.getCurrentPushProvider() + val name = p?.getCurrentDistributor(matrixClient)?.name + currentDistributorName = if (name != null) { + AsyncAction.Success(name) + } else { + AsyncAction.Failure(Exception("Failed to get current push provider")) + } + } + + var showChangePushProviderDialog by remember { mutableStateOf(false) } + + fun CoroutineScope.changePushProvider( + data: Pair? + ) = launch { + showChangePushProviderDialog = false + data ?: return@launch + // No op if the value is the same. + if (data.second.name == currentDistributorName.dataOrNull()) return@launch + currentDistributorName = AsyncAction.Loading + data.let { (pushProvider, distributor) -> + pushService.registerWith( + matrixClient = matrixClient, + pushProvider = pushProvider, + distributor = distributor + ) + .fold( + { + currentDistributorName = AsyncAction.Success(distributor.name) + refreshPushProvider++ + }, + { + currentDistributorName = AsyncAction.Failure(it) + } + ) + } + } + fun handleEvents(event: AdvancedSettingsEvents) { when (event) { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { @@ -63,6 +130,9 @@ class AdvancedSettingsPresenter @Inject constructor( appPreferencesStore.setTheme(event.theme.name) showChangeThemeDialog = false } + AdvancedSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true + AdvancedSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false + is AdvancedSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) } } @@ -71,6 +141,9 @@ class AdvancedSettingsPresenter @Inject constructor( isSharePresenceEnabled = isSharePresenceEnabled, theme = theme, showChangeThemeDialog = showChangeThemeDialog, + currentPushDistributor = currentDistributorName, + availablePushDistributors = distributorNames.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = { handleEvents(it) } ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 527515d867c..23de2fda137 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -17,11 +17,16 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.ImmutableList data class AdvancedSettingsState( val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, val theme: Theme, val showChangeThemeDialog: Boolean, + val currentPushDistributor: AsyncAction, + val availablePushDistributors: ImmutableList, + val showChangePushProviderDialog: Boolean, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index fb6f2a2659b..5e6af364f2f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -18,6 +18,8 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.toImmutableList open class AdvancedSettingsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -26,6 +28,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider = AsyncAction.Success("Firebase"), + availablePushDistributors: List = listOf("Firebase", "ntfy"), + showChangePushProviderDialog: Boolean = false, + eventSink: (AdvancedSettingsEvents) -> Unit = {}, ) = AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, - eventSink = {} + currentPushDistributor = currentPushDistributor, + availablePushDistributors = availablePushDistributors.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, + eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 68e1c7ea220..9b82a87bbce 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -16,19 +16,24 @@ package io.element.android.features.preferences.impl.advanced +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import io.element.android.compound.theme.Theme import io.element.android.compound.theme.themes import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.dialogs.ListOption import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings @@ -81,6 +86,34 @@ fun AdvancedSettingsView( ), onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } ) + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) + }, + trailingContent = when (state.currentPushDistributor) { + AsyncAction.Uninitialized, + AsyncAction.Confirming, + AsyncAction.Loading -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + is AsyncAction.Failure -> ListItemContent.Text( + stringResource(id = CommonStrings.common_error) + ) + is AsyncAction.Success -> ListItemContent.Text( + state.currentPushDistributor.dataOrNull() ?: "" + ) + }, + onClick = { + if (state.currentPushDistributor.isReady()) { + state.eventSink(AdvancedSettingsEvents.ChangePushProvider) + } + } + ) } if (state.showChangeThemeDialog) { @@ -97,6 +130,22 @@ fun AdvancedSettingsView( onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangeTheme) }, ) } + + if (state.showChangePushProviderDialog) { + SingleSelectionDialog( + title = stringResource(id = R.string.screen_advanced_settings_choose_distributor_dialog_title_android), + options = state.availablePushDistributors.map { + ListOption(title = it) + }.toImmutableList(), + initialSelection = state.availablePushDistributors.indexOf(state.currentPushDistributor.dataOrNull()), + onOptionSelected = { index -> + state.eventSink( + AdvancedSettingsEvents.SetPushProvider(index) + ) + }, + onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) }, + ) + } } @Composable diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index e3ca6bf8b42..7e4175d3bd0 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -21,8 +21,16 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem import kotlinx.coroutines.test.runTest @@ -100,11 +108,93 @@ class AdvancedSettingsPresenterTest { } } + @Test + fun `present - change push provider`() = runTest { + val presenter = createAdvancedSettingsPresenter( + pushService = createFakePushService(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName0")) + assertThat(initialState.availablePushDistributors).containsExactly("aDistributorName0", "aDistributorName1") + initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + // Cancel + withDialog.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) + val withoutDialog = awaitItem() + assertThat(withoutDialog.showChangePushProviderDialog).isFalse() + withDialog.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName1")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider error`() = runTest { + val presenter = createAdvancedSettingsPresenter( + pushService = createFakePushService( + registerWithLambda = { _, _, _ -> + Result.failure(Exception("An error")) + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + private fun createFakePushService( + registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + } + ): PushService { + val pushProvider1 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + ) + val pushProvider2 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + ) + return FakePushService( + availablePushProviders = listOf(pushProvider1, pushProvider2), + registerWithLambda = registerWithLambda, + ) + } + private fun createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + matrixClient: MatrixClient = FakeMatrixClient(), + pushService: PushService = FakePushService(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, + matrixClient = matrixClient, + pushService = pushService, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt new file mode 100644 index 00000000000..ee8c9005790 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.compound.theme.Theme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AdvancedSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + onBackPressed = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on Appearance emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.common_appearance) + eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangeTheme) + } + + @Test + fun `clicking on other theme emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + showChangeThemeDialog = true + ), + ) + rule.clickOn(CommonStrings.common_dark) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(Theme.Dark)) + } + + @Test + fun `clicking on View source emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) + } + + @Test + fun `clicking on Share presence emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Push notification provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_push_provider_android) + eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangePushProvider) + } + + @Test + fun `clicking on a push provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + showChangePushProviderDialog = true, + availablePushDistributors = listOf("P1", "P2") + ), + ) + rule.onNodeWithText("P2").performClick() + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetPushProvider(1)) + } +} + +private fun AndroidComposeTestRule.setAdvancedSettingsView( + state: AdvancedSettingsState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AdvancedSettingsView( + state = state, + onBackPressed = onBackPressed, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c56647634f..ddd20c6a1e6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -167,7 +167,7 @@ sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version. sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" -unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" +unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt index e69b1b9eafd..9545f1ab688 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -86,6 +86,8 @@ sealed interface AsyncAction { fun isFailure(): Boolean = this is Failure fun isSuccess(): Boolean = this is Success + + fun isReady() = isSuccess() || isFailure() } suspend inline fun MutableState>.runCatchingUpdatingState( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index 71a642965ff..2a16e7be35d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -18,5 +18,5 @@ package io.element.android.libraries.matrix.api.pusher interface PushersService { suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result - suspend fun unsetHttpPusher(): Result + suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt new file mode 100644 index 00000000000..2bd91a6d02c --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.pusher + +data class UnsetHttpPusherData( + val pushKey: String, + val appId: String, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt index 60ca4df3114..2686d03c6b0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.pushers import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.HttpPusherData @@ -54,8 +55,16 @@ class RustPushersService( } } - override suspend fun unsetHttpPusher(): Result { - // TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK. - return Result.success(Unit) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result { + return withContext(dispatchers.io) { + runCatching { + client.deletePusher( + identifiers = PusherIdentifiers( + pushkey = unsetHttpPusherData.pushKey, + appId = unsetHttpPusherData.appId + ), + ) + } + } } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt index 6ff7e4a20ba..05a40e99952 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -18,8 +18,9 @@ package io.element.android.libraries.matrix.test.pushers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData class FakePushersService : PushersService { override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) - override suspend fun unsetHttpPusher(): Result = Result.success(Unit) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = Result.success(Unit) } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index abfc328e9f6..607213953d5 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -24,6 +24,11 @@ interface PushService { // TODO Move away fun notificationStyleChanged() + /** + * Return the current push provider, or null if none. + */ + suspend fun getCurrentPushProvider(): PushProvider? + /** * Return the list of push providers, available at compile time, and * available at runtime, sorted by index. @@ -35,7 +40,11 @@ interface PushService { * * The method has effect only if the [PushProvider] is different than the current one. */ - suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) + suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result /** * Return false in case of early error. diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index cff18cfb3d1..4396337883f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationD import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import timber.log.Timber import javax.inject.Inject @ContributesBinding(AppScope::class) @@ -39,6 +40,11 @@ class DefaultPushService @Inject constructor( defaultNotificationDrawerManager.notificationStyleChanged() } + override suspend fun getCurrentPushProvider(): PushProvider? { + val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() + return pushProviders.find { it.name == currentPushProvider } + } + override fun getAvailablePushProviders(): List { return pushProviders .filter { it.isAvailable() } @@ -48,21 +54,31 @@ class DefaultPushService @Inject constructor( /** * Get current push provider, compare with provided one, then unregister and register if different, and store change. */ - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result { val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val currentPushProviderName = userPushStore.getPushProviderName() - if (currentPushProviderName != pushProvider.name) { + val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } + val currentDistributorValue = currentPushProvider?.getCurrentDistributor(matrixClient)?.value + if (currentPushProviderName != pushProvider.name || currentDistributorValue != distributor.value) { // Unregister previous one if any - pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient) + currentPushProvider?.unregister(matrixClient) + ?.onFailure { + Timber.w(it, "Failed to unregister previous push provider") + return Result.failure(it) + } } - pushProvider.registerWith(matrixClient, distributor) // Store new value userPushStore.setPushProviderName(pushProvider.name) + // Then try to register + return pushProvider.registerWith(matrixClient, distributor) } override suspend fun testPush(): Boolean { - val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() - val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false + val pushProvider = getCurrentPushProvider() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false pushersManager.testPush(config) return true diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt index 4306072e48a..7dba9f56780 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -62,22 +63,26 @@ class PushersManager @Inject constructor( /** * Register a pusher to the server if not done yet. */ - override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + override suspend fun registerPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { Timber.tag(loggerTag.value) .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server") } - matrixClient.pushersService().setHttpPusher( - createHttpPusher(pushKey, gateway, matrixClient.sessionId) - ).fold( - { + return matrixClient.pushersService() + .setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ) + .onSuccess { userDataStore.setCurrentRegisteredPushKey(pushKey) - }, - { throwable -> + } + .onFailure { throwable -> Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") } - ) } private suspend fun createHttpPusher( @@ -106,8 +111,25 @@ class PushersManager @Inject constructor( return "{\"cs\":\"$secretForUser\"}" } - override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { - matrixClient.pushersService().unsetHttpPusher() + override suspend fun unregisterPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { + val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + return matrixClient.pushersService() + .unsetHttpPusher( + unsetHttpPusherData = UnsetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.PUSHER_APP_ID + ) + ) + .onSuccess { + userDataStore.setCurrentRegisteredPushKey(null) + } + .onFailure { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher") + } } companion object { diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index 969815ec662..a148e5f6a2c 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -23,16 +23,29 @@ import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.tests.testutils.simulateLongTask class FakePushService( - private val testPushBlock: suspend () -> Boolean = { true } + private val testPushBlock: suspend () -> Boolean = { true }, + private val availablePushProviders: List = emptyList(), + private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + }, ) : PushService { override fun notificationStyleChanged() { } + override suspend fun getCurrentPushProvider(): PushProvider? { + return availablePushProviders.firstOrNull() + } + override fun getAvailablePushProviders(): List { - return emptyList() + return availablePushProviders } - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result = simulateLongTask { + return registerWithLambda(matrixClient, pushProvider, distributor) } override suspend fun testPush(): Boolean = simulateLongTask { diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt index 7eda80fed9c..1db3dc26106 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt @@ -16,6 +16,14 @@ package io.element.android.libraries.pushproviders.api +/** + * Firebase does not have the concept of distributor. So for Firebase, there will be one distributor: + * Distributor("Firebase", "Firebase"). + * + * For UnifiedPush, for instance, the Distributor can be: + * Distributor("io.heckel.ntfy", "ntfy"). + * But other values are possible. + */ data class Distributor( val value: String, val name: String, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt index 4e9b818dd4d..d111dc139fb 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -42,12 +42,17 @@ interface PushProvider { /** * Register the pusher to the homeserver. */ - suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result + + /** + * Return the current distributor, or null if none. + */ + suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? /** * Unregister the pusher. */ - suspend fun unregister(matrixClient: MatrixClient) + suspend fun unregister(matrixClient: MatrixClient): Result suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt index 2529e4bb96e..d38f5dec1e2 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -19,6 +19,6 @@ package io.element.android.libraries.pushproviders.api import io.element.android.libraries.matrix.api.MatrixClient interface PusherSubscriber { - suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) - suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt index 20d0de4ebfb..e61ea4bc912 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.pushproviders.firebase +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId @@ -43,12 +44,24 @@ class FirebaseNewTokenHandler @Inject constructor( // Register the pusher for all the sessions sessionStore.getAllSessions().toUserList() .map { SessionId(it) } - .forEach { userId -> - val userDataStore = userPushStoreFactory.getOrCreate(userId) + .forEach { sessionId -> + val userDataStore = userPushStoreFactory.getOrCreate(sessionId) if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.PUSHER_HTTP_URL) - } + matrixAuthenticationService + .restoreSession(sessionId) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to restore session $sessionId") + } + .flatMap { client -> + pusherSubscriber.registerPusher( + matrixClient = client, + pushKey = firebaseToken, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) + } + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to register pusher for session $sessionId") + } } else { Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt index 317d49f3b6e..b88d70b1572 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -43,21 +43,35 @@ class FirebasePushProvider @Inject constructor( } override fun getDistributors(): List { - return listOf(Distributor("Firebase", "Firebase")) + return listOf(firebaseDistributor) } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( + IllegalStateException( + "Unable to register pusher, Firebase token is not known." + ) + ).also { Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") } - pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) + return pusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = pushKey, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) } - override suspend fun unregister(matrixClient: MatrixClient) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient) = firebaseDistributor + + override suspend fun unregister(matrixClient: MatrixClient): Result { + val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( + IllegalStateException( + "Unable to unregister pusher, Firebase token is not known." + ) + ).also { Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") } - pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) + return pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { @@ -68,4 +82,8 @@ class FirebasePushProvider @Inject constructor( ) } } + + companion object { + private val firebaseDistributor = Distributor("Firebase", "Firebase") + } } diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt index 8d8b94ec196..44aa1ca18f1 100644 --- a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -25,18 +25,22 @@ class FakePushProvider( override val index: Int = 0, override val name: String = "aFakePushProvider", private val isAvailable: Boolean = true, - private val distributors: List = emptyList() + private val distributors: List = listOf(Distributor("aDistributorValue", "aDistributorName")), ) : PushProvider { override fun isAvailable(): Boolean = isAvailable override fun getDistributors(): List = distributors - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - // No-op + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + return Result.success(Unit) } - override suspend fun unregister(matrixClient: MatrixClient) { - // No-op + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + return distributors.firstOrNull() + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { + return Result.success(Unit) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index 8dd71118b3e..24f93b88dcf 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -18,55 +18,42 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.Distributor -import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds class RegisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - private val pusherSubscriber: PusherSubscriber, - private val unifiedPushStore: UnifiedPushStore, + private val endpointRegistrationHandler: EndpointRegistrationHandler, + private val coroutineScope: CoroutineScope, ) { - sealed interface RegisterUnifiedPushResult { - data object Success : RegisterUnifiedPushResult - data object NeedToAskUserForDistributor : RegisterUnifiedPushResult - data object Error : RegisterUnifiedPushResult - } - - suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { - val distributorValue = distributor.value - if (distributorValue.isNotEmpty()) { - saveAndRegisterApp(distributorValue, clientSecret) - val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error - val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error - pusherSubscriber.registerPusher(matrixClient, endpoint, gateway) - return RegisterUnifiedPushResult.Success - } - - // TODO Below should never happen? - if (UnifiedPush.getDistributor(context).isNotEmpty()) { - registerApp(clientSecret) - return RegisterUnifiedPushResult.Success - } - - val distributors = UnifiedPush.getDistributors(context) - - return if (distributors.size == 1) { - saveAndRegisterApp(distributors.first(), clientSecret) - RegisterUnifiedPushResult.Success - } else { - RegisterUnifiedPushResult.NeedToAskUserForDistributor + suspend fun execute(distributor: Distributor, clientSecret: String): Result { + UnifiedPush.saveDistributor(context, distributor.value) + val completable = CompletableDeferred>() + val job = coroutineScope.launch { + val result = endpointRegistrationHandler.state + .filter { it.clientSecret == clientSecret } + .first() + .result + completable.complete(result) } - } - - private fun saveAndRegisterApp(distributor: String, clientSecret: String) { - UnifiedPush.saveDistributor(context, distributor) - registerApp(clientSecret) - } - - private fun registerApp(clientSecret: String) { + // This will trigger the callback + // VectorUnifiedPushMessagingReceiver.onNewEndpoint UnifiedPush.registerApp(context = context, instance = clientSecret) + // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed + return withTimeout(30.seconds) { + completable.await() + } + .onFailure { + job.cancel() + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt index 4ee637a3abc..3ae733d2615 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.pushproviders.unifiedpush +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -27,7 +28,7 @@ import javax.inject.Inject private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) /** - * Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider. + * Handle new endpoint received from UnifiedPush. Will update the session matching the client secret. */ class UnifiedPushNewGatewayHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, @@ -35,18 +36,25 @@ class UnifiedPushNewGatewayHandler @Inject constructor( private val pushClientSecret: PushClientSecret, private val matrixAuthenticationService: MatrixAuthenticationService, ) { - suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { // Register the pusher for the session with this client secret, if is it using UnifiedPush. - val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also { + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { Timber.w("Unable to retrieve session") } val userDataStore = userPushStoreFactory.getOrCreate(userId) - if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, endpoint, pushGateway) - } + return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { + matrixAuthenticationService + .restoreSession(userId) + .flatMap { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } } else { Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + Result.failure( + IllegalStateException("This session is not using UnifiedPush pusher") + ) } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt index e7ea1841c5c..4530a4667fc 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -58,14 +58,22 @@ class UnifiedPushProvider @Inject constructor( return unifiedPushDistributorProvider.getDistributors() } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret) + return registerUnifiedPushUseCase.execute(distributor, clientSecret) + .onSuccess { + unifiedPushStore.setDistributorValue(matrixClient.sessionId, distributor.value) + } } - override suspend fun unregister(matrixClient: MatrixClient) { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + val distributorValue = unifiedPushStore.getDistributorValue(matrixClient.sessionId) + return getDistributors().find { it.value == distributorValue } + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - unRegisterUnifiedPushUseCase.execute(clientSecret) + return unRegisterUnifiedPushUseCase.execute(matrixClient, clientSecret) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt index d063dfce3e6..7b6ee5ef37d 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -21,6 +21,7 @@ import android.content.SharedPreferences import androidx.core.content.edit import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.matrix.api.core.UserId import javax.inject.Inject class UnifiedPushStore @Inject constructor( @@ -71,8 +72,19 @@ class UnifiedPushStore @Inject constructor( } } + fun getDistributorValue(userId: UserId): String? { + return defaultPrefs.getString(PREFS_DISTRIBUTOR + userId, null) + } + + fun setDistributorValue(userId: UserId, value: String) { + defaultPrefs.edit { + putString(PREFS_DISTRIBUTOR + userId, value) + } + } + companion object { private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + private const val PREFS_DISTRIBUTOR = "DISTRIBUTOR" } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index b6030564ca6..b2dd33c252f 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -18,29 +18,27 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber import org.unifiedpush.android.connector.UnifiedPush -import timber.log.Timber import javax.inject.Inject class UnregisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - // private val pushDataStore: PushDataStore, private val unifiedPushStore: UnifiedPushStore, - // private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, + private val pusherSubscriber: PusherSubscriber, ) { - suspend fun execute(clientSecret: String) { - // val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - // pushDataStore.setFdroidSyncBackgroundMode(mode) - try { - unifiedPushStore.getEndpoint(clientSecret)?.let { - Timber.d("Removing $it") - // TODO pushersManager?.unregisterPusher(it) - } - } catch (e: Exception) { - Timber.d(e, "Probably unregistering a non existing pusher") + suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + val endpoint = unifiedPushStore.getEndpoint(clientSecret) + val gateway = unifiedPushStore.getPushGateway(clientSecret) + if (endpoint == null || gateway == null) { + return Result.failure(IllegalStateException("No endpoint or gateway found for client secret")) } - unifiedPushStore.storeUpEndpoint(null, clientSecret) - unifiedPushStore.storePushGateway(null, clientSecret) - UnifiedPush.unregisterApp(context) + return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) + .onSuccess { + unifiedPushStore.storeUpEndpoint(null, clientSecret) + unifiedPushStore.storePushGateway(null, clientSecret) + UnifiedPush.unregisterApp(context) + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index e28d67ecf74..c47aabae37e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -21,6 +21,8 @@ import android.content.Intent import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch @@ -37,6 +39,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler + @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler private val coroutineScope = CoroutineScope(SupervisorJob()) @@ -69,20 +72,33 @@ 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: adding $endpoint") - // If the endpoint has changed - // or the gateway has changed - if (unifiedPushStore.getEndpoint(instance) != endpoint) { - unifiedPushStore.storeUpEndpoint(endpoint, instance) - coroutineScope.launch { - val gateway = unifiedPushGatewayResolver.getGateway(endpoint) - unifiedPushStore.storePushGateway(gateway, instance) - gateway?.let { pushGateway -> - newGatewayHandler.handle(endpoint, pushGateway, instance) - } + Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint") + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + unifiedPushStore.storePushGateway(gateway, instance) + if (gateway == null) { + Timber.tag(loggerTag.value).w("No gateway found for endpoint $endpoint") + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = Result.failure(IllegalStateException("No gateway found for endpoint $endpoint")), + ) + ) + } else { + val result = newGatewayHandler.handle(endpoint, gateway, instance) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") + } + .onSuccess { + unifiedPushStore.storeUpEndpoint(endpoint, instance) + } + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = result, + ) + ) } - } else { - Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") } guardServiceStarter.stop() } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt new file mode 100644 index 00000000000..504ae51916d --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.registration + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +data class RegistrationResult( + val clientSecret: String, + val result: Result, +) + +@SingleIn(AppScope::class) +class EndpointRegistrationHandler @Inject constructor() { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun registrationDone(result: RegistrationResult) { + _state.emit(result) + } +} diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt index fcfd6475c34..d24cc5ff5eb 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -24,7 +24,7 @@ interface UserPushStore { suspend fun getPushProviderName(): String? suspend fun setPushProviderName(value: String) suspend fun getCurrentRegisteredPushKey(): String? - suspend fun setCurrentRegisteredPushKey(value: String) + suspend fun setCurrentRegisteredPushKey(value: String?) fun getNotificationEnabledForDevice(): Flow suspend fun setNotificationEnabledForDevice(enabled: Boolean) diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt index f7a159f6c35..cfcc9e3da4f 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -76,9 +76,13 @@ class UserPushStoreDataStore( return context.dataStore.data.first()[currentPushKey] } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { context.dataStore.edit { - it[currentPushKey] = value + if (value == null) { + it.remove(currentPushKey) + } else { + it[currentPushKey] = value + } } } diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt b/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt index 2afbf3210e2..3428fb5d3e3 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt @@ -36,7 +36,7 @@ class FakeUserPushStore : UserPushStore { return currentRegisteredPushKey } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { currentRegisteredPushKey = value } diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png index 7522f1df811..8f3d4227752 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de588d3ef8770778779d09b2f883e4327c4cc3afce98d33aab32298e32ca070a -size 44474 +oid sha256:fde2f5bfe13c4c775cbbea215a6a4d8fe9e474f5939e41b2ae9c23238871dedb +size 50191 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png index 244bcde7239..2ee9b8e3d79 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:342ef5ebb8ece155939816d6ba04298b5827dd1c125891986ee3d6389c75fe64 -size 43970 +oid sha256:1f20dda0c0ce1757da7014083cfa0c602eed2bd1019ddbd0566668338b6697a1 +size 49764 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png index cfdce8855f2..f46a4906475 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92ed07e7bea55582c88ca8c8f3918c1058a5f56a1f7a196fd8080c37bff6bc52 -size 35864 +oid sha256:b6fd202a1f27239b3a6d4f587538905d01bd0fcfa745262535c050e37600e71f +size 36066 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png index 5c691e4c48f..17ee7ac710f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4219e66ccebc52570ee2193b9a4b564a8c9c370c051a8c377e4ad12a9f3f8ffb -size 44001 +oid sha256:6fcca4452f2523e8ec8601efc5a2c08b85e1b198992020fce3d75799d046d68a +size 49778 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..35c515822e7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22b20d44483d246fb0b10a96d2e243195b2c2c45df4721337c77be0f4261d596 +size 42891 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..35751355e7f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:462452d822da2efa59ba1e6451d9ff8fecd9b1dc3a7c96bbae2c61be845b140f +size 49428 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..1cd6b5237db --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Day-1_2_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a414a44619f982494a64348f82149e2d9ac41d8a7c963135f5ec3d5eede0c4eb +size 49513 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png index 47095a02b7a..6af195ec370 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:706666dd4351887061879359d6947daef5ce74e552784b18bd0216b233ee6048 -size 41826 +oid sha256:876406e0e009f3d874cf1b9101702c82de792b6f66d4cb0870b03dafef124afa +size 47270 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png index e4fc9e45872..0683ac11d61 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:731f570f939af1f515f030f03420aeca39880100707e74e4ebe2e3433ac56a95 -size 41441 +oid sha256:f3576179a7bd4c23ecc0567c4f38282af12d34d93d988b17dad54a307a69699d +size 46897 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png index 62c48d20500..df10f0fa71f 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d7cadd4ed9e918a52a7b7ab0c6416c0cb06b1d77900c68606c0f532a6b0647c -size 31551 +oid sha256:4f8372768cb4605bf5e3d889d847eb08bd71e0fee9fa62a60d99f04d583618e4 +size 31772 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png index 448627f3d9c..d1ac2e827c8 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_3,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffa635614cbf5a57c4b481c99eadaf56777342090fbc26441be3e413b16ff95b -size 41436 +oid sha256:7dc86ad9c4246582c4468bcb0c0c1c566e76c56a73813d3a1040b4836f5f9ba6 +size 46872 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..106f6044400 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ef2574271233ac991ea92ef9b5b00fec488ea14c3d75cc8a308bbe4b11e5a63 +size 37557 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_5,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_5,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..a0491f08908 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_5,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76a2946b89cd562a090b43e14f752d61c161a3409e3169ab1e88d78e325048bd +size 46391 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_6,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_6,NEXUS_5,1.0,en].png new file mode 100644 index 00000000000..b48924e9a01 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.preferences.impl.advanced_AdvancedSettingsView_null_AdvancedSettingsView-Night-1_3_null_6,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edac5e24157eaa960bcf320c3f4fdd79f0a62bf09b47b36193d9936e953d3489 +size 46511