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
commit d67c1c6fb9
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.libraries.push.api.PushService
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class LoggedInPresenter @Inject constructor( class LoggedInPresenter @Inject constructor(
@ -55,10 +56,26 @@ class LoggedInPresenter @Inject constructor(
LaunchedEffect(isVerified) { LaunchedEffect(isVerified) {
if (isVerified) { if (isVerified) {
// Ensure pusher is registered // Ensure pusher is registered
// TODO Manually select push provider for now val currentPushProvider = pushService.getCurrentPushProvider()
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect val result = if (currentPushProvider == null) {
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect // Register with the first available push provider
pushService.registerWith(matrixClient, pushProvider, distributor) 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.mediaupload.api)
implementation(projects.libraries.permissions.api) implementation(projects.libraries.permissions.api)
implementation(projects.libraries.push.api) implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.lockscreen.api) implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
@ -90,6 +91,7 @@ dependencies {
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.logout.impl) testImplementation(projects.features.logout.impl)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)

View file

@ -24,4 +24,7 @@ sealed interface AdvancedSettingsEvents {
data object ChangeTheme : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents
data object CancelChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents
data class SetTheme(val theme: Theme) : 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 package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.compound.theme.mapToTheme
import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.preferences.api.store.SessionPreferencesStore 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.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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class AdvancedSettingsPresenter @Inject constructor( class AdvancedSettingsPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val matrixClient: MatrixClient,
private val pushService: PushService,
) : Presenter<AdvancedSettingsState> { ) : Presenter<AdvancedSettingsState> {
@Composable @Composable
override fun present(): AdvancedSettingsState { override fun present(): AdvancedSettingsState {
@ -49,6 +60,62 @@ class AdvancedSettingsPresenter @Inject constructor(
} }
.collectAsState(initial = Theme.System) .collectAsState(initial = Theme.System)
var showChangeThemeDialog by remember { mutableStateOf(false) } 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) { fun handleEvents(event: AdvancedSettingsEvents) {
when (event) { when (event) {
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
@ -63,6 +130,9 @@ class AdvancedSettingsPresenter @Inject constructor(
appPreferencesStore.setTheme(event.theme.name) appPreferencesStore.setTheme(event.theme.name)
showChangeThemeDialog = false 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, isSharePresenceEnabled = isSharePresenceEnabled,
theme = theme, theme = theme,
showChangeThemeDialog = showChangeThemeDialog, showChangeThemeDialog = showChangeThemeDialog,
currentPushDistributor = currentDistributorName,
availablePushDistributors = distributorNames.toImmutableList(),
showChangePushProviderDialog = showChangePushProviderDialog,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -17,11 +17,16 @@
package io.element.android.features.preferences.impl.advanced package io.element.android.features.preferences.impl.advanced
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.collections.immutable.ImmutableList
data class AdvancedSettingsState( data class AdvancedSettingsState(
val isDeveloperModeEnabled: Boolean, val isDeveloperModeEnabled: Boolean,
val isSharePresenceEnabled: Boolean, val isSharePresenceEnabled: Boolean,
val theme: Theme, val theme: Theme,
val showChangeThemeDialog: Boolean, val showChangeThemeDialog: Boolean,
val currentPushDistributor: AsyncAction<String>,
val availablePushDistributors: ImmutableList<String>,
val showChangePushProviderDialog: Boolean,
val eventSink: (AdvancedSettingsEvents) -> Unit 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.compound.theme.Theme import io.element.android.compound.theme.Theme
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.collections.immutable.toImmutableList
open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> { open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSettingsState> {
override val values: Sequence<AdvancedSettingsState> override val values: Sequence<AdvancedSettingsState>
@ -26,6 +28,9 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider<AdvancedSett
aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true),
aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(showChangeThemeDialog = true),
aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = 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, isDeveloperModeEnabled: Boolean = false,
isSendPublicReadReceiptsEnabled: Boolean = false, isSendPublicReadReceiptsEnabled: Boolean = false,
showChangeThemeDialog: Boolean = false, showChangeThemeDialog: Boolean = false,
currentPushDistributor: AsyncAction<String> = AsyncAction.Success("Firebase"),
availablePushDistributors: List<String> = listOf("Firebase", "ntfy"),
showChangePushProviderDialog: Boolean = false,
eventSink: (AdvancedSettingsEvents) -> Unit = {},
) = AdvancedSettingsState( ) = AdvancedSettingsState(
isDeveloperModeEnabled = isDeveloperModeEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled,
isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled,
theme = Theme.System, theme = Theme.System,
showChangeThemeDialog = showChangeThemeDialog, 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 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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter 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.Theme
import io.element.android.compound.theme.themes import io.element.android.compound.theme.themes
import io.element.android.features.preferences.impl.R 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.ListOption
import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog 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.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -81,6 +86,34 @@ fun AdvancedSettingsView(
), ),
onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } 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) { if (state.showChangeThemeDialog) {
@ -97,6 +130,22 @@ fun AdvancedSettingsView(
onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangeTheme) }, 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 @Composable

View file

@ -21,8 +21,16 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme 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.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore 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.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import kotlinx.coroutines.test.runTest 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( private fun createAdvancedSettingsPresenter(
appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
matrixClient: MatrixClient = FakeMatrixClient(),
pushService: PushService = FakePushService(),
) = AdvancedSettingsPresenter( ) = AdvancedSettingsPresenter(
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
sessionPreferencesStore = sessionPreferencesStore, 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" } sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4"
sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" 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" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } 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 isFailure(): Boolean = this is Failure
fun isSuccess(): Boolean = this is Success fun isSuccess(): Boolean = this is Success
fun isReady() = isSuccess() || isFailure()
} }
suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState( suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(

View file

@ -18,5 +18,5 @@ package io.element.android.libraries.matrix.api.pusher
interface PushersService { interface PushersService {
suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit> 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.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.pusher.PushersService 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.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.HttpPusherData import org.matrix.rustcomponents.sdk.HttpPusherData
@ -54,8 +55,16 @@ class RustPushersService(
} }
} }
override suspend fun unsetHttpPusher(): Result<Unit> { override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): 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 withContext(dispatchers.io) {
return Result.success(Unit) 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.PushersService
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
class FakePushersService : PushersService { class FakePushersService : PushersService {
override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) 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 // TODO Move away
fun notificationStyleChanged() 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 * Return the list of push providers, available at compile time, and
* available at runtime, sorted by index. * 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. * 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. * 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.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
@ -39,6 +40,11 @@ class DefaultPushService @Inject constructor(
defaultNotificationDrawerManager.notificationStyleChanged() defaultNotificationDrawerManager.notificationStyleChanged()
} }
override suspend fun getCurrentPushProvider(): PushProvider? {
val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider()
return pushProviders.find { it.name == currentPushProvider }
}
override fun getAvailablePushProviders(): List<PushProvider> { override fun getAvailablePushProviders(): List<PushProvider> {
return pushProviders return pushProviders
.filter { it.isAvailable() } .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. * 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 userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
val currentPushProviderName = userPushStore.getPushProviderName() 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 // 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 // Store new value
userPushStore.setPushProviderName(pushProvider.name) userPushStore.setPushProviderName(pushProvider.name)
// Then try to register
return pushProvider.registerWith(matrixClient, distributor)
} }
override suspend fun testPush(): Boolean { override suspend fun testPush(): Boolean {
val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() val pushProvider = getCurrentPushProvider() ?: return false
val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false
val config = pushProvider.getCurrentUserPushConfig() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false
pushersManager.testPush(config) pushersManager.testPush(config)
return true 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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId 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.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest
import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig
import io.element.android.libraries.pushproviders.api.PusherSubscriber 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. * 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) val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId)
if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { if (userDataStore.getCurrentRegisteredPushKey() == pushKey) {
Timber.tag(loggerTag.value) 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") .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server")
} }
matrixClient.pushersService().setHttpPusher( return matrixClient.pushersService()
createHttpPusher(pushKey, gateway, matrixClient.sessionId) .setHttpPusher(
).fold( createHttpPusher(pushKey, gateway, matrixClient.sessionId)
{ )
.onSuccess {
userDataStore.setCurrentRegisteredPushKey(pushKey) userDataStore.setCurrentRegisteredPushKey(pushKey)
}, }
{ throwable -> .onFailure { throwable ->
Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher")
} }
)
} }
private suspend fun createHttpPusher( private suspend fun createHttpPusher(
@ -106,8 +111,25 @@ class PushersManager @Inject constructor(
return "{\"cs\":\"$secretForUser\"}" return "{\"cs\":\"$secretForUser\"}"
} }
override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { override suspend fun unregisterPusher(
matrixClient.pushersService().unsetHttpPusher() 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 { companion object {

View file

@ -23,16 +23,29 @@ import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
class FakePushService( 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 { ) : PushService {
override fun notificationStyleChanged() { override fun notificationStyleChanged() {
} }
override fun getAvailablePushProviders(): List<PushProvider> { override suspend fun getCurrentPushProvider(): PushProvider? {
return emptyList() 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 { override suspend fun testPush(): Boolean = simulateLongTask {

View file

@ -16,6 +16,14 @@
package io.element.android.libraries.pushproviders.api 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( data class Distributor(
val value: String, val value: String,
val name: String, val name: String,

View file

@ -42,12 +42,17 @@ interface PushProvider {
/** /**
* Register the pusher to the homeserver. * 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. * Unregister the pusher.
*/ */
suspend fun unregister(matrixClient: MatrixClient) suspend fun unregister(matrixClient: MatrixClient): Result<Unit>
suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? 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 import io.element.android.libraries.matrix.api.MatrixClient
interface PusherSubscriber { interface PusherSubscriber {
suspend fun registerPusher(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) suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result<Unit>
} }

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.pushproviders.firebase 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.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
@ -43,12 +44,24 @@ class FirebaseNewTokenHandler @Inject constructor(
// Register the pusher for all the sessions // Register the pusher for all the sessions
sessionStore.getAllSessions().toUserList() sessionStore.getAllSessions().toUserList()
.map { SessionId(it) } .map { SessionId(it) }
.forEach { userId -> .forEach { sessionId ->
val userDataStore = userPushStoreFactory.getOrCreate(userId) val userDataStore = userPushStoreFactory.getOrCreate(sessionId)
if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) { if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) {
matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> matrixAuthenticationService
pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.PUSHER_HTTP_URL) .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 { } else {
Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") 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> { override fun getDistributors(): List<Distributor> {
return listOf(Distributor("Firebase", "Firebase")) return listOf(firebaseDistributor)
} }
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {
val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { 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.") 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) { override suspend fun getCurrentDistributor(matrixClient: MatrixClient) = firebaseDistributor
val pushKey = firebaseStore.getFcmToken() ?: return Unit.also {
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.") 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? { 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 index: Int = 0,
override val name: String = "aFakePushProvider", override val name: String = "aFakePushProvider",
private val isAvailable: Boolean = true, private val isAvailable: Boolean = true,
private val distributors: List<Distributor> = emptyList() private val distributors: List<Distributor> = listOf(Distributor("aDistributorValue", "aDistributorName")),
) : PushProvider { ) : PushProvider {
override fun isAvailable(): Boolean = isAvailable override fun isAvailable(): Boolean = isAvailable
override fun getDistributors(): List<Distributor> = distributors override fun getDistributors(): List<Distributor> = distributors
override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result<Unit> {
// No-op return Result.success(Unit)
} }
override suspend fun unregister(matrixClient: MatrixClient) { override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? {
// No-op return distributors.firstOrNull()
}
override suspend fun unregister(matrixClient: MatrixClient): Result<Unit> {
return Result.success(Unit)
} }
override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {

View file

@ -18,55 +18,42 @@ package io.element.android.libraries.pushproviders.unifiedpush
import android.content.Context import android.content.Context
import io.element.android.libraries.di.ApplicationContext 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.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 org.unifiedpush.android.connector.UnifiedPush
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class RegisterUnifiedPushUseCase @Inject constructor( class RegisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val pusherSubscriber: PusherSubscriber, private val endpointRegistrationHandler: EndpointRegistrationHandler,
private val unifiedPushStore: UnifiedPushStore, private val coroutineScope: CoroutineScope,
) { ) {
sealed interface RegisterUnifiedPushResult { suspend fun execute(distributor: Distributor, clientSecret: String): Result<Unit> {
data object Success : RegisterUnifiedPushResult UnifiedPush.saveDistributor(context, distributor.value)
data object NeedToAskUserForDistributor : RegisterUnifiedPushResult val completable = CompletableDeferred<Result<Unit>>()
data object Error : RegisterUnifiedPushResult val job = coroutineScope.launch {
} val result = endpointRegistrationHandler.state
.filter { it.clientSecret == clientSecret }
suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { .first()
val distributorValue = distributor.value .result
if (distributorValue.isNotEmpty()) { completable.complete(result)
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
} }
// This will trigger the callback
// TODO Below should never happen? // VectorUnifiedPushMessagingReceiver.onNewEndpoint
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) {
UnifiedPush.registerApp(context = context, instance = clientSecret) 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 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.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushproviders.api.PusherSubscriber
@ -27,7 +28,7 @@ import javax.inject.Inject
private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) 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( class UnifiedPushNewGatewayHandler @Inject constructor(
private val pusherSubscriber: PusherSubscriber, private val pusherSubscriber: PusherSubscriber,
@ -35,18 +36,25 @@ class UnifiedPushNewGatewayHandler @Inject constructor(
private val pushClientSecret: PushClientSecret, private val pushClientSecret: PushClientSecret,
private val matrixAuthenticationService: MatrixAuthenticationService, 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. // 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") Timber.w("Unable to retrieve session")
} }
val userDataStore = userPushStoreFactory.getOrCreate(userId) val userDataStore = userPushStoreFactory.getOrCreate(userId)
if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) {
matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> matrixAuthenticationService
pusherSubscriber.registerPusher(client, endpoint, pushGateway) .restoreSession(userId)
} .flatMap { client ->
pusherSubscriber.registerPusher(client, endpoint, pushGateway)
}
} else { } else {
Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") 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() 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) 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) val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId)
unRegisterUnifiedPushUseCase.execute(clientSecret) return unRegisterUnifiedPushUseCase.execute(matrixClient, clientSecret)
} }
override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? {

View file

@ -21,6 +21,7 @@ import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.matrix.api.core.UserId
import javax.inject.Inject import javax.inject.Inject
class UnifiedPushStore @Inject constructor( 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 { companion object {
private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN"
private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" 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 android.content.Context
import io.element.android.libraries.di.ApplicationContext 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 org.unifiedpush.android.connector.UnifiedPush
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class UnregisterUnifiedPushUseCase @Inject constructor( class UnregisterUnifiedPushUseCase @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
// private val pushDataStore: PushDataStore,
private val unifiedPushStore: UnifiedPushStore, private val unifiedPushStore: UnifiedPushStore,
// private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, private val pusherSubscriber: PusherSubscriber,
) { ) {
suspend fun execute(clientSecret: String) { suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result<Unit> {
// val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME val endpoint = unifiedPushStore.getEndpoint(clientSecret)
// pushDataStore.setFdroidSyncBackgroundMode(mode) val gateway = unifiedPushStore.getPushGateway(clientSecret)
try { if (endpoint == null || gateway == null) {
unifiedPushStore.getEndpoint(clientSecret)?.let { return Result.failure(IllegalStateException("No endpoint or gateway found for client secret"))
Timber.d("Removing $it")
// TODO pushersManager?.unregisterPusher(it)
}
} catch (e: Exception) {
Timber.d(e, "Probably unregistering a non existing pusher")
} }
unifiedPushStore.storeUpEndpoint(null, clientSecret) return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway)
unifiedPushStore.storePushGateway(null, clientSecret) .onSuccess {
UnifiedPush.unregisterApp(context) 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.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.pushproviders.api.PushHandler 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.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -37,6 +39,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() {
@Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushStore: UnifiedPushStore
@Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver
@Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler
@Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler
private val coroutineScope = CoroutineScope(SupervisorJob()) 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. * You should send the endpoint to your application server and sync for missing notifications.
*/ */
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint")
// If the endpoint has changed coroutineScope.launch {
// or the gateway has changed val gateway = unifiedPushGatewayResolver.getGateway(endpoint)
if (unifiedPushStore.getEndpoint(instance) != endpoint) { unifiedPushStore.storePushGateway(gateway, instance)
unifiedPushStore.storeUpEndpoint(endpoint, instance) if (gateway == null) {
coroutineScope.launch { Timber.tag(loggerTag.value).w("No gateway found for endpoint $endpoint")
val gateway = unifiedPushGatewayResolver.getGateway(endpoint) endpointRegistrationHandler.registrationDone(
unifiedPushStore.storePushGateway(gateway, instance) RegistrationResult(
gateway?.let { pushGateway -> clientSecret = instance,
newGatewayHandler.handle(endpoint, pushGateway, 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() 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 getPushProviderName(): String?
suspend fun setPushProviderName(value: String) suspend fun setPushProviderName(value: String)
suspend fun getCurrentRegisteredPushKey(): String? suspend fun getCurrentRegisteredPushKey(): String?
suspend fun setCurrentRegisteredPushKey(value: String) suspend fun setCurrentRegisteredPushKey(value: String?)
fun getNotificationEnabledForDevice(): Flow<Boolean> fun getNotificationEnabledForDevice(): Flow<Boolean>
suspend fun setNotificationEnabledForDevice(enabled: Boolean) suspend fun setNotificationEnabledForDevice(enabled: Boolean)

View file

@ -76,9 +76,13 @@ class UserPushStoreDataStore(
return context.dataStore.data.first()[currentPushKey] return context.dataStore.data.first()[currentPushKey]
} }
override suspend fun setCurrentRegisteredPushKey(value: String) { override suspend fun setCurrentRegisteredPushKey(value: String?) {
context.dataStore.edit { 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 return currentRegisteredPushKey
} }
override suspend fun setCurrentRegisteredPushKey(value: String) { override suspend fun setCurrentRegisteredPushKey(value: String?) {
currentRegisteredPushKey = value currentRegisteredPushKey = value
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:de588d3ef8770778779d09b2f883e4327c4cc3afce98d33aab32298e32ca070a oid sha256:fde2f5bfe13c4c775cbbea215a6a4d8fe9e474f5939e41b2ae9c23238871dedb
size 44474 size 50191

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:342ef5ebb8ece155939816d6ba04298b5827dd1c125891986ee3d6389c75fe64 oid sha256:1f20dda0c0ce1757da7014083cfa0c602eed2bd1019ddbd0566668338b6697a1
size 43970 size 49764

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:92ed07e7bea55582c88ca8c8f3918c1058a5f56a1f7a196fd8080c37bff6bc52 oid sha256:b6fd202a1f27239b3a6d4f587538905d01bd0fcfa745262535c050e37600e71f
size 35864 size 36066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4219e66ccebc52570ee2193b9a4b564a8c9c370c051a8c377e4ad12a9f3f8ffb oid sha256:6fcca4452f2523e8ec8601efc5a2c08b85e1b198992020fce3d75799d046d68a
size 44001 size 49778

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:706666dd4351887061879359d6947daef5ce74e552784b18bd0216b233ee6048 oid sha256:876406e0e009f3d874cf1b9101702c82de792b6f66d4cb0870b03dafef124afa
size 41826 size 47270

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:731f570f939af1f515f030f03420aeca39880100707e74e4ebe2e3433ac56a95 oid sha256:f3576179a7bd4c23ecc0567c4f38282af12d34d93d988b17dad54a307a69699d
size 41441 size 46897

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9d7cadd4ed9e918a52a7b7ab0c6416c0cb06b1d77900c68606c0f532a6b0647c oid sha256:4f8372768cb4605bf5e3d889d847eb08bd71e0fee9fa62a60d99f04d583618e4
size 31551 size 31772

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ffa635614cbf5a57c4b481c99eadaf56777342090fbc26441be3e413b16ff95b oid sha256:7dc86ad9c4246582c4468bcb0c0c1c566e76c56a73813d3a1040b4836f5f9ba6
size 41436 size 46872

View file

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

View file

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

View file

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