Merge pull request #2873 from element-hq/feature/bma/pushProviderSwitch
Push provider switch
This commit is contained in:
commit
d67c1c6fb9
50 changed files with 782 additions and 154 deletions
|
|
@ -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
1
changelog.d/2340.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Allow configuring push notification provider
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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? {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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? {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:22b20d44483d246fb0b10a96d2e243195b2c2c45df4721337c77be0f4261d596
|
||||||
|
size 42891
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:462452d822da2efa59ba1e6451d9ff8fecd9b1dc3a7c96bbae2c61be845b140f
|
||||||
|
size 49428
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a414a44619f982494a64348f82149e2d9ac41d8a7c963135f5ec3d5eede0c4eb
|
||||||
|
size 49513
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5ef2574271233ac991ea92ef9b5b00fec488ea14c3d75cc8a308bbe4b11e5a63
|
||||||
|
size 37557
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:76a2946b89cd562a090b43e14f752d61c161a3409e3169ab1e88d78e325048bd
|
||||||
|
size 46391
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:edac5e24157eaa960bcf320c3f4fdd79f0a62bf09b47b36193d9936e953d3489
|
||||||
|
size 46511
|
||||||
Loading…
Add table
Add a link
Reference in a new issue