Merge pull request #2873 from element-hq/feature/bma/pushProviderSwitch

Push provider switch
This commit is contained in:
Benoit Marty
2024-05-21 16:52:01 +02:00
committed by GitHub
50 changed files with 782 additions and 154 deletions

View File

@@ -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")
}
}
}

1
changelog.d/2340.misc Normal file
View File

@@ -0,0 +1 @@
Allow configuring push notification provider

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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<AdvancedSettingsState> {
@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<String>>(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<PushProvider, Distributor>?
) = 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) }
)
}

View File

@@ -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<String>,
val availablePushDistributors: ImmutableList<String>,
val showChangePushProviderDialog: Boolean,
val eventSink: (AdvancedSettingsEvents) -> Unit
)

View File

@@ -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<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState>
@@ -26,6 +28,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true),
aAdvancedSettingsState(showChangePushProviderDialog = true),
aAdvancedSettingsState(currentPushDistributor = AsyncAction.Loading),
aAdvancedSettingsState(currentPushDistributor = AsyncAction.Failure(Exception("Failed to change distributor"))),
)
}
@@ -33,10 +38,17 @@ fun aAdvancedSettingsState(
isDeveloperModeEnabled: Boolean = false,
isSendPublicReadReceiptsEnabled: Boolean = false,
showChangeThemeDialog: Boolean = false,
currentPushDistributor: AsyncAction<String> = AsyncAction.Success("Firebase"),
availablePushDistributors: List<String> = 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
)

View File

@@ -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

View File

@@ -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<Unit> = { _, _, _ ->
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,
)
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<AdvancedSettingsEvents>(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<AdvancedSettingsEvents>()
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<AdvancedSettingsEvents>()
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<AdvancedSettingsEvents>()
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<AdvancedSettingsEvents>()
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<AdvancedSettingsEvents>()
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<AdvancedSettingsEvents>()
rule.setAdvancedSettingsView(
state = aAdvancedSettingsState(
eventSink = eventsRecorder,
showChangePushProviderDialog = true,
availablePushDistributors = listOf("P1", "P2")
),
)
rule.onNodeWithText("P2").performClick()
eventsRecorder.assertSingle(AdvancedSettingsEvents.SetPushProvider(1))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAdvancedSettingsView(
state: AdvancedSettingsState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AdvancedSettingsView(
state = state,
onBackPressed = onBackPressed,
)
}
}

View File

@@ -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" }

View File

@@ -86,6 +86,8 @@ sealed interface AsyncAction<out T> {
fun isFailure(): Boolean = this is Failure
fun isSuccess(): Boolean = this is Success
fun isReady() = isSuccess() || isFailure()
}
suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(

View File

@@ -18,5 +18,5 @@ package io.element.android.libraries.matrix.api.pusher
interface PushersService {
suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit>
suspend fun unsetHttpPusher(): Result<Unit>
suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result<Unit>
}

View File

@@ -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,
)

View File

@@ -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<Unit> {
// 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<Unit> {
return withContext(dispatchers.io) {
runCatching {
client.deletePusher(
identifiers = PusherIdentifiers(
pushkey = unsetHttpPusherData.pushKey,
appId = unsetHttpPusherData.appId
),
)
}
}
}
}

View File

@@ -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<Unit> = Result.success(Unit)
override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result<Unit> = Result.success(Unit)
}

View File

@@ -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<Unit>
/**
* Return false in case of early error.

View File

@@ -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<PushProvider> {
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<Unit> {
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

View File

@@ -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<Unit> {
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<Unit> {
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 {

View File

@@ -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<PushProvider> = emptyList(),
private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result<Unit> = { _, _, _ ->
Result.success(Unit)
},
) : PushService {
override fun notificationStyleChanged() {
}
override fun getAvailablePushProviders(): List<PushProvider> {
return emptyList()
override suspend fun getCurrentPushProvider(): PushProvider? {
return availablePushProviders.firstOrNull()
}
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
override fun getAvailablePushProviders(): List<PushProvider> {
return availablePushProviders
}
override suspend fun registerWith(
matrixClient: MatrixClient,
pushProvider: PushProvider,
distributor: Distributor,
): Result<Unit> = simulateLongTask {
return registerWithLambda(matrixClient, pushProvider, distributor)
}
override suspend fun testPush(): Boolean = simulateLongTask {

View File

@@ -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,

View File

@@ -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<Unit>
/**
* 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<Unit>
suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig?
}

View File

@@ -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<Unit>
suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit>
}

View File

@@ -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")
}

View File

@@ -43,21 +43,35 @@ class FirebasePushProvider @Inject constructor(
}
override fun getDistributors(): List<Distributor> {
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<Unit> {
val pushKey = firebaseStore.getFcmToken() ?: return Result.failure<Unit>(
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<Unit> {
val pushKey = firebaseStore.getFcmToken() ?: return Result.failure<Unit>(
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")
}
}

View File

@@ -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<Distributor> = emptyList()
private val distributors: List<Distributor> = listOf(Distributor("aDistributorValue", "aDistributorName")),
) : PushProvider {
override fun isAvailable(): Boolean = isAvailable
override fun getDistributors(): List<Distributor> = distributors
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) {
// No-op
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {
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<Unit> {
return Result.success(Unit)
}
override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {

View File

@@ -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
suspend fun execute(distributor: Distributor, clientSecret: String): Result<Unit> {
UnifiedPush.saveDistributor(context, distributor.value)
val completable = CompletableDeferred<Result<Unit>>()
val job = coroutineScope.launch {
val result = endpointRegistrationHandler.state
.filter { it.clientSecret == clientSecret }
.first()
.result
completable.complete(result)
}
// 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
}
}
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()
}
}
}

View File

@@ -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<Unit> {
// 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<Unit>(
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")
)
}
}
}

View File

@@ -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<Unit> {
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<Unit> {
val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId)
unRegisterUnifiedPushUseCase.execute(clientSecret)
return unRegisterUnifiedPushUseCase.execute(matrixClient, clientSecret)
}
override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {

View File

@@ -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"
}
}

View File

@@ -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<Unit> {
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)
}
}
}

View File

@@ -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()
}

View File

@@ -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<Unit>,
)
@SingleIn(AppScope::class)
class EndpointRegistrationHandler @Inject constructor() {
private val _state = MutableSharedFlow<RegistrationResult>()
val state: SharedFlow<RegistrationResult> = _state
suspend fun registrationDone(result: RegistrationResult) {
_state.emit(result)
}
}

View File

@@ -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<Boolean>
suspend fun setNotificationEnabledForDevice(enabled: Boolean)

View File

@@ -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
}
}
}

View File

@@ -36,7 +36,7 @@ class FakeUserPushStore : UserPushStore {
return currentRegisteredPushKey
}
override suspend fun setCurrentRegisteredPushKey(value: String) {
override suspend fun setCurrentRegisteredPushKey(value: String?) {
currentRegisteredPushKey = value
}