diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index c792687763..01db2cef83 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -3,7 +3,9 @@ backstack blurhash + fdroid ftue + gplay homeserver konsist kover diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 69833a985e..18054297d6 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -1,6 +1,6 @@ appId: ${MAESTRO_APP_ID} --- -- tapOn: "Continue" +- tapOn: "Sign in manually" - runFlow: ../assertions/assertLoginDisplayed.yaml - takeScreenshot: build/maestro/100-SignIn - runFlow: changeServer.yaml diff --git a/.maestro/tests/account/verifySession.yaml b/.maestro/tests/account/verifySession.yaml index efc45ac3ef..a16322543f 100644 --- a/.maestro/tests/account/verifySession.yaml +++ b/.maestro/tests/account/verifySession.yaml @@ -9,5 +9,5 @@ appId: ${MAESTRO_APP_ID} - tapOn: "Continue" - extendedWaitUntil: visible: "Device verified" - timeout: 10000 + timeout: 30000 - tapOn: "Continue" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 56350242c0..4735355464 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,6 +123,30 @@ + + + + + + + + + + + + + + + + + + + + { Timber.plant(tracingService.createTimberTree()) val tracingConfiguration = if (BuildConfig.DEBUG) { val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val store = SharedPrefTracingConfigurationStore(prefs) + val store = SharedPreferencesTracingConfigurationStore(prefs) val builder = TargetLogLevelMapBuilder(store) TracingConfiguration( filterConfiguration = TracingFilterConfigurations.custom(builder.getCurrentMap()), diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt similarity index 97% rename from app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt rename to app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt index bdadf1e3ef..e8e83028a7 100644 --- a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt +++ b/app/src/main/kotlin/io/element/android/x/intent/DefaultIntentProvider.kt @@ -31,7 +31,7 @@ import io.element.android.x.MainActivity import javax.inject.Inject @ContributesBinding(AppScope::class) -class IntentProviderImpl @Inject constructor( +class DefaultIntentProvider @Inject constructor( @ApplicationContext private val context: Context, private val deepLinkCreator: DeepLinkCreator, ) : IntentProvider { diff --git a/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt similarity index 90% rename from app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt rename to app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt index b8b854d158..87e72bcb12 100644 --- a/app/src/test/kotlin/io/element/android/x/intent/IntentProviderImplTest.kt +++ b/app/src/test/kotlin/io/element/android/x/intent/DefaultIntentProviderTest.kt @@ -30,10 +30,10 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) -class IntentProviderImplTest { +class DefaultIntentProviderTest { @Test fun `test getViewRoomIntent with Session`() { - val sut = createIntentProviderImpl() + val sut = createDefaultIntentProvider() val result = sut.getViewRoomIntent( sessionId = A_SESSION_ID, roomId = null, @@ -45,7 +45,7 @@ class IntentProviderImplTest { @Test fun `test getViewRoomIntent with Session and Room`() { - val sut = createIntentProviderImpl() + val sut = createDefaultIntentProvider() val result = sut.getViewRoomIntent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -57,7 +57,7 @@ class IntentProviderImplTest { @Test fun `test getViewRoomIntent with Session, Room and Thread`() { - val sut = createIntentProviderImpl() + val sut = createDefaultIntentProvider() val result = sut.getViewRoomIntent( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -67,8 +67,8 @@ class IntentProviderImplTest { assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") } - private fun createIntentProviderImpl(): IntentProviderImpl { - return IntentProviderImpl( + private fun createDefaultIntentProvider(): DefaultIntentProvider { + return DefaultIntentProvider( context = RuntimeEnvironment.getApplication() as Context, deepLinkCreator = DeepLinkCreator(), ) diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt index 8d41a2a207..9ca4f73314 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt @@ -20,6 +20,9 @@ object NotificationConfig { // TODO EAx Implement and set to true at some point const val SUPPORT_MARK_AS_READ_ACTION = false + // TODO EAx Implement and set to true at some point + const val SUPPORT_JOIN_DECLINE_INVITE = false + // TODO EAx Implement and set to true at some point const val SUPPORT_QUICK_REPLY_ACTION = false } diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt similarity index 78% rename from features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt index 969ac382a4..e2a78fc522 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/OnBoardingConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,9 +14,12 @@ * limitations under the License. */ -package io.element.android.features.onboarding.impl +package io.element.android.appconfig object OnBoardingConfig { + /** Whether the user can use QR code login. */ const val CAN_LOGIN_WITH_QR_CODE = false + + /** Whether the user can create an account using the app. */ const val CAN_CREATE_ACCOUNT = false } diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index c24e544240..95fb4a8a08 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.coil) implementation(projects.features.ftue.api) + implementation(projects.features.share.api) implementation(projects.features.viewfolder.api) implementation(projects.services.apperror.impl) @@ -71,6 +72,7 @@ dependencies { testImplementation(projects.tests.testutils) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) + testImplementation(projects.features.share.test) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.analytics.test) testImplementation(libs.test.appyx.junit) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index bd68a9151e..06643f616c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -16,6 +16,7 @@ package io.element.android.appnav +import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable @@ -54,6 +55,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint +import io.element.android.features.share.api.ShareEntryPoint import io.element.android.features.userprofile.api.UserProfileEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode @@ -98,6 +100,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val networkMonitor: NetworkMonitor, private val ftueService: FtueService, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, + private val shareEntryPoint: ShareEntryPoint, private val matrixClient: MatrixClient, snackbarDispatcher: SnackbarDispatcher, ) : BaseFlowNode( @@ -219,6 +222,9 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data object RoomDirectorySearch : NavTarget + + @Parcelize + data class IncomingShare(val intent: Intent) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -229,31 +235,31 @@ class LoggedInFlowNode @AssistedInject constructor( } NavTarget.RoomList -> { val callback = object : RoomListEntryPoint.Callback { - override fun onRoomClicked(roomId: RoomId) { + override fun onRoomClick(roomId: RoomId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) } - override fun onSettingsClicked() { + override fun onSettingsClick() { backstack.push(NavTarget.Settings()) } - override fun onCreateRoomClicked() { + override fun onCreateRoomClick() { backstack.push(NavTarget.CreateRoom) } - override fun onSessionConfirmRecoveryKeyClicked() { + override fun onSessionConfirmRecoveryKeyClick() { backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) } - override fun onRoomSettingsClicked(roomId: RoomId) { + override fun onRoomSettingsClick(roomId: RoomId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details)) } - override fun onReportBugClicked() { + override fun onReportBugClick() { plugins().forEach { it.onOpenBugReport() } } - override fun onRoomDirectorySearchClicked() { + override fun onRoomDirectorySearchClick() { backstack.push(NavTarget.RoomDirectorySearch) } } @@ -272,7 +278,7 @@ class LoggedInFlowNode @AssistedInject constructor( coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) } } - override fun onPermalinkClicked(data: PermalinkData) { + override fun onPermalinkClick(data: PermalinkData) { when (data) { is PermalinkData.UserLink -> { // Should not happen (handled by MessagesNode) @@ -325,7 +331,7 @@ class LoggedInFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenBugReport() } } - override fun onSecureBackupClicked() { + override fun onSecureBackupClick() { backstack.push(NavTarget.SecureBackup()) } @@ -363,7 +369,7 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.RoomDirectorySearch -> { roomDirectoryEntryPoint.nodeBuilder(this, buildContext) .callback(object : RoomDirectoryEntryPoint.Callback { - override fun onResultClicked(roomDescription: RoomDescription) { + override fun onResultClick(roomDescription: RoomDescription) { backstack.push( NavTarget.Room( roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(), @@ -375,6 +381,20 @@ class LoggedInFlowNode @AssistedInject constructor( }) .build() } + is NavTarget.IncomingShare -> { + shareEntryPoint.nodeBuilder(this, buildContext) + .callback(object : ShareEntryPoint.Callback { + override fun onDone(roomIds: List) { + navigateUp() + if (roomIds.size == 1) { + val targetRoomId = roomIds.first() + backstack.push(NavTarget.Room(targetRoomId.toRoomIdOrAlias())) + } + } + }) + .params(ShareEntryPoint.Params(intent = navTarget.intent)) + .build() + } } } @@ -414,6 +434,17 @@ class LoggedInFlowNode @AssistedInject constructor( } } + internal suspend fun attachIncomingShare(intent: Intent) { + waitForNavTargetAttached { navTarget -> + navTarget is NavTarget.RoomList + } + attachChild { + backstack.push( + NavTarget.IncomingShare(intent) + ) + } + } + @Composable override fun View(modifier: Modifier) { Box(modifier = modifier) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index fae6d586aa..72d2402eef 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -31,10 +31,13 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.login.api.LoginEntryPoint +import io.element.android.features.login.api.LoginFlowType import io.element.android.features.onboarding.api.OnBoardingEntryPoint import io.element.android.features.preferences.api.ConfigureTracingEntryPoint import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices +import io.element.android.libraries.designsystem.utils.ScreenOrientation import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.ui.media.NotLoggedInImageLoaderFactory import kotlinx.parcelize.Parcelize @@ -73,9 +76,7 @@ class NotLoggedInFlowNode @AssistedInject constructor( data object OnBoarding : NavTarget @Parcelize - data class LoginFlow( - val isAccountCreation: Boolean, - ) : NavTarget + data class LoginFlow(val type: LoginFlowType) : NavTarget @Parcelize data object ConfigureTracing : NavTarget @@ -86,11 +87,15 @@ class NotLoggedInFlowNode @AssistedInject constructor( NavTarget.OnBoarding -> { val callback = object : OnBoardingEntryPoint.Callback { override fun onSignUp() { - backstack.push(NavTarget.LoginFlow(isAccountCreation = true)) + backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_UP)) } override fun onSignIn() { - backstack.push(NavTarget.LoginFlow(isAccountCreation = false)) + backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_MANUAL)) + } + + override fun onSignInWithQrCode() { + backstack.push(NavTarget.LoginFlow(type = LoginFlowType.SIGN_IN_QR_CODE)) } override fun onOpenDeveloperSettings() { @@ -108,7 +113,7 @@ class NotLoggedInFlowNode @AssistedInject constructor( } is NavTarget.LoginFlow -> { loginEntryPoint.nodeBuilder(this, buildContext) - .params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation)) + .params(LoginEntryPoint.Params(flowType = navTarget.type)) .build() } NavTarget.ConfigureTracing -> { @@ -119,6 +124,9 @@ class NotLoggedInFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { + // The login flow doesn't support landscape mode on mobile devices yet + ForceOrientationInMobileDevices(orientation = ScreenOrientation.PORTRAIT) + BackstackView() } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 4b886d9c99..d200acb84e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -283,6 +283,19 @@ class RootFlowNode @AssistedInject constructor( is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData) + is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent) + } + } + + private suspend fun onIncomingShare(intent: Intent) { + // Is there a session already? + val latestSessionId = authenticationService.getLatestSessionId() + if (latestSessionId == null) { + // No session, open login + switchToNotLoggedInFlow() + } else { + attachSession(latestSessionId) + .attachIncomingShare(intent) } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt index dafbf8c283..d41fbbfebb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/intent/IntentResolver.kt @@ -30,6 +30,7 @@ sealed interface ResolvedIntent { data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent + data class IncomingShare(val intent: Intent) : ResolvedIntent } class IntentResolver @Inject constructor( @@ -56,6 +57,10 @@ class IntentResolver @Inject constructor( ?.takeIf { it !is PermalinkData.FallbackLink } if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData) + if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) { + return ResolvedIntent.IncomingShare(intent) + } + // Unknown intent Timber.w("Unknown intent") return null diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt index 70337e8d33..7133fae9f0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomFlowNode.kt @@ -217,7 +217,7 @@ class RoomFlowNode @AssistedInject constructor( LoadingRoomNodeView( state = LoadingRoomState.Loading, hasNetworkConnection = networkStatus == NetworkStatus.Online, - onBackClicked = { navigateUp() }, + onBackClick = { navigateUp() }, modifier = modifier, ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt index 49bcb53048..6adb371fdc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -121,14 +121,14 @@ class JoinedRoomFlowNode @AssistedInject constructor( } } - private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> + private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier -> val loadingRoomState by loadingRoomStateStateFlow.collectAsState() val networkStatus by networkMonitor.connectivity.collectAsState() LoadingRoomNodeView( state = loadingRoomState, hasNetworkConnection = networkStatus == NetworkStatus.Online, modifier = modifier, - onBackClicked = onBackClicked + onBackClick = onBackClick ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 142b658e5e..915001c919 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -77,7 +77,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( ), DaggerComponentOwner { interface Callback : Plugin { fun onOpenRoom(roomId: RoomId) - fun onPermalinkClicked(data: PermalinkData) + fun onPermalinkClick(data: PermalinkData) fun onForwardedToSingleRoom(roomId: RoomId) fun onOpenGlobalNotificationSettings() } @@ -144,16 +144,16 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( return when (navTarget) { is NavTarget.Messages -> { val callback = object : MessagesEntryPoint.Callback { - override fun onRoomDetailsClicked() { + override fun onRoomDetailsClick() { backstack.push(NavTarget.RoomDetails) } - override fun onUserDataClicked(userId: UserId) { + override fun onUserDataClick(userId: UserId) { backstack.push(NavTarget.RoomMemberDetails(userId)) } - override fun onPermalinkClicked(data: PermalinkData) { - callbacks.forEach { it.onPermalinkClicked(data) } + override fun onPermalinkClick(data: PermalinkData) { + callbacks.forEach { it.onPermalinkClick(data) } } override fun onForwardedToSingleRoom(roomId: RoomId) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt index 14fb955cb5..d85ccfa398 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun LoadingRoomNodeView( state: LoadingRoomState, hasNetworkConnection: Boolean, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -54,7 +54,7 @@ fun LoadingRoomNodeView( topBar = { Column { ConnectivityIndicatorView(isOnline = hasNetworkConnection) - LoadingRoomTopBar(onBackClicked) + LoadingRoomTopBar(onBackClick) } }, content = { padding -> @@ -83,11 +83,11 @@ fun LoadingRoomNodeView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LoadingRoomTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = { IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp) @@ -101,7 +101,7 @@ private fun LoadingRoomTopBar( internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview { LoadingRoomNodeView( state = state, - onBackClicked = {}, + onBackClick = {}, hasNetworkConnection = false ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt index dbc190354f..73c5b9f4b8 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomState.kt @@ -16,6 +16,7 @@ package io.element.android.appnav.room.joined +import androidx.compose.runtime.Immutable import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SingleIn @@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +@Immutable sealed interface LoadingRoomState { data object Loading : LoadingRoomState data object Error : LoadingRoomState diff --git a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt index 8ec673ceec..fe76113a95 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/root/RootPresenter.kt @@ -17,11 +17,16 @@ package io.element.android.appnav.root import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import im.vector.app.features.analytics.plan.SuperProperties import io.element.android.features.rageshake.api.crash.CrashDetectionPresenter import io.element.android.features.rageshake.api.detection.RageshakeDetectionPresenter +import io.element.android.features.share.api.ShareService import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.apperror.api.AppErrorStateService import javax.inject.Inject @@ -29,6 +34,9 @@ class RootPresenter @Inject constructor( private val crashDetectionPresenter: CrashDetectionPresenter, private val rageshakeDetectionPresenter: RageshakeDetectionPresenter, private val appErrorStateService: AppErrorStateService, + private val analyticsService: AnalyticsService, + private val shareService: ShareService, + private val sdkMetadata: SdkMetadata, ) : Presenter { @Composable override fun present(): RootState { @@ -36,6 +44,20 @@ class RootPresenter @Inject constructor( val crashDetectionState = crashDetectionPresenter.present() val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState() + LaunchedEffect(Unit) { + analyticsService.updateSuperProperties( + SuperProperties( + cryptoSDK = SuperProperties.CryptoSDK.Rust, + appPlatform = SuperProperties.AppPlatform.EXA, + cryptoSDKVersion = sdkMetadata.sdkGitSha, + ) + ) + } + + LaunchedEffect(Unit) { + shareService.observeFeatureFlag(this) + } + return RootState( rageshakeDetectionState = rageshakeDetectionState, crashDetectionState = crashDetectionState, diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt index 93e727da75..d307f851cd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt @@ -156,7 +156,7 @@ class JoinRoomLoadedFlowNodeTest { ) val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() // WHEN - fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() + fakeMessagesEntryPoint.callback?.onRoomDetailsClick() // THEN roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt index 0806937c1a..aeaeb10859 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/RootPresenterTest.kt @@ -28,11 +28,17 @@ import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.test.rageshake.FakeRageShake import io.element.android.features.rageshake.test.rageshake.FakeRageshakeDataStore import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.features.share.api.ShareService +import io.element.android.features.share.test.FakeShareService +import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.apperror.api.AppErrorState import io.element.android.services.apperror.api.AppErrorStateService import io.element.android.services.apperror.impl.DefaultAppErrorStateService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -53,6 +59,22 @@ class RootPresenterTest { } } + @Test + fun `present - check that share service is invoked`() = runTest { + val lambda = lambdaRecorder { _ -> } + val presenter = createRootPresenter( + shareService = FakeShareService { + lambda(it) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + lambda.assertions().isCalledOnce() + } + } + @Test fun `present - passes app error state`() = runTest { val presenter = createRootPresenter( @@ -77,7 +99,8 @@ class RootPresenterTest { } private fun createRootPresenter( - appErrorService: AppErrorStateService = DefaultAppErrorStateService() + appErrorService: AppErrorStateService = DefaultAppErrorStateService(), + shareService: ShareService = FakeShareService {}, ): RootPresenter { val crashDataStore = FakeCrashDataStore() val rageshakeDataStore = FakeRageshakeDataStore() @@ -99,6 +122,9 @@ class RootPresenterTest { crashDetectionPresenter = crashDetectionPresenter, rageshakeDetectionPresenter = rageshakeDetectionPresenter, appErrorStateService = appErrorService, + analyticsService = FakeAnalyticsService(), + shareService = shareService, + sdkMetadata = FakeSdkMetadata("sha") ) } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt index 6dce8a6310..4fb518b058 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt @@ -20,21 +20,21 @@ import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import kotlinx.coroutines.test.runTest import org.junit.Test class MatrixClientsHolderTest { @Test fun `test getOrNull`() { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull() } @Test fun `test getOrRestore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -47,7 +47,7 @@ class MatrixClientsHolderTest { @Test fun `test remove`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -60,7 +60,7 @@ class MatrixClientsHolderTest { @Test fun `test remove all`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -73,7 +73,7 @@ class MatrixClientsHolderTest { @Test fun `test save and restore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 074009cacc..aa0fe9cbf1 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith @@ -208,13 +209,33 @@ class IntentResolverTest { permalinkParserResult = { permalinkData } ) val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { - action = Intent.ACTION_SEND + action = Intent.ACTION_BATTERY_LOW data = "https://matrix.to/invalid".toUri() } val result = sut.resolve(intent) assertThat(result).isNull() } + @Test + fun `test incoming share simple`() { + val sut = createIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_SEND + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + } + + @Test + fun `test incoming share multiple`() { + val sut = createIntentResolver() + val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { + action = Intent.ACTION_SEND_MULTIPLE + } + val result = sut.resolve(intent) + assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent)) + } + @Test fun `test resolve invalid`() { val sut = createIntentResolver( @@ -229,7 +250,7 @@ class IntentResolverTest { } private fun createIntentResolver( - permalinkParserResult: () -> PermalinkData = { throw NotImplementedError() } + permalinkParserResult: () -> PermalinkData = { lambdaError() } ): IntentResolver { return IntentResolver( deeplinkParser = DeeplinkParser(), diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt similarity index 98% rename from appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt rename to appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt index cce8879d4f..55ddf465e3 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTests.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/AnalyticsVerificationStateMappingTest.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class AnalyticsVerificationStateMappingTests { +class AnalyticsVerificationStateMappingTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/build.gradle.kts b/build.gradle.kts index a0eedc8db7..ce822e3fa5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,7 +60,7 @@ allprojects { config.from(files("$rootDir/tools/detekt/detekt.yml")) } dependencies { - detektPlugins("io.nlopez.compose.rules:detekt:0.3.12") + detektPlugins("io.nlopez.compose.rules:detekt:0.4.4") } // KtLint diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index a2290619ae..73fe9e083a 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -63,15 +63,15 @@ fun AnalyticsOptInView( ) { val eventSink = state.eventSink - fun onTermsAccepted() { + fun onAcceptTerms() { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) } - fun onTermsDeclined() { + fun onDeclineTerms() { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) } - BackHandler(onBack = ::onTermsDeclined) + BackHandler(onBack = ::onDeclineTerms) HeaderFooterPage( modifier = modifier .fillMaxSize() @@ -82,8 +82,8 @@ fun AnalyticsOptInView( content = { AnalyticsOptInContent() }, footer = { AnalyticsOptInFooter( - onTermsAccepted = ::onTermsAccepted, - onTermsDeclined = ::onTermsDeclined, + onAcceptTerms = ::onAcceptTerms, + onDeclineTerms = ::onDeclineTerms, ) } ) @@ -165,19 +165,19 @@ private fun AnalyticsOptInContent() { @Composable private fun AnalyticsOptInFooter( - onTermsAccepted: () -> Unit, - onTermsDeclined: () -> Unit, + onAcceptTerms: () -> Unit, + onDeclineTerms: () -> Unit, ) { ButtonColumnMolecule { Button( text = stringResource(id = CommonStrings.action_ok), - onClick = onTermsAccepted, + onClick = onAcceptTerms, modifier = Modifier.fillMaxWidth(), ) TextButton( text = stringResource(id = CommonStrings.action_not_now), size = ButtonSize.Medium, - onClick = onTermsDeclined, + onClick = onDeclineTerms, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts index 1f8c9778fa..8281d84d20 100644 --- a/features/call/build.gradle.kts +++ b/features/call/build.gradle.kts @@ -35,7 +35,6 @@ anvil { } dependencies { - implementation(projects.appnav) implementation(projects.appconfig) implementation(projects.anvilannotations) implementation(projects.libraries.architecture) @@ -44,6 +43,7 @@ dependencies { implementation(projects.libraries.matrix.impl) implementation(projects.libraries.network) implementation(projects.libraries.preferences.api) + implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.webkit) implementation(libs.serialization.json) @@ -60,5 +60,6 @@ dependencies { testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.analytics.test) testImplementation(projects.tests.testutils) } diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt index 5f12ace0ac..10168eba2f 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt @@ -72,15 +72,10 @@ class CallForegroundService : Service() { startForeground(1, notification) } - @Suppress("DEPRECATION") override fun onDestroy() { super.onDestroy() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - stopForeground(true) - } + stopForeground(STOP_FOREGROUND_REMOVE) } override fun onBind(intent: Intent?): IBinder? { diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt index 1ec75ed47d..49f6352212 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenPresenter.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.CallType import io.element.android.features.call.data.WidgetMessage import io.element.android.features.call.utils.CallWidgetProvider @@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect @@ -61,6 +63,7 @@ class CallScreenPresenter @AssistedInject constructor( private val clock: SystemClock, private val dispatchers: CoroutineDispatchers, private val matrixClientsProvider: MatrixClientProvider, + private val screenTracker: ScreenTracker, private val appCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory @@ -83,6 +86,15 @@ class CallScreenPresenter @AssistedInject constructor( loadUrl(callType, urlState, callWidgetDriver) } + when (callType) { + is CallType.ExternalUrl -> { + // No analytics yet for external calls + } + is CallType.RoomCall -> { + screenTracker.TrackScreen(screen = MobileScreen.ScreenName.RoomCall) + } + } + HandleMatrixClientSyncState() callWidgetDriver.value?.let { driver -> diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt index c3f157a876..cc62ce03e1 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt @@ -81,12 +81,12 @@ internal fun CallScreenView( .fillMaxSize(), url = state.urlState, userAgent = state.userAgent, - onPermissionsRequested = { request -> + onPermissionsRequest = { request -> val androidPermissions = mapWebkitPermissions(request.resources) val callback: RequestPermissionCallback = { request.grant(it) } requestPermissions(androidPermissions.toTypedArray(), callback) }, - onWebViewCreated = { webView -> + onWebViewCreate = { webView -> val interceptor = WebViewWidgetMessageInterceptor(webView) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) } @@ -98,8 +98,8 @@ internal fun CallScreenView( private fun CallWebView( url: AsyncData, userAgent: String, - onPermissionsRequested: (PermissionRequest) -> Unit, - onWebViewCreated: (WebView) -> Unit, + onPermissionsRequest: (PermissionRequest) -> Unit, + onWebViewCreate: (WebView) -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -111,8 +111,8 @@ private fun CallWebView( modifier = modifier, factory = { context -> WebView(context).apply { - onWebViewCreated(this) - setup(userAgent, onPermissionsRequested) + onWebViewCreate(this) + setup(userAgent, onPermissionsRequest) } }, update = { webView -> diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt index 859c6a5820..ead787e1e7 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt @@ -31,6 +31,7 @@ import android.webkit.PermissionRequest import androidx.activity.compose.setContent import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -40,7 +41,6 @@ import androidx.core.content.IntentCompat import chat.schildi.lib.preferences.DefaultScPreferencesStore import chat.schildi.lib.preferences.LocalScPreferencesStore import chat.schildi.theme.ScTheme -import com.bumble.appyx.core.integrationpoint.NodeComponentActivity import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme import io.element.android.compound.theme.isDark @@ -53,7 +53,7 @@ import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.libraries.architecture.bindings import javax.inject.Inject -class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator { +class ElementCallActivity : AppCompatActivity(), CallScreenNavigator { companion object { private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS" @@ -125,13 +125,11 @@ class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - updateUiMode(newConfig) } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - setCallType(intent) } diff --git a/features/call/src/main/res/values-be/translations.xml b/features/call/src/main/res/values-be/translations.xml index 8355f3af9b..867c4850d3 100644 --- a/features/call/src/main/res/values-be/translations.xml +++ b/features/call/src/main/res/values-be/translations.xml @@ -1,6 +1,6 @@ - "Бягучы выклік" - "Націсніце, каб вярнуцца да выкліка" - "☎️ Выконваецца выклік" + "Бягучы званок" + "Націсніце, каб вярнуцца да званку" + "☎️ Ідзе званок" diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 8eecc94e78..30579ad99c 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.MobileScreen import io.element.android.features.call.CallType import io.element.android.features.call.utils.FakeCallWidgetProvider import io.element.android.features.call.utils.FakeWidgetMessageInterceptor @@ -30,11 +31,15 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider +import io.element.android.services.analytics.api.ScreenTracker +import io.element.android.services.analytics.test.FakeScreenTracker import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilTimeout +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -53,48 +58,60 @@ class CallScreenPresenterTest { @Test fun `present - with CallType ExternalUrl just loads the URL`() = runTest { - val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io")) + val analyticsLambda = lambdaRecorder { } + val presenter = createCallScreenPresenter( + callType = CallType.ExternalUrl("https://call.element.io"), + screenTracker = FakeScreenTracker(analyticsLambda) + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { // Wait until the URL is loaded skipItems(1) - val initialState = awaitItem() assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.isInWidgetMode).isFalse() + analyticsLambda.assertions().isNeverCalled() } } @Test fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val widgetProvider = FakeCallWidgetProvider(widgetDriver) + val analyticsLambda = lambdaRecorder { } val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, widgetProvider = widgetProvider, + screenTracker = FakeScreenTracker(analyticsLambda) ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { // Wait until the URL is loaded skipItems(1) - val initialState = awaitItem() assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.isInWidgetMode).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetDriver.runCalledCount).isEqualTo(1) + // Called several times because of the recomposition + analyticsLambda.assertions().isCalledExactly(2) + .withSequence( + listOf(value(MobileScreen.ScreenName.RoomCall)), + listOf(value(MobileScreen.ScreenName.RoomCall)) + ) } } @Test fun `present - set message interceptor, send and receive messages`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -119,12 +136,13 @@ class CallScreenPresenterTest { @Test fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -149,12 +167,13 @@ class CallScreenPresenterTest { @Test fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), + screenTracker = FakeScreenTracker {}, ) val messageInterceptor = FakeWidgetMessageInterceptor() moleculeFlow(RecompositionMode.Immediate) { @@ -178,14 +197,15 @@ class CallScreenPresenterTest { @Test fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), + screenTracker = FakeScreenTracker {}, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -201,14 +221,15 @@ class CallScreenPresenterTest { @Test fun `present - automatically stops the Matrix client sync on dispose`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, navigator = navigator, dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }), + screenTracker = FakeScreenTracker {}, ) val hasRun = Mutex(true) val job = launch { @@ -229,10 +250,11 @@ class CallScreenPresenterTest { private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), - widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), + screenTracker: ScreenTracker = FakeScreenTracker(), ): CallScreenPresenter { val userAgentProvider = object : UserAgentProvider { override fun provide(): String { @@ -241,14 +263,15 @@ class CallScreenPresenterTest { } val clock = SystemClock { 0 } return CallScreenPresenter( - callType, - navigator, - widgetProvider, - userAgentProvider, - clock, - dispatchers, - matrixClientsProvider, - this, + callType = callType, + navigator = navigator, + callWidgetProvider = widgetProvider, + userAgentProvider = userAgentProvider, + clock = clock, + dispatchers = dispatchers, + matrixClientsProvider = matrixClientsProvider, + screenTracker = screenTracker, + appCoroutineScope = this, ) } } diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index 368db19fcc..0b7e2ce953 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import kotlinx.coroutines.test.runTest import org.junit.Test @@ -76,7 +76,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - returns a widget driver when all steps are successful`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) @@ -89,7 +89,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - will use a custom base url if it exists`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt index 27e47ee708..c9e9ebb2ae 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -19,10 +19,10 @@ package io.element.android.features.call.utils 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.widget.MatrixWidgetDriver -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver class FakeCallWidgetProvider( - private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + private val widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), private val url: String = "https://call.element.io", ) : CallWidgetProvider { var getWidgetCalled = false diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt index f00f10905d..eba288859c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt @@ -47,8 +47,8 @@ class AddPeopleNode @AssistedInject constructor( AddPeopleView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, - onNextPressed = this::onContinue, + onBackClick = this::navigateUp, + onNextClick = this::onContinue, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index 1bfb680d00..93a71cda53 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -42,8 +42,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AddPeopleView( state: UserListState, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -51,14 +51,14 @@ fun AddPeopleView( topBar = { AddPeopleViewTopBar( hasSelectedUsers = state.selectedUsers.isNotEmpty(), - onBackPressed = { + onBackClick = { if (state.isSearchActive) { state.eventSink(UserListEvents.OnSearchActiveChanged(false)) } else { - onBackPressed() + onBackClick() } }, - onNextPressed = onNextPressed, + onNextClick = onNextClick, ) } ) { padding -> @@ -69,8 +69,8 @@ fun AddPeopleView( .consumeWindowInsets(padding), state = state, showBackButton = false, - onUserSelected = {}, - onUserDeselected = {}, + onSelectUser = {}, + onDeselectUser = {}, ) } } @@ -79,8 +79,8 @@ fun AddPeopleView( @Composable private fun AddPeopleViewTopBar( hasSelectedUsers: Boolean, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, ) { TopAppBar( title = { @@ -89,12 +89,12 @@ private fun AddPeopleViewTopBar( style = ElementTheme.typography.aliasScreenTitle ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip TextButton( text = stringResource(id = textActionResId), - onClick = onNextPressed, + onClick = onNextClick, ) } ) @@ -105,7 +105,7 @@ private fun AddPeopleViewTopBar( internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview { AddPeopleView( state = state, - onBackPressed = {}, - onNextPressed = {}, + onBackClick = {}, + onNextClick = {}, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt index 1bfd1567c2..f204aa6259 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt @@ -41,7 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun RoomPrivacyOption( roomPrivacyItem: RoomPrivacyItem, - onOptionSelected: (RoomPrivacyItem) -> Unit, + onOptionClick: (RoomPrivacyItem) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, ) { @@ -50,7 +50,7 @@ fun RoomPrivacyOption( .fillMaxWidth() .selectable( selected = isSelected, - onClick = { onOptionSelected(roomPrivacyItem) }, + onClick = { onOptionClick(roomPrivacyItem) }, role = Role.RadioButton, ) .padding(8.dp), @@ -98,12 +98,12 @@ internal fun RoomPrivacyOptionPreview() = ElementPreview { Column { RoomPrivacyOption( roomPrivacyItem = aRoomPrivacyItem, - onOptionSelected = {}, + onOptionClick = {}, isSelected = true, ) RoomPrivacyOption( roomPrivacyItem = aRoomPrivacyItem, - onOptionSelected = {}, + onOptionClick = {}, isSelected = false, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt index 415ca054ff..0aafd515f8 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt @@ -53,11 +53,11 @@ fun SearchUserBar( showLoader: Boolean, selectedUsers: ImmutableList, active: Boolean, - isMultiSelectionEnabled: Boolean, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserSelected: (MatrixUser) -> Unit, - onUserDeselected: (MatrixUser) -> Unit, + isMultiSelectionEnable: Boolean, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onUserSelect: (MatrixUser) -> Unit, + onUserDeselect: (MatrixUser) -> Unit, modifier: Modifier = Modifier, showBackButton: Boolean = true, placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), @@ -66,14 +66,14 @@ fun SearchUserBar( SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, showBackButton = showBackButton, contentPrefix = { - if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { + if (isMultiSelectionEnable && active && selectedUsers.isNotEmpty()) { // We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour // should change to indicate elevation. @@ -96,7 +96,7 @@ fun SearchUserBar( contentPadding = PaddingValues(16.dp), selectedUsers = selectedUsers, autoScroll = true, - onUserRemoved = onUserDeselected, + onUserRemove = onUserDeselect, modifier = Modifier.background(appBarContainerColor) ) } @@ -109,7 +109,7 @@ fun SearchUserBar( resultState = state, resultHandler = { users -> LazyColumn(state = columnState) { - if (isMultiSelectionEnabled) { + if (isMultiSelectionEnable) { itemsIndexed(users) { index, searchResult -> SearchMultipleUsersResultItem( modifier = Modifier.fillMaxWidth(), @@ -117,9 +117,9 @@ fun SearchUserBar( isUserSelected = selectedUsers.contains(searchResult.matrixUser), onCheckedChange = { checked -> if (checked) { - onUserSelected(searchResult.matrixUser) + onUserSelect(searchResult.matrixUser) } else { - onUserDeselected(searchResult.matrixUser) + onUserDeselect(searchResult.matrixUser) } } ) @@ -132,7 +132,7 @@ fun SearchUserBar( SearchSingleUserResultItem( modifier = Modifier.fillMaxWidth(), searchResult = searchResult, - onClick = { onUserSelected(searchResult.matrixUser) } + onClick = { onUserSelect(searchResult.matrixUser) } ) if (index < users.lastIndex) { HorizontalDivider() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt index 0e1c448015..7eead66276 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt @@ -44,8 +44,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun UserListView( state: UserListState, - onUserSelected: (MatrixUser) -> Unit, - onUserDeselected: (MatrixUser) -> Unit, + onSelectUser: (MatrixUser) -> Unit, + onDeselectUser: (MatrixUser) -> Unit, modifier: Modifier = Modifier, showBackButton: Boolean = true, ) { @@ -59,17 +59,17 @@ fun UserListView( selectedUsers = state.selectedUsers, active = state.isSearchActive, showLoader = state.showSearchLoader, - isMultiSelectionEnabled = state.isMultiSelectionEnabled, + isMultiSelectionEnable = state.isMultiSelectionEnabled, showBackButton = showBackButton, - onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, - onUserSelected = { + onActiveChange = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, + onUserSelect = { state.eventSink(UserListEvents.AddToSelection(it)) - onUserSelected(it) + onSelectUser(it) }, - onUserDeselected = { + onUserDeselect = { state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) + onDeselectUser(it) }, ) @@ -78,9 +78,9 @@ fun UserListView( contentPadding = PaddingValues(16.dp), selectedUsers = state.selectedUsers, autoScroll = true, - onUserRemoved = { + onUserRemove = { state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) + onDeselectUser(it) }, ) } @@ -102,10 +102,10 @@ fun UserListView( onCheckedChange = { if (isSelected) { state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser)) - onUserDeselected(recentDirectRoom.matrixUser) + onDeselectUser(recentDirectRoom.matrixUser) } else { state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser)) - onUserSelected(recentDirectRoom.matrixUser) + onSelectUser(recentDirectRoom.matrixUser) } }, data = CheckableUserRowData.Resolved( @@ -129,7 +129,7 @@ fun UserListView( internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview { UserListView( state = state, - onUserSelected = {}, - onUserDeselected = {}, + onSelectUser = {}, + onDeselectUser = {}, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt index 1d3499f743..3d8f96b9d6 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -50,7 +50,7 @@ class ConfigureRoomNode @AssistedInject constructor( fun onCreateRoomSuccess(roomId: RoomId) } - private fun onRoomCreated(roomId: RoomId) { + private fun onCreateRoomSuccess(roomId: RoomId) { plugins().forEach { it.onCreateRoomSuccess(roomId) } } @@ -60,8 +60,8 @@ class ConfigureRoomNode @AssistedInject constructor( ConfigureRoomView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, - onRoomCreated = this::onRoomCreated, + onBackClick = this::navigateUp, + onCreateRoomSuccess = this::onCreateRoomSuccess, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index ad9bb7bfdf..73441f9d16 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -65,14 +65,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ConfigureRoomView( state: ConfigureRoomState, - onBackPressed: () -> Unit, - onRoomCreated: (RoomId) -> Unit, + onBackClick: () -> Unit, + onCreateRoomSuccess: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() isAvatarActionsSheetVisible.value = true } @@ -82,8 +82,8 @@ fun ConfigureRoomView( topBar = { ConfigureRoomToolbar( isNextActionEnabled = state.isCreateButtonEnabled, - onBackPressed = onBackPressed, - onNextPressed = { + onBackClick = onBackClick, + onNextClick = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, @@ -102,20 +102,20 @@ fun ConfigureRoomView( modifier = Modifier.padding(horizontal = 16.dp), avatarUri = state.config.avatarUri, roomName = state.config.roomName.orEmpty(), - onAvatarClick = ::onAvatarClicked, - onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, + onAvatarClick = ::onAvatarClick, + onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, ) RoomTopic( modifier = Modifier.padding(horizontal = 16.dp), topic = state.config.topic.orEmpty(), - onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, + onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, ) if (state.config.invites.isNotEmpty()) { SelectedUsersRowList( modifier = Modifier.padding(bottom = 16.dp), contentPadding = PaddingValues(horizontal = 24.dp), selectedUsers = state.config.invites, - onUserRemoved = { + onUserRemove = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) }, @@ -124,7 +124,7 @@ fun ConfigureRoomView( RoomPrivacyOptions( modifier = Modifier.padding(bottom = 40.dp), selected = state.config.privacy, - onOptionSelected = { + onOptionClick = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) }, @@ -136,7 +136,7 @@ fun ConfigureRoomView( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, - onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } + onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -146,7 +146,7 @@ fun ConfigureRoomView( progressText = stringResource(CommonStrings.common_creating_room), ) }, - onSuccess = { onRoomCreated(it) }, + onSuccess = { onCreateRoomSuccess(it) }, errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, @@ -161,8 +161,8 @@ fun ConfigureRoomView( @Composable private fun ConfigureRoomToolbar( isNextActionEnabled: Boolean, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, ) { TopAppBar( title = { @@ -171,12 +171,12 @@ private fun ConfigureRoomToolbar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_create), enabled = isNextActionEnabled, - onClick = onNextPressed, + onClick = onNextClick, ) } ) @@ -187,7 +187,7 @@ private fun RoomNameWithAvatar( avatarUri: Uri?, roomName: String, onAvatarClick: () -> Unit, - onRoomNameChanged: (String) -> Unit, + onChangeRoomName: (String) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -205,7 +205,7 @@ private fun RoomNameWithAvatar( value = roomName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, - onValueChange = onRoomNameChanged, + onValueChange = onChangeRoomName, ) } } @@ -213,7 +213,7 @@ private fun RoomNameWithAvatar( @Composable private fun RoomTopic( topic: String, - onTopicChanged: (String) -> Unit, + onTopicChange: (String) -> Unit, modifier: Modifier = Modifier, ) { LabelledTextField( @@ -221,7 +221,7 @@ private fun RoomTopic( label = stringResource(R.string.screen_create_room_topic_label), value = topic, placeholder = stringResource(CommonStrings.common_topic_placeholder), - onValueChange = onTopicChanged, + onValueChange = onTopicChange, maxLines = 3, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, @@ -232,7 +232,7 @@ private fun RoomTopic( @Composable private fun RoomPrivacyOptions( selected: RoomPrivacy?, - onOptionSelected: (RoomPrivacyItem) -> Unit, + onOptionClick: (RoomPrivacyItem) -> Unit, modifier: Modifier = Modifier, ) { val items = roomPrivacyItems() @@ -241,7 +241,7 @@ private fun RoomPrivacyOptions( RoomPrivacyOption( roomPrivacyItem = item, isSelected = selected == item.privacy, - onOptionSelected = onOptionSelected, + onOptionClick = onOptionClick, ) } } @@ -252,7 +252,7 @@ private fun RoomPrivacyOptions( internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { ConfigureRoomView( state = state, - onBackPressed = {}, - onRoomCreated = {}, + onBackClick = {}, + onCreateRoomSuccess = {}, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index 14b0061f53..745fb3377e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -68,10 +68,10 @@ class CreateRoomRootNode @AssistedInject constructor( CreateRoomRootView( state = state, modifier = modifier, - onClosePressed = this::navigateUp, - onNewRoomClicked = ::onCreateNewRoom, + onCloseClick = this::navigateUp, + onNewRoomClick = ::onCreateNewRoom, onOpenDM = ::onStartChatSuccess, - onInviteFriendsClicked = { invitePeople(activity) } + onInviteFriendsClick = { invitePeople(activity) } ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 33707896fa..015483ff7f 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -59,17 +59,17 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun CreateRoomRootView( state: CreateRoomRootState, - onClosePressed: () -> Unit, - onNewRoomClicked: () -> Unit, + onCloseClick: () -> Unit, + onNewRoomClick: () -> Unit, onOpenDM: (RoomId) -> Unit, - onInviteFriendsClicked: () -> Unit, + onInviteFriendsClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier.fillMaxWidth(), topBar = { if (!state.userListState.isSearchActive) { - CreateRoomRootViewTopBar(onClosePressed = onClosePressed) + CreateRoomRootViewTopBar(onCloseClick = onCloseClick) } } ) { paddingValues -> @@ -86,18 +86,18 @@ fun CreateRoomRootView( state = state.userListState.copy( recentDirectRooms = persistentListOf(), ), - onUserSelected = { + onSelectUser = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }, - onUserDeselected = { }, + onDeselectUser = { }, ) if (!state.userListState.isSearchActive) { CreateRoomActionButtonsList( state = state, - onNewRoomClicked = onNewRoomClicked, - onInvitePeopleClicked = onInviteFriendsClicked, - onDmClicked = onOpenDM, + onNewRoomClick = onNewRoomClick, + onInvitePeopleClick = onInviteFriendsClick, + onDmClick = onOpenDM, ) } } @@ -125,7 +125,7 @@ fun CreateRoomRootView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreateRoomRootViewTopBar( - onClosePressed: () -> Unit, + onCloseClick: () -> Unit, ) { TopAppBar( title = { @@ -137,7 +137,7 @@ private fun CreateRoomRootViewTopBar( navigationIcon = { BackButton( imageVector = CompoundIcons.Close(), - onClick = onClosePressed, + onClick = onCloseClick, ) } ) @@ -146,23 +146,23 @@ private fun CreateRoomRootViewTopBar( @Composable private fun CreateRoomActionButtonsList( state: CreateRoomRootState, - onNewRoomClicked: () -> Unit, - onInvitePeopleClicked: () -> Unit, - onDmClicked: (RoomId) -> Unit, + onNewRoomClick: () -> Unit, + onInvitePeopleClick: () -> Unit, + onDmClick: (RoomId) -> Unit, ) { LazyColumn { item { CreateRoomActionButton( iconRes = CompoundDrawables.ic_compound_plus, text = stringResource(id = R.string.screen_create_room_action_create_room), - onClick = onNewRoomClicked, + onClick = onNewRoomClick, ) } item { CreateRoomActionButton( iconRes = CompoundDrawables.ic_compound_share_android, text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), - onClick = onInvitePeopleClicked, + onClick = onInvitePeopleClick, ) } if (state.userListState.recentDirectRooms.isNotEmpty()) { @@ -177,7 +177,7 @@ private fun CreateRoomActionButtonsList( MatrixUserRow( modifier = Modifier.clickable( onClick = { - onDmClicked(recentDirectRoom.roomId) + onDmClick(recentDirectRoom.roomId) } ), matrixUser = recentDirectRoom.matrixUser, @@ -222,9 +222,9 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv ElementPreview { CreateRoomRootView( state = state, - onClosePressed = {}, - onNewRoomClicked = {}, + onCloseClick = {}, + onNewRoomClick = {}, onOpenDM = {}, - onInviteFriendsClicked = {}, + onInviteFriendsClick = {}, ) } diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml index 1ab27b86e4..2415fe6bb6 100644 --- a/features/createroom/impl/src/main/res/values-be/translations.xml +++ b/features/createroom/impl/src/main/res/values-be/translations.xml @@ -1,7 +1,7 @@ "Новы пакой" - "Запрасіць карыстальникаў" + "Запрасіць карыстальнікаў" "Пры стварэнні пакоя адбылася памылка" "Паведамленні ў гэтым пакоі зашыфраваны. Гэта шыфраванне нельга адключыць." "Прыватны пакой (толькі па запрашэнні)" diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt similarity index 99% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTests.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt index 1285cf37b6..0edc58959a 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultStartDMActionTest.kt @@ -31,7 +31,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.runTest import org.junit.Test -class DefaultStartDMActionTests { +class DefaultStartDMActionTest { @Test fun `when dm is found, assert state is updated with given room id`() = runTest { val matrixClient = FakeMatrixClient().apply { diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTest.kt similarity index 98% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTest.kt index 0fa04f685f..092d8d93e3 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenterTest.kt @@ -30,7 +30,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -class AddPeoplePresenterTests { +class AddPeoplePresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt index 36741347e5..da9f1594e5 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt @@ -47,7 +47,7 @@ class AddPeopleViewTest { aUserListState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -75,7 +75,7 @@ class AddPeopleViewTest { aUserListState( eventSink = eventsRecorder, ), - onNextPressed = it + onNextClick = it ) rule.clickOn(CommonStrings.action_skip) } @@ -85,14 +85,14 @@ class AddPeopleViewTest { private fun AndroidComposeTestRule.setAddPeopleView( state: UserListState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onNextPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onNextClick: () -> Unit = EnsureNeverCalled(), ) { setContent { AddPeopleView( state = state, - onBackPressed = onBackPressed, - onNextPressed = onNextPressed, + onBackClick = onBackClick, + onNextClick = onNextClick, ) } } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt similarity index 99% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt index 6baf998fcf..e3f73cf73a 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTest.kt @@ -61,7 +61,7 @@ private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2" private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" @RunWith(RobolectricTestRunner::class) -class ConfigureRoomPresenterTests { +class ConfigureRoomPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt similarity index 99% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt index 2c48300d3c..a6328cdc92 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenterTest.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class CreateRoomRootPresenterTests { +class CreateRoomRootPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt index dcb2e02347..bdcb524e33 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt @@ -54,7 +54,7 @@ class CreateRoomRootViewTest { aCreateRoomRootState( eventSink = eventsRecorder, ), - onClosePressed = it + onCloseClick = it ) rule.pressBack() } @@ -68,7 +68,7 @@ class CreateRoomRootViewTest { aCreateRoomRootState( eventSink = eventsRecorder, ), - onNewRoomClicked = it + onNewRoomClick = it ) rule.clickOn(R.string.screen_create_room_action_create_room) } @@ -84,7 +84,7 @@ class CreateRoomRootViewTest { applicationName = "test", eventSink = eventsRecorder, ), - onInviteFriendsClicked = it + onInviteFriendsClick = it ) val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test") rule.onNodeWithText(text).performClick() @@ -114,18 +114,18 @@ class CreateRoomRootViewTest { private fun AndroidComposeTestRule.setCreateRoomRootView( state: CreateRoomRootState, - onClosePressed: () -> Unit = EnsureNeverCalled(), - onNewRoomClicked: () -> Unit = EnsureNeverCalled(), + onCloseClick: () -> Unit = EnsureNeverCalled(), + onNewRoomClick: () -> Unit = EnsureNeverCalled(), onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onInviteFriendsClicked: () -> Unit = EnsureNeverCalled(), + onInviteFriendsClick: () -> Unit = EnsureNeverCalled(), ) { setContent { CreateRoomRootView( state = state, - onClosePressed = onClosePressed, - onNewRoomClicked = onNewRoomClicked, + onCloseClick = onCloseClick, + onNewRoomClick = onNewRoomClick, onOpenDM = onOpenDM, - onInviteFriendsClicked = onInviteFriendsClicked, + onInviteFriendsClick = onInviteFriendsClick, ) } } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTest.kt similarity index 99% rename from features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt rename to features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTest.kt index c4d6d9ab00..0786d02446 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/userlist/DefaultUserListPresenterTest.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class DefaultUserListPresenterTests { +class DefaultUserListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt index 2f2d838269..68c2bc6410 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt @@ -34,18 +34,18 @@ class WelcomeNode @AssistedInject constructor( private val buildMeta: BuildMeta, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun onContinueClicked() + fun onContinueClick() } - private fun onContinueClicked() { - plugins.filterIsInstance().forEach { it.onContinueClicked() } + private fun onContinueClick() { + plugins.filterIsInstance().forEach { it.onContinueClick() } } @Composable override fun View(modifier: Modifier) { WelcomeView( applicationName = buildMeta.applicationName, - onContinueClicked = ::onContinueClicked, + onContinueClick = ::onContinueClick, modifier = modifier ) } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt index 50af73903e..c491b7d4a1 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -52,9 +52,9 @@ import kotlinx.collections.immutable.persistentListOf fun WelcomeView( applicationName: String, modifier: Modifier = Modifier, - onContinueClicked: () -> Unit, + onContinueClick: () -> Unit, ) { - BackHandler(onBack = onContinueClicked) + BackHandler(onBack = onContinueClick) OnBoardingPage( modifier = modifier .systemBarsPadding() @@ -90,7 +90,7 @@ fun WelcomeView( Button( text = stringResource(CommonStrings.action_continue), modifier = Modifier.fillMaxWidth(), - onClick = onContinueClicked + onClick = onContinueClick ) Spacer(modifier = Modifier.height(32.dp)) } @@ -113,6 +113,6 @@ private fun listItems() = persistentListOf( @Composable internal fun WelcomeViewPreview() { ElementPreview { - WelcomeView(applicationName = "Element X", onContinueClicked = {}) + WelcomeView(applicationName = "Element X", onContinueClick = {}) } } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt similarity index 87% rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt index a8393d48ad..331353a8a0 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/SharedPreferencesWelcomeScreenState.kt @@ -20,15 +20,14 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.SingleIn import javax.inject.Inject @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) -class AndroidWelcomeScreenState @Inject constructor( - @DefaultPreferences private val sharedPreferences: SharedPreferences, -) : WelcomeScreenState { +class SharedPreferencesWelcomeScreenState @Inject constructor( + private val sharedPreferences: SharedPreferences, +) : WelcomeScreenStore { companion object { private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown" } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenStore.kt similarity index 96% rename from features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt rename to features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenStore.kt index d2be17fcbb..a7a5b26a4c 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenStore.kt @@ -16,7 +16,7 @@ package io.element.android.features.ftue.impl.welcome.state -interface WelcomeScreenState { +interface WelcomeScreenStore { fun isWelcomeScreenNeeded(): Boolean fun setWelcomeScreenShown() fun reset() diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml index 8496f9f631..413bad9132 100644 --- a/features/ftue/impl/src/main/res/values-be/translations.xml +++ b/features/ftue/impl/src/main/res/values-be/translations.xml @@ -2,34 +2,6 @@ "Вы можаце змяніць налады пазней." "Дазвольце апавяшчэнні і ніколі не прапускайце іх" - "Ўсталяванне бяспечнага злучэння" - "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх." - "Што зараз?" - "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема" - "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi." - "Калі гэта не дапамагло, увайдзіце ўручную" - "Злучэнне небяспечнае" - "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе." - "Увядзіце наступны нумар на іншай прыладзе." - "Гатовы да сканавання" - "Адкрыйце %1$s на настольнай прыладзе" - "Націсніце на свой аватар" - "Выберыце %1$s" - "“Звязаць новую прыладу”" - "Адсканіруйце QR-код з дапамогай гэтай прылады" - "Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код" - "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе." - "Паўтарыць спробу" - "Няправільны QR-код" - "Перайсці ў налады камеры" - "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады." - "Дазвольце доступ да камеры для сканавання QR-кода" - "Сканаваць QR-код" - "Пачаць спачатку" - "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз." - "У чаканні іншай прылады" - "Ваш правайдэр уліковага запісу можа запытаць наступны код для праверкі ўваходу." - "Ваш код спраўджання" "Званкі, апытанні, пошук і многае іншае будзе дададзена пазней у гэтым годзе." "Гісторыя паведамленняў для зашыфраваных пакояў пакуль недаступна." "Мы будзем рады пачуць вашае меркаванне, паведаміце нам аб гэтым праз старонку налад." diff --git a/features/ftue/impl/src/main/res/values-bg/translations.xml b/features/ftue/impl/src/main/res/values-bg/translations.xml index d762eecfdd..22370ab1c5 100644 --- a/features/ftue/impl/src/main/res/values-bg/translations.xml +++ b/features/ftue/impl/src/main/res/values-bg/translations.xml @@ -2,7 +2,6 @@ "Можете да промените настройките си по-късно." "Разрешете известията и никога не пропускайте съобщение" - "Повторен опит" "Хронологията на съобщенията за шифровани стаи все още не е налична." "Добре дошли в %1$s!" diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml index e27eeb7404..bc262cf2b4 100644 --- a/features/ftue/impl/src/main/res/values-cs/translations.xml +++ b/features/ftue/impl/src/main/res/values-cs/translations.xml @@ -2,34 +2,6 @@ "Nastavení můžete později změnit." "Povolte oznámení a nezmeškejte žádnou zprávu" - "Navazování zabezpečeného spojení" - "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat." - "Co teď?" - "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí" - "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi" - "Pokud to nefunguje, přihlaste se ručně" - "Připojení není zabezpečené" - "Budete požádáni o zadání dvou níže uvedených číslic." - "Zadejte níže uvedené číslo na svém dalším zařízení" - "Připraveno ke skenování" - "Otevřete %1$s na stolním počítači" - "Klikněte na svůj avatar" - "Vybrat %1$s" - "\"Připojit nové zařízení\"" - "Naskenujte QR kód pomocí tohoto zařízení" - "Otevřete %1$s na jiném zařízení pro získání QR kódu" - "Použijte QR kód zobrazený na druhém zařízení." - "Zkusit znovu" - "Špatný QR kód" - "Přejděte na nastavení fotoaparátu" - "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení." - "Povolte přístup k fotoaparátu a naskenujte QR kód" - "Naskenujte QR kód" - "Začít znovu" - "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu." - "Čekání na vaše další zařízení" - "Váš poskytovatel účtu může požádat o následující kód pro ověření přihlášení." - "Váš ověřovací kód" "Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku." "Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici." "Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením." diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml index 41fe886ee6..151500adda 100644 --- a/features/ftue/impl/src/main/res/values-de/translations.xml +++ b/features/ftue/impl/src/main/res/values-de/translations.xml @@ -2,34 +2,6 @@ "Du kannst deine Einstellungen später ändern." "Erlaube Benachrichtigungen und verpasse keine Nachricht" - "Sichere Verbindung aufbauen" - "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden." - "Und jetzt?" - "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war." - "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN." - "Wenn das nicht funktioniert, melde dich manuell an" - "Die Verbindung ist nicht sicher" - "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben." - "Trage die unten angezeigte Zahl auf einem anderen Device ein" - "Bereit zum Scannen" - "%1$s auf einem Desktop-Gerät öffnen" - "Klick auf deinen Avatar" - "Wähle %1$s" - "\"Neues Gerät verknüpfen\"" - "Scanne den QR-Code mit diesem Gerät" - "Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten" - "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird." - "Erneut versuchen" - "Falscher QR-Code" - "Gehe zu den Kameraeinstellungen" - "Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren." - "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes" - "QR-Code scannen" - "Neu beginnen" - "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut." - "Warten auf dein anderes Gerät" - "Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen." - "Dein Verifizierungscode" "Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt." "Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein." "Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit." diff --git a/features/ftue/impl/src/main/res/values-es/translations.xml b/features/ftue/impl/src/main/res/values-es/translations.xml index 8f366558d1..6a3224c531 100644 --- a/features/ftue/impl/src/main/res/values-es/translations.xml +++ b/features/ftue/impl/src/main/res/values-es/translations.xml @@ -2,7 +2,6 @@ "Puedes cambiar la configuración más tarde." "Activa las notificaciones y nunca te pierdas un mensaje" - "Inténtalo de nuevo" "Las llamadas, las encuestas, la búsqueda y más se agregarán más adelante este año." "El historial de mensajes de las salas cifradas aún no está disponible." "Nos encantaría saber de ti, haznos saber lo que piensas a través de la página de configuración." diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml index c69b729fc7..9fc11413cb 100644 --- a/features/ftue/impl/src/main/res/values-fr/translations.xml +++ b/features/ftue/impl/src/main/res/values-fr/translations.xml @@ -2,33 +2,6 @@ "Vous pourrez modifier vos paramètres ultérieurement." "Autorisez les notifications et ne manquez aucun message" - "Établissement d’une connexion sécurisée" - "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier." - "Et maintenant ?" - "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau" - "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi" - "Si cela ne fonctionne pas, connectez-vous manuellement" - "La connexion n’est pas sécurisée" - "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil." - "Saisissez le nombre ci-dessous sur votre autre appareil" - "Ouvrez %1$s sur un ordinateur" - "Cliquez sur votre image de profil" - "Choisissez %1$s" - "“Associer une nouvelle session”" - "Suivez les instructions affichées" - "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" - "Scannez le QR code affiché sur l’autre appareil." - "Essayer à nouveau" - "QR code erroné" - "Accéder aux paramètres de l’appareil photo" - "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer." - "Autoriser l’usage de la caméra pour scanner le code QR" - "Scannez le QR code" - "Recommencer" - "Une erreur inattendue s’est produite. Veuillez réessayer." - "En attente de votre autre session" - "Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion." - "Votre code de vérification" "Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année." "L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour." "N’hésitez pas à nous faire part de vos commentaires via l’écran des paramètres." diff --git a/features/ftue/impl/src/main/res/values-hu/translations.xml b/features/ftue/impl/src/main/res/values-hu/translations.xml index 0d463d2900..6a7acab0a4 100644 --- a/features/ftue/impl/src/main/res/values-hu/translations.xml +++ b/features/ftue/impl/src/main/res/values-hu/translations.xml @@ -2,34 +2,6 @@ "A beállításokat később is módosíthatja." "Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem" - "Biztonságos kapcsolat létesítése" - "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk." - "Most mi lesz?" - "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt." - "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát" - "Ha ez nem működik, jelentkezzen be kézileg" - "A kapcsolat nem biztonságos" - "A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén." - "Adja meg az alábbi számot a másik eszközén" - "Készen áll a beolvasásra" - "Nyissa meg az %1$set egy asztali eszközön" - "Kattintson a profilképére" - "Válassza ezt: %1$s" - "„Új eszköz összekapcsolása”" - "Olvassa be a QR-kódot ezzel az eszközzel" - "Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez." - "Használja a másik eszközön látható QR-kódot." - "Próbálja újra" - "Hibás QR-kód" - "Ugrás a kamerabeállításokhoz" - "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját." - "Engedélyezze a kamera elérését a QR-kód beolvasásához" - "Olvassa be a QR-kódot" - "Újrakezdés" - "Váratlan hiba történt. Próbálja meg újra." - "Várakozás a másik eszközre" - "A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez." - "Az Ön ellenőrzőkódja" "A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra." "A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben." "Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon." diff --git a/features/ftue/impl/src/main/res/values-in/translations.xml b/features/ftue/impl/src/main/res/values-in/translations.xml index c99839166b..1004f8a643 100644 --- a/features/ftue/impl/src/main/res/values-in/translations.xml +++ b/features/ftue/impl/src/main/res/values-in/translations.xml @@ -2,32 +2,6 @@ "Anda dapat mengubah pengaturan Anda nanti." "Izinkan pemberitahuan dan jangan pernah melewatkan pesan" - "Membuat koneksi" - "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka." - "Apa sekarang?" - "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan" - "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi" - "Jika tidak berhasil, masuk secara manual" - "Koneksi tidak aman" - "Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini." - "Masukkan nomor di perangkat Anda" - "Buka %1$s di perangkat desktop" - "Klik pada avatar Anda" - "Pilih %1$s" - "“Tautkan perangkat baru”" - "Buka %1$s di perangkat lain untuk mendapatkan kode QR" - "Gunakan kode QR yang ditampilkan di perangkat lain." - "Coba lagi" - "Kode QR salah" - "Pergi ke pengaturan kamera" - "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan." - "Izinkan akses kamera untuk memindai kode QR" - "Pindai kode QR" - "Mulai dari awal" - "Terjadi kesalahan tak terduga. Silakan coba lagi." - "Menunggu perangkat Anda yang lain" - "Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk." - "Kode verifikasi Anda" "Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini." "Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini." "Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan." diff --git a/features/ftue/impl/src/main/res/values-it/translations.xml b/features/ftue/impl/src/main/res/values-it/translations.xml index cb81d4d97a..eaff5216dc 100644 --- a/features/ftue/impl/src/main/res/values-it/translations.xml +++ b/features/ftue/impl/src/main/res/values-it/translations.xml @@ -2,33 +2,6 @@ "Potrai modificare le tue impostazioni in seguito." "Consenti le notifiche e non perdere mai un messaggio" - "Stabilendo la connessione" - "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro." - "E adesso?" - "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete." - "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi." - "Se il problema persiste, accedi manualmente" - "La connessione non è sicura" - "Ti verrà chiesto di inserire le due cifre mostrate su questo dispositivo." - "Inserisci il numero qui sotto sull\'altro dispositivo" - "Apri %1$s su un dispositivo desktop" - "Clicca sul tuo avatar" - "Seleziona %1$s" - "\"Collega un nuovo dispositivo\"" - "Segui le istruzioni mostrate" - "Apri %1$s su un altro dispositivo per ottenere il codice QR" - "Usa il codice QR mostrato sull\'altro dispositivo." - "Riprova" - "Codice QR sbagliato" - "Vai alle impostazioni della fotocamera" - "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo." - "Consenti l\'accesso alla fotocamera per la scansione del codice QR" - "Scansiona il codice QR" - "Ricomincia" - "Si è verificato un errore inatteso. Riprova." - "In attesa dell\'altro dispositivo" - "Il fornitore dell\'account potrebbe richiedere il seguente codice per verificare l\'accesso." - "Il tuo codice di verifica" "Chiamate, sondaggi, ricerche e altro ancora saranno aggiunti nel corso dell\'anno." "La cronologia dei messaggi per le stanze crittografate non è ancora disponibile." "Ci piacerebbe sentire il tuo parere, facci sapere cosa ne pensi tramite la pagina delle impostazioni." diff --git a/features/ftue/impl/src/main/res/values-pt/translations.xml b/features/ftue/impl/src/main/res/values-pt/translations.xml index 5b2660f6c6..8ff79c3b64 100644 --- a/features/ftue/impl/src/main/res/values-pt/translations.xml +++ b/features/ftue/impl/src/main/res/values-pt/translations.xml @@ -2,34 +2,6 @@ "Podes alterar as tuas definições mais tarde." "Permite as notificações e nunca percas uma mensagem" - "A estabelecer uma ligação segura" - "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles." - "E agora?" - "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede" - "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis." - "Se isso não funcionar, inicia sessão manualmente" - "Ligação insegura" - "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo." - "Insere o número abaixo no teu dispositivo" - "Pronto para ler" - "Abre a %1$s num computador" - "Carrega no teu avatar" - "Seleciona %1$s" - "“Ligar novo dispositivo”" - "Lê o código QR com este dispositivo" - "Abre a %1$s noutro dispositivo para obteres o código QR" - "Lê o código QR apresentado no outro dispositivo." - "Tentar novamente" - "Código QR inválido" - "Ir para as configurações da câmara" - "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo." - "Permitir o acesso à câmara para ler o código QR" - "Ler o código QR" - "Começar de novo" - "Ocorreu um erro inesperado. Tenta novamente." - "À espera do teu outro dispositivo" - "O teu fornecedor de conta pode pedir o seguinte código para verificar o início de sessão." - "O teu código de verificação" "Chamadas, sondagens, pesquisa e mais funcionalidades vão ser adicionadas ao longo do ano." "O histórico de mensagens em salas cifradas ainda não está disponível." "Gostaríamos de ouvir a tua opinião, diz-nos o que pensas através da página de configurações." diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml index 58482738d6..b89537903d 100644 --- a/features/ftue/impl/src/main/res/values-ro/translations.xml +++ b/features/ftue/impl/src/main/res/values-ro/translations.xml @@ -2,34 +2,6 @@ "Puteți modifica setările mai târziu." "Permiteți notificările și nu pierdeți niciodată un mesaj" - "Se stabilește o conexiune securizată" - "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." - "Și acum?" - "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea." - "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi." - "Dacă nu funcționează, conectați-vă manual" - "Conexiunea nu este sigură" - "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." - "Introduceți numărul de mai jos pe celălalt dispozitiv" - "Gata de scanare" - "Deschideți %1$s pe un dispozitiv desktop" - "Faceți clic pe avatarul dumneavoastră" - "Selectați %1$s" - "„Conectați un dispozitiv nou”" - "Scanați codul QR cu acest dispozitiv" - "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" - "Utilizați codul QR afișat pe celălalt dispozitiv." - "Încercați din nou" - "Cod QR greșit" - "Mergeți la setările camerei" - "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua." - "Permiteți accesul la cameră pentru a scana codul QR" - "Scanați codul QR" - "Începeți din nou" - "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." - "În așteptarea celuilalt dispozitiv" - "Furnizorul dumneavoastră de cont poate solicita următorul cod pentru a verifica conectarea." - "Codul dumneavoastră de verificare" "Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an." "Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare." "Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări." diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml index 300647b7f0..6ddceef57f 100644 --- a/features/ftue/impl/src/main/res/values-ru/translations.xml +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -2,33 +2,6 @@ "Вы можете изменить настройки позже." "Разрешите уведомления и никогда не пропустите сообщение" - "Установление безопасного соединения" - "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них." - "Что теперь?" - "Попробуйте снова войти в систему с помощью QR-кода, если это была сетевая проблема" - "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные" - "Если это не помогло, войдите вручную" - "Соединение не защищено" - "Вам нужно будет ввести две цифры, показанные на этом устройстве." - "Введите показанный номер на своем другом устройстве" - "Откройте %1$s на настольном устройстве" - "Нажмите на свое изображение" - "Выбрать %1$s" - "\"Привязать новое устройство\"" - "Соблюдайте показанную инструкцию" - "Откройте %1$s на другом устройстве, чтобы получить QR-код" - "Используйте QR-код, показанный на другом устройстве." - "Повторить попытку" - "Неверный QR-код" - "Перейдите в настройки камеры" - "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства." - "Разрешите доступ к камере для сканирования QR-кода" - "Сканировать QR-код" - "Начать заново" - "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз." - "В ожидании другого устройства" - "Поставщик учетной записи может запросить следующий код для подтверждения входа." - "Ваш код подтверждения" "Звонки, опросы, поиск и многое другое будут добавлены позже в этом году." "История сообщений для зашифрованных комнат в этом обновлении будет недоступна." "Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек." diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml index 0344f5588e..d963dbd734 100644 --- a/features/ftue/impl/src/main/res/values-sk/translations.xml +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -2,34 +2,6 @@ "Svoje nastavenia môžete neskôr zmeniť." "Povoľte oznámenia a nikdy nezmeškajte žiadnu správu" - "Nadväzovanie bezpečného spojenia" - "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať." - "Čo teraz?" - "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou" - "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta" - "Ak to nefunguje, prihláste sa manuálne" - "Pripojenie nie je bezpečené" - "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení." - "Zadajte nižšie uvedené číslo na vašom druhom zariadení" - "Pripravené na skenovanie" - "Otvorte %1$s na stolnom zariadení" - "Kliknite na svoj obrázok" - "Vyberte %1$s" - "„Prepojiť nové zariadenie“" - "Naskenujte QR kód pomocou tohto zariadenia" - "Ak chcete získať QR kód, otvorte %1$s na inom zariadení" - "Použite QR kód zobrazený na druhom zariadení." - "Skúste to znova" - "Nesprávny QR kód" - "Prejsť na nastavenia fotoaparátu" - "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia." - "Povoľte prístup k fotoaparátu na naskenovanie QR kódu" - "Naskenovať QR kód" - "Začať odznova" - "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova." - "Čaká sa na vaše druhé zariadenie" - "Váš poskytovateľ účtu môže požiadať o nasledujúci kód na overenie prihlásenia." - "Váš overovací kód" "Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." "História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii." "Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení." diff --git a/features/ftue/impl/src/main/res/values-sv/translations.xml b/features/ftue/impl/src/main/res/values-sv/translations.xml index ca0f1776e8..31823cfaab 100644 --- a/features/ftue/impl/src/main/res/values-sv/translations.xml +++ b/features/ftue/impl/src/main/res/values-sv/translations.xml @@ -2,7 +2,6 @@ "Du kan ändra dina inställningar senare." "Tillåt aviseringar och missa aldrig ett meddelande" - "Försök igen" "Samtal, omröstningar, sökning och mer kommer att läggas till senare i år." "Meddelandehistorik för krypterade rum är inte tillgänglig än." "Vi vill gärna höra från dig, låt oss veta vad du tycker via inställningssidan." diff --git a/features/ftue/impl/src/main/res/values-uk/translations.xml b/features/ftue/impl/src/main/res/values-uk/translations.xml index f9a72b34af..53909599b2 100644 --- a/features/ftue/impl/src/main/res/values-uk/translations.xml +++ b/features/ftue/impl/src/main/res/values-uk/translations.xml @@ -2,7 +2,6 @@ "Ви можете змінити свої налаштування пізніше." "Дозволити сповіщення і ніколи не пропускати повідомлення" - "Спробуйте ще раз" "Дзвінки, опитування, пошук тощо будуть додані пізніше цього року." "Історія повідомлень для зашифрованих кімнат ще недоступна." "Ми хотіли б почути вас, розкажіть нам ваші враження та ідеї щодо застосунку на сторінці налаштувань." diff --git a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml index 51947ca25b..8e5d5e3109 100644 --- a/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/ftue/impl/src/main/res/values-zh-rTW/translations.xml @@ -1,6 +1,5 @@ - "再試一次" "通話、投票、搜尋等更多功能將在今年登場。" "在這次的更新,您無法查看聊天室內被加密的歷史訊息。" "我們很樂意聽取您的意見,請到設定頁面告訴我們您的想法。" diff --git a/features/ftue/impl/src/main/res/values-zh/translations.xml b/features/ftue/impl/src/main/res/values-zh/translations.xml index f6432fb3c6..bbc5059789 100644 --- a/features/ftue/impl/src/main/res/values-zh/translations.xml +++ b/features/ftue/impl/src/main/res/values-zh/translations.xml @@ -2,33 +2,6 @@ "您可以稍后更改设置。" "允许通知,绝不错过任何消息" - "建立安全连接" - "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。" - "现在怎么办?" - "如果这是网络问题,请尝试使用二维码再次登录" - "如果你遇到同样的问题,请尝试使用不同的 WiFi 网络或使用你的移动数据代替 WiFi" - "如果不起作用,请手动登录" - "连接不安全" - "您会被要求输入此设备上显示的两位数。" - "在您的其他设备上输入下面的数字" - "在桌面设备上打开 %1$s" - "点击你的头像" - "选择 %1$s" - "「连接新设备」" - "按照说明进行操作" - "在另一台设备上打开 %1$s 以获取二维码" - "使用其他设备上显示的二维码。" - "再试一次" - "二维码错误" - "转到摄像头设置" - "您需要授予 %1$s 使用设备摄像头的权限才能继续。" - "允许摄像头权限以扫描 QR 码" - "扫描二维码" - "重新开始" - "发生了意外错误。请再试一次。" - "等着您的其他设备" - "您的账户提供商可能会要求您提供以下代码来验证登录。" - "您的验证码" "今年晚些时候将增加通话、投票、搜索等功能。" "加密房间的消息历史记录尚不可用。" "我们很乐意听取您的意见,请通过设置页面告诉我们您的想法。" diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 256dcbae31..3e8c86b761 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -2,34 +2,6 @@ "You can change your settings later." "Allow notifications and never miss a message" - "Establishing a secure connection" - "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them." - "What now?" - "Try signing in again with a QR code in case this was a network problem" - "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" - "If that doesn’t work, sign in manually" - "Connection not secure" - "You’ll be asked to enter the two digits shown on this device." - "Enter the number below on your other device" - "Ready to scan" - "Open %1$s on a desktop device" - "Click on your avatar" - "Select %1$s" - "“Link new device”" - "Scan the QR code with this device" - "Open %1$s on another device to get the QR code" - "Use the QR code shown on the other device." - "Try again" - "Wrong QR code" - "Go to camera settings" - "You need to give permission for %1$s to use your device’s camera in order to continue." - "Allow camera access to scan the QR code" - "Scan the QR code" - "Start over" - "An unexpected error occurred. Please try again." - "Waiting for your other device" - "Your account provider may ask for the following code to verify the sign in." - "Your verification code" "Calls, polls, search and more will be added later this year." "Message history for encrypted rooms isn’t available yet." "We’d love to hear from you, let us know what you think via the settings page." diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt similarity index 99% rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt index e346e3a1dc..5c73d08778 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTests.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueServiceTest.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.test.runTest import org.junit.Test -class DefaultFtueServiceTests { +class DefaultFtueServiceTest { @Test fun `given any check being false and session verification state being loaded, FtueState is Incomplete`() = runTest { val sessionVerificationService = FakeSessionVerificationService().apply { diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt similarity index 99% rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt index 4d8ae554d7..a114b14f47 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTest.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class NotificationsOptInPresenterTests { +class NotificationsOptInPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt similarity index 94% rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt index 6b4d4b2287..a75c659c7b 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/InMemoryWelcomeScreenState.kt @@ -16,7 +16,7 @@ package io.element.android.features.ftue.impl.welcome.state -class FakeWelcomeState : WelcomeScreenState { +class InMemoryWelcomeScreenState : WelcomeScreenStore { private var isWelcomeScreenNeeded = true override fun isWelcomeScreenNeeded(): Boolean { diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt index c969458122..02afe4fe94 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt @@ -24,8 +24,8 @@ interface AcceptDeclineInviteView { @Composable fun Render( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt index 05ba21b98d..0972eb3eb8 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt @@ -112,7 +112,7 @@ class AcceptDeclineInvitePresenter @Inject constructor( trigger = JoinedRoom.Trigger.Invite, ) .onSuccess { - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } .map { roomId } } @@ -122,7 +122,7 @@ class AcceptDeclineInvitePresenter @Inject constructor( suspend { client.getRoom(roomId)?.use { it.leave().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } roomId }.runCatchingUpdatingState(declinedAction) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt index 3f229fbe85..38615fa553 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt @@ -36,21 +36,21 @@ import kotlin.jvm.optionals.getOrNull @Composable fun AcceptDeclineInviteView( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { AsyncActionView( async = state.acceptAction, - onSuccess = onInviteAccepted, + onSuccess = onAcceptInvite, onErrorDismiss = { state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError) }, ) AsyncActionView( async = state.declineAction, - onSuccess = onInviteDeclined, + onSuccess = onDeclineInvite, onErrorDismiss = { state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError) }, @@ -59,10 +59,10 @@ fun AcceptDeclineInviteView( if (invite != null) { DeclineConfirmationDialog( invite = invite, - onConfirmClicked = { + onConfirmClick = { state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite) }, - onDismissClicked = { + onDismissClick = { state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) } ) @@ -75,8 +75,8 @@ fun AcceptDeclineInviteView( @Composable private fun DeclineConfirmationDialog( invite: InviteData, - onConfirmClicked: () -> Unit, - onDismissClicked: () -> Unit, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { val contentResource = if (invite.isDirect) { @@ -97,8 +97,8 @@ private fun DeclineConfirmationDialog( title = stringResource(titleResource), submitText = stringResource(CommonStrings.action_decline), cancelText = stringResource(CommonStrings.action_cancel), - onSubmitClicked = onConfirmClicked, - onDismiss = onDismissClicked, + onSubmitClick = onConfirmClick, + onDismiss = onDismissClick, ) } @@ -108,7 +108,7 @@ internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInvit ElementPreview { AcceptDeclineInviteView( state = state, - onInviteAccepted = {}, - onInviteDeclined = {}, + onAcceptInvite = {}, + onDeclineInvite = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt similarity index 84% rename from features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt rename to features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt index a86b220364..f7654fa2f3 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/DefaultAcceptDeclineInviteView.kt @@ -26,18 +26,18 @@ import io.element.android.libraries.matrix.api.core.RoomId import javax.inject.Inject @ContributesBinding(SessionScope::class) -class AcceptDeclineInviteViewWrapper @Inject constructor() : AcceptDeclineInviteView { +class DefaultAcceptDeclineInviteView @Inject constructor() : AcceptDeclineInviteView { @Composable override fun Render( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier, ) { AcceptDeclineInviteView( state = state, - onInviteAccepted = onInviteAccepted, - onInviteDeclined = onInviteDeclined, + onAcceptInvite = onAcceptInvite, + onDeclineInvite = onDeclineInvite, modifier = modifier ) } diff --git a/features/invite/impl/src/main/res/values-be/translations.xml b/features/invite/impl/src/main/res/values-be/translations.xml index 9a37d82357..13c241102f 100644 --- a/features/invite/impl/src/main/res/values-be/translations.xml +++ b/features/invite/impl/src/main/res/values-be/translations.xml @@ -5,5 +5,5 @@ "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?" "Адхіліць чат" "Няма запрашэнняў" - "%1$s (%2$s) запрасіў вас" + "%1$s (%2$s) запрасіў(-ла) вас" diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt index 247df956e6..672c5b58ee 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt @@ -23,8 +23,10 @@ import io.element.android.features.invite.api.response.InviteData import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom @@ -128,6 +130,12 @@ class AcceptDeclineInvitePresenterTest { @Test fun `present - declining invite success flow`() = runTest { + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> + Result.success(Unit) + } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda + ) val declineInviteSuccess = lambdaRecorder { -> Result.success(Unit) } @@ -139,7 +147,10 @@ class AcceptDeclineInvitePresenterTest { } ) } - val presenter = createAcceptDeclineInvitePresenter(client = client) + val presenter = createAcceptDeclineInvitePresenter( + client = client, + notificationDrawerManager = notificationDrawerManager, + ) presenter.test { val inviteData = anInviteData() awaitItem().also { state -> @@ -159,7 +170,10 @@ class AcceptDeclineInvitePresenterTest { } cancelAndConsumeRemainingEvents() } - assert(declineInviteSuccess).isCalledOnce() + declineInviteSuccess.assertions().isCalledOnce() + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) } @Test @@ -192,18 +206,29 @@ class AcceptDeclineInvitePresenterTest { cancelAndConsumeRemainingEvents() } assert(joinRoomFailure) - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(emptyList()), value(JoinedRoom.Trigger.Invite)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) ) } @Test fun `present - accepting invite success flow`() = runTest { + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> + Result.success(Unit) + } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda + ) val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger -> Result.success(Unit) } - val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomSuccess) + val presenter = createAcceptDeclineInvitePresenter( + joinRoomLambda = joinRoomSuccess, + notificationDrawerManager = notificationDrawerManager, + ) presenter.test { val inviteData = anInviteData() awaitItem().also { state -> @@ -221,10 +246,15 @@ class AcceptDeclineInvitePresenterTest { cancelAndConsumeRemainingEvents() } assert(joinRoomSuccess) - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(emptyList()), value(JoinedRoom.Trigger.Invite)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) ) + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) } private fun anInviteData( diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt index 2cddd9d45b..fa320a6545 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt @@ -50,15 +50,15 @@ class JoinRoomNode @AssistedInject constructor( val state = presenter.present() JoinRoomView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, onJoinSuccess = ::navigateUp, onKnockSuccess = ::navigateUp, modifier = modifier ) acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, - onInviteAccepted = {}, - onInviteDeclined = { navigateUp() }, + onAcceptInvite = {}, + onDeclineInvite = { navigateUp() }, modifier = Modifier ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt index 0f849aac24..905d5dd2d1 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -42,6 +42,7 @@ data class JoinRoomState( } } +@Immutable sealed interface ContentState { data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 435cd1bf8b..2a4989e613 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -65,7 +65,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun JoinRoomView( state: JoinRoomState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, onJoinSuccess: () -> Unit, onKnockSuccess: () -> Unit, modifier: Modifier = Modifier, @@ -78,7 +78,7 @@ fun JoinRoomView( containerColor = Color.Transparent, paddingValues = PaddingValues(16.dp), topBar = { - JoinRoomTopBar(onBackClicked = onBackPressed) + JoinRoomTopBar(onBackClick = onBackClick) }, content = { JoinRoomContent( @@ -104,7 +104,7 @@ fun JoinRoomView( onRetry = { state.eventSink(JoinRoomEvents.RetryFetchingContent) }, - onGoBack = onBackPressed, + onGoBack = onBackClick, ) } ) @@ -312,11 +312,11 @@ private fun JoinRoomContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun JoinRoomTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {}, ) @@ -327,7 +327,7 @@ private fun JoinRoomTopBar( internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { JoinRoomView( state = state, - onBackPressed = { }, + onBackClick = { }, onJoinSuccess = { }, onKnockSuccess = { }, ) diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt index bf4449be41..c0ae1c0a64 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt @@ -45,7 +45,7 @@ class JoinRoomViewTest { aJoinRoomState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -167,7 +167,7 @@ class JoinRoomViewTest { contentState = aLoadedContentState(roomType = RoomType.Space), eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.clickOn(CommonStrings.action_go_back) } @@ -176,14 +176,14 @@ class JoinRoomViewTest { private fun AndroidComposeTestRule.setJoinRoomView( state: JoinRoomState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onJoinSuccess: () -> Unit = EnsureNeverCalled(), onKnockSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { JoinRoomView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, onJoinSuccess = onJoinSuccess, onKnockSuccess = onKnockSuccess, ) diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt index 5168ee9936..58bbd6b1ad 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt @@ -89,7 +89,7 @@ private fun LeaveRoomConfirmationDialog( title = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room), content = stringResource(text), submitText = stringResource(CommonStrings.action_leave), - onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, + onSubmitClick = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) }, ) } diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt similarity index 95% rename from features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt rename to features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt index 56c2f9b8fd..c949a5f446 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImpl.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenter.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomState @@ -29,6 +30,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.Gen import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.LastUserInRoom import io.element.android.features.leaveroom.api.LeaveRoomState.Confirmation.PrivateRoom import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @@ -36,7 +38,8 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -class LeaveRoomPresenterImpl @Inject constructor( +@ContributesBinding(SessionScope::class) +class DefaultLeaveRoomPresenter @Inject constructor( private val client: MatrixClient, private val roomMembershipObserver: RoomMembershipObserver, private val dispatchers: CoroutineDispatchers, diff --git a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt similarity index 93% rename from features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt rename to features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt index 664ca4bd28..9962a3bd32 100644 --- a/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplTest.kt +++ b/features/leaveroom/impl/src/test/kotlin/io/element/android/features/leaveroom/impl/DefaultLeaveRoomPresenterTest.kt @@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.leaveroom.api.LeaveRoomEvent -import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @@ -37,13 +36,13 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class LeaveRoomPresenterImplTest { +class DefaultLeaveRoomPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @Test fun `present - initial state hides all dialogs`() = runTest { - val presenter = createLeaveRoomPresenter() + val presenter = createDefaultLeaveRoomPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -56,7 +55,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show generic confirmation`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -76,7 +75,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show private room confirmation`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -96,7 +95,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show last user in room confirmation`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -116,7 +115,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show DM confirmation`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -137,7 +136,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - leaving a room leaves the room`() = runTest { val roomMembershipObserver = RoomMembershipObserver() - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -159,7 +158,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show error if leave room fails`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -183,7 +182,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - show progress indicator while leaving a room`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -205,7 +204,7 @@ class LeaveRoomPresenterImplTest { @Test fun `present - hide error hides the error`() = runTest { - val presenter = createLeaveRoomPresenter( + val presenter = createDefaultLeaveRoomPresenter( client = FakeMatrixClient().apply { givenGetRoomResult( roomId = A_ROOM_ID, @@ -231,10 +230,10 @@ class LeaveRoomPresenterImplTest { } } -private fun TestScope.createLeaveRoomPresenter( +private fun TestScope.createDefaultLeaveRoomPresenter( client: MatrixClient = FakeMatrixClient(), roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), -): LeaveRoomPresenter = LeaveRoomPresenterImpl( +): DefaultLeaveRoomPresenter = DefaultLeaveRoomPresenter( client = client, roomMembershipObserver = roomMembershipObserver, dispatchers = testCoroutineDispatchers(false), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index f5f0bbe078..6896172363 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -21,12 +21,12 @@ import android.view.Gravity import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color -import com.mapbox.mapboxsdk.camera.CameraPosition -import com.mapbox.mapboxsdk.geometry.LatLng import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.maplibre.compose.MapLocationSettings import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings import io.element.android.libraries.maplibre.compose.MapUiSettings +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng /** * Common configuration values for the map. diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt index 0a488ded85..ad8aafc385 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt @@ -29,7 +29,7 @@ internal fun PermissionDeniedDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_auth_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt index 4f4f19c6b3..0f44804977 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt @@ -29,7 +29,7 @@ internal fun PermissionRationaleDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt similarity index 93% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterImpl.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt index 7966640231..bf3dd19947 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterImpl.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/permissions/DefaultPermissionsPresenter.kt @@ -26,13 +26,14 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.libraries.di.AppScope -class PermissionsPresenterImpl @AssistedInject constructor( +@Suppress("unused") +class DefaultPermissionsPresenter @AssistedInject constructor( @Assisted private val permissions: List ) : PermissionsPresenter { @AssistedFactory @ContributesBinding(AppScope::class) interface Factory : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenterImpl + override fun create(permissions: List): DefaultPermissionsPresenter } @OptIn(ExperimentalPermissionsApi::class) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt similarity index 93% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt index fb5ff1c1c7..7eb30a0a6c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEntryPointImpl.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt @@ -25,7 +25,7 @@ import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) -class SendLocationEntryPointImpl @Inject constructor() : SendLocationEntryPoint { +class DefaultSendLocationEntryPoint @Inject constructor() : SendLocationEntryPoint { override fun createNode( parentNode: Node, buildContext: BuildContext diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt index a396cc120f..40aac154fa 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.mapbox.mapboxsdk.camera.CameraPosition import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.Location @@ -61,9 +60,10 @@ import io.element.android.libraries.designsystem.theme.components.bottomsheet.re import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.maplibre.compose.CameraMode import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason -import io.element.android.libraries.maplibre.compose.MapboxMap +import io.element.android.libraries.maplibre.compose.MapLibreMap import io.element.android.libraries.maplibre.compose.rememberCameraPositionState import io.element.android.libraries.ui.strings.CommonStrings +import org.maplibre.android.camera.CameraPosition @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -189,7 +189,7 @@ fun SendLocationView( .consumeWindowInsets(it), contentAlignment = Alignment.Center ) { - MapboxMap( + MapLibreMap( styleUri = rememberTileStyleUrl(), modifier = Modifier.fillMaxSize(), cameraPositionState = cameraPositionState, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt similarity index 93% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt index 7dc1fc02f3..06ba937571 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEntryPointImpl.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPoint.kt @@ -25,7 +25,7 @@ import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) -class ShowLocationEntryPointImpl @Inject constructor() : ShowLocationEntryPoint { +class DefaultShowLocationEntryPoint @Inject constructor() : ShowLocationEntryPoint { override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: ShowLocationEntryPoint.Inputs): Node { return parentNode.createNode(buildContext, listOf(inputs)) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt index 610f0b9079..3e02866cb7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -54,7 +54,7 @@ class ShowLocationNode @AssistedInject constructor( ShowLocationView( state = presenter.present(), modifier = modifier, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 2f352d50d2..0aca7eba3a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -33,8 +33,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.mapbox.mapboxsdk.camera.CameraPosition -import com.mapbox.mapboxsdk.geometry.LatLng import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.TypographyTokens @@ -56,18 +54,20 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.maplibre.compose.CameraMode import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason import io.element.android.libraries.maplibre.compose.IconAnchor -import io.element.android.libraries.maplibre.compose.MapboxMap +import io.element.android.libraries.maplibre.compose.MapLibreMap import io.element.android.libraries.maplibre.compose.Symbol import io.element.android.libraries.maplibre.compose.rememberCameraPositionState import io.element.android.libraries.maplibre.compose.rememberSymbolState import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableMap +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.geometry.LatLng @OptIn(ExperimentalMaterial3Api::class) @Composable fun ShowLocationView( state: ShowLocationState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { when (state.permissionDialog) { @@ -121,7 +121,7 @@ fun ShowLocationView( }, navigationIcon = { BackButton( - onClick = onBackPressed, + onClick = onBackClick, ) }, actions = { @@ -166,7 +166,7 @@ fun ShowLocationView( ) } - MapboxMap( + MapLibreMap( styleUri = rememberTileStyleUrl(), modifier = Modifier.fillMaxSize(), images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(), @@ -194,7 +194,7 @@ fun ShowLocationView( internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview { ShowLocationView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt similarity index 95% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt index e79ac9e453..8bbaf5c428 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt @@ -18,7 +18,7 @@ package io.element.android.features.location.impl.common.permissions import androidx.compose.runtime.Composable -class PermissionsPresenterFake : PermissionsPresenter { +class FakePermissionsPresenter : PermissionsPresenter { val events = mutableListOf() private fun handleEvent(event: PermissionsEvents) { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt index b6b469c44a..af4ac6c9c7 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -24,9 +24,9 @@ import im.vector.app.features.analytics.plan.Composer import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.matrix.api.room.location.AssetType @@ -45,7 +45,7 @@ class SendLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeMatrixRoom = FakeMatrixRoom() private val fakeAnalyticsService = FakeAnalyticsService() private val fakeMessageComposerContext = FakeMessageComposerContext() @@ -53,7 +53,7 @@ class SendLocationPresenterTest { private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, room = fakeMatrixRoom, analyticsService = fakeAnalyticsService, @@ -64,7 +64,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -90,7 +90,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions partially granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.SomeGranted, shouldShowRationale = false, @@ -116,7 +116,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -142,7 +142,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -168,7 +168,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -199,7 +199,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -221,13 +221,13 @@ class SendLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter myLocationState.eventSink(SendLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -258,7 +258,7 @@ class SendLocationPresenterTest { @Test fun `share sender location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -314,7 +314,7 @@ class SendLocationPresenterTest { @Test fun `share pin location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -370,7 +370,7 @@ class SendLocationPresenterTest { @Test fun `composer context passes through analytics`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -418,7 +418,7 @@ class SendLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index ff80a3935d..dab964b6e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -23,9 +23,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule @@ -38,13 +38,13 @@ class ShowLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val location = Location(1.23, 4.56, 7.8f) private val presenter = ShowLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, fakeLocationActions, fakeBuildMeta, @@ -54,7 +54,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with no location permission`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -74,7 +74,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state location permission denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -94,7 +94,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -109,7 +109,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with partial location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -137,7 +137,7 @@ class ShowLocationPresenterTest { @Test fun `centers on user location`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -165,7 +165,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -196,7 +196,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -218,13 +218,13 @@ class ShowLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -255,7 +255,7 @@ class ShowLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index 05160dca56..21cbd873e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -49,7 +49,7 @@ class ShowLocationViewTest { state = aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -62,7 +62,7 @@ class ShowLocationViewTest { aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(shareContentDescription).performClick() @@ -76,7 +76,7 @@ class ShowLocationViewTest { aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) @@ -90,7 +90,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionDenied, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) @@ -104,7 +104,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionDenied, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) @@ -118,7 +118,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionRationale, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) @@ -132,7 +132,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionRationale, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) @@ -141,14 +141,14 @@ class ShowLocationViewTest { private fun AndroidComposeTestRule.setShowLocationView( state: ShowLocationState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { - // Simulate a LocalInspectionMode for MapboxMap + // Simulate a LocalInspectionMode for MapLibreMap CompositionLocalProvider(LocalInspectionMode provides true) { ShowLocationView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index 13fe1e62aa..5cebdfbf01 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -115,7 +115,7 @@ class LockScreenSettingsFlowNode @AssistedInject constructor( } NavTarget.Settings -> { val callback = object : LockScreenSettingsNode.Callback { - override fun onChangePinClicked() { + override fun onChangePinClick() { backstack.push(NavTarget.SetupPin) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt index edc50df50e..4f80c7fff0 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt @@ -34,11 +34,11 @@ class LockScreenSettingsNode @AssistedInject constructor( private val presenter: LockScreenSettingsPresenter, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun onChangePinClicked() + fun onChangePinClick() } - private fun onChangePinClicked() { - plugins().forEach { it.onChangePinClicked() } + private fun onChangePinClick() { + plugins().forEach { it.onChangePinClick() } } @Composable @@ -46,8 +46,8 @@ class LockScreenSettingsNode @AssistedInject constructor( val state = presenter.present() LockScreenSettingsView( state = state, - onBackPressed = this::navigateUp, - onChangePinClicked = this::onChangePinClicked, + onBackClick = this::navigateUp, + onChangePinClick = this::onChangePinClick, modifier = modifier, ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt index 7c8634a078..375c5cde41 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt @@ -34,19 +34,19 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable fun LockScreenSettingsView( state: LockScreenSettingsState, - onChangePinClicked: () -> Unit, - onBackPressed: () -> Unit, + onChangePinClick: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock), - onBackPressed = onBackPressed, + onBackClick = onBackClick, modifier = modifier ) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = false) { PreferenceText( title = stringResource(id = R.string.screen_app_lock_settings_change_pin), - onClick = onChangePinClicked + onClick = onChangePinClick ) PreferenceDivider() if (state.showRemovePinOption) { @@ -74,7 +74,7 @@ fun LockScreenSettingsView( ConfirmationDialog( title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title), content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message), - onSubmitClicked = { + onSubmitClick = { state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin) }, onDismiss = { @@ -92,8 +92,8 @@ internal fun LockScreenSettingsViewPreview( ElementPreview { LockScreenSettingsView( state = state, - onChangePinClicked = {}, - onBackPressed = {}, + onChangePinClick = {}, + onBackClick = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt index 99001e5334..6d3325d480 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt @@ -49,8 +49,8 @@ fun SetupBiometricView( }, footer = { SetupBiometricFooter( - onAllowClicked = { state.eventSink(SetupBiometricEvents.AllowBiometric) }, - onSkipClicked = { state.eventSink(SetupBiometricEvents.UsePin) } + onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) }, + onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) } ) }, ) @@ -68,18 +68,18 @@ private fun SetupBiometricHeader() { @Composable private fun SetupBiometricFooter( - onAllowClicked: () -> Unit, - onSkipClicked: () -> Unit, + onAllowClick: () -> Unit, + onSkipClick: () -> Unit, ) { ButtonColumnMolecule { val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) Button( text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth), - onClick = onAllowClicked + onClick = onAllowClick ) TextButton( text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_skip), - onClick = onSkipClicked + onClick = onSkipClick ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt index ddd12f65df..b56b0daa86 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt @@ -37,7 +37,7 @@ class SetupPinNode @AssistedInject constructor( val state = presenter.present() SetupPinView( state = state, - onBackClicked = this::navigateUp, + onBackClick = this::navigateUp, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt index a3dcab5a43..2a0e890470 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun SetupPinView( state: SetupPinState, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -60,7 +60,7 @@ fun SetupPinView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {} ) @@ -154,7 +154,7 @@ internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) ElementPreview { SetupPinView( state = state, - onBackClicked = {}, + onBackClick = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt index 467de77d2b..70b6a6c634 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -192,7 +192,7 @@ private fun SignOutPrompt( ConfirmationDialog( title = stringResource(id = R.string.screen_app_lock_signout_alert_title), content = stringResource(id = R.string.screen_app_lock_signout_alert_message), - onSubmitClicked = onSignOut, + onSubmitClick = onSignOut, onDismiss = onDismiss, ) } else { diff --git a/features/lockscreen/impl/src/main/res/values-be/translations.xml b/features/lockscreen/impl/src/main/res/values-be/translations.xml index 2c5bd554b0..e38af70014 100644 --- a/features/lockscreen/impl/src/main/res/values-be/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-be/translations.xml @@ -30,8 +30,8 @@ "Няправільны PIN-код. У вас застаўся %1$d шанец" - "Няправільны PIN-код. У вас застаўася %1$d шанца" - "Няправільны PIN-код. У вас застаўася %1$d шанцаў" + "Няправільны PIN-код. У вас засталася %1$d шанцы" + "Няправільны PIN-код. У вас засталася %1$d шанцаў" "Выкарыстоўваць біяметрыю" "Выкарыстоўваць PIN-код" diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt index 6870a336d3..392693d9ef 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt @@ -20,7 +20,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.lockscreen.impl.unlock.signout.DefaultSignOut import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.test.runTest @@ -28,7 +28,7 @@ import org.junit.Test class DefaultSignOutTest { private val matrixClient = FakeMatrixClient() - private val authenticationService = FakeAuthenticationService() + private val authenticationService = FakeMatrixAuthenticationService() private val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) private val sut = DefaultSignOut(authenticationService, matrixClientProvider) diff --git a/features/login/api/build.gradle.kts b/features/login/api/build.gradle.kts index 5b7bddb15f..97fb3bec04 100644 --- a/features/login/api/build.gradle.kts +++ b/features/login/api/build.gradle.kts @@ -15,6 +15,7 @@ */ plugins { id("io.element.android-library") + id("kotlin-parcelize") } android { diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt index 07a546192d..545dbb9f45 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt @@ -16,13 +16,15 @@ package io.element.android.features.login.api +import android.os.Parcelable import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import io.element.android.libraries.architecture.FeatureEntryPoint +import kotlinx.parcelize.Parcelize interface LoginEntryPoint : FeatureEntryPoint { data class Params( - val isAccountCreation: Boolean, + val flowType: LoginFlowType ) fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder @@ -32,3 +34,10 @@ interface LoginEntryPoint : FeatureEntryPoint { fun build(): Node } } + +@Parcelize +enum class LoginFlowType : Parcelable { + SIGN_IN_MANUAL, + SIGN_IN_QR_CODE, + SIGN_UP +} diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts index 47ddd893fa..25bf1dc1b3 100644 --- a/features/login/impl/build.gradle.kts +++ b/features/login/impl/build.gradle.kts @@ -49,6 +49,8 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.permissions.api) + implementation(projects.libraries.qrcode) implementation(libs.androidx.browser) implementation(platform(libs.network.retrofit.bom)) implementation(libs.network.retrofit) @@ -57,10 +59,15 @@ dependencies { ksp(libs.showkase.processor) testImplementation(libs.test.junit) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.permissions.test) testImplementation(projects.tests.testutils) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt index 633fc43b5c..914b60756e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPoint.kt @@ -32,7 +32,7 @@ class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint { return object : LoginEntryPoint.NodeBuilder { override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder { - plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation) + plugins += LoginFlowNode.Inputs(flowType = params.flowType) return this } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index cd17a7daf3..0af102a403 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -35,12 +35,14 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.api.LoginFlowType import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcActionFlow import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.webview.OidcNode +import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode import io.element.android.features.login.impl.screens.loginpassword.LoginFormState @@ -69,7 +71,7 @@ class LoginFlowNode @AssistedInject constructor( private val oidcActionFlow: OidcActionFlow, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.ConfirmAccountProvider, + initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -79,7 +81,7 @@ class LoginFlowNode @AssistedInject constructor( private var darkTheme: Boolean = false data class Inputs( - val isAccountCreation: Boolean, + val flowType: LoginFlowType, ) : NodeInputs private val inputs: Inputs = inputs() @@ -107,6 +109,9 @@ class LoginFlowNode @AssistedInject constructor( } sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + @Parcelize data object ConfirmAccountProvider : NavTarget @@ -128,9 +133,16 @@ class LoginFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { + NavTarget.Root -> { + if (inputs.flowType == LoginFlowType.SIGN_IN_QR_CODE) { + createNode(buildContext) + } else { + resolve(NavTarget.ConfirmAccountProvider, buildContext) + } + } NavTarget.ConfirmAccountProvider -> { val inputs = ConfirmAccountProviderNode.Inputs( - isAccountCreation = inputs.isAccountCreation + isAccountCreation = inputs.flowType == LoginFlowType.SIGN_UP, ) val callback = object : ConfirmAccountProviderNode.Callback { override fun onOidcDetails(oidcDetails: OidcDetails) { @@ -163,7 +175,7 @@ class LoginFlowNode @AssistedInject constructor( backstack.singleTop(NavTarget.ConfirmAccountProvider) } - override fun onOtherClicked() { + override fun onOtherClick() { backstack.push(NavTarget.SearchAccountProvider) } } @@ -197,7 +209,7 @@ class LoginFlowNode @AssistedInject constructor( loginFormState = navTarget.loginFormState, ) val callback = object : WaitListNode.Callback { - override fun onCancelClicked() { + override fun onCancelClick() { navigateUp() } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt index 9f71ccc7e5..6818caadb8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -33,8 +33,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable fun ChangeServerView( state: ChangeServerState, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -53,8 +53,8 @@ fun ChangeServerView( is ChangeServerError.SlidingSyncAlert -> { SlidingSyncNotSupportedDialog( modifier = modifier, - onLearnMoreClicked = { - onLearnMoreClicked() + onLearnMoreClick = { + onLearnMoreClick() eventSink.invoke(ChangeServerEvents.ClearError) }, onDismiss = { @@ -66,9 +66,9 @@ fun ChangeServerView( } is AsyncData.Loading -> ProgressDialog() is AsyncData.Success -> { - val latestOnDone by rememberUpdatedState(onDone) + val latestOnSuccess by rememberUpdatedState(onSuccess) LaunchedEffect(state.changeServerAction) { - latestOnDone() + latestOnSuccess() } } AsyncData.Uninitialized -> Unit @@ -80,7 +80,7 @@ fun ChangeServerView( internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreview { ChangeServerView( state = state, - onLearnMoreClicked = {}, - onDone = {}, + onLearnMoreClick = {}, + onSuccess = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt new file mode 100644 index 0000000000..d16a69f75f --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginBindings.kt @@ -0,0 +1,25 @@ +/* + * 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.login.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager + +@ContributesTo(QrCodeLoginScope::class) +interface QrCodeLoginBindings { + fun qrCodeLoginManager(): QrCodeLoginManager +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginComponent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginComponent.kt new file mode 100644 index 0000000000..f92e192e40 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginComponent.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.Subcomponent +import io.element.android.libraries.architecture.NodeFactoriesBindings +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn + +@SingleIn(QrCodeLoginScope::class) +@MergeSubcomponent(QrCodeLoginScope::class) +interface QrCodeLoginComponent : NodeFactoriesBindings { + @Subcomponent.Builder + interface Builder { + fun build(): QrCodeLoginComponent + } + + @ContributesTo(AppScope::class) + interface ParentBindings { + fun qrCodeLoginComponentBuilder(): Builder + } +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt similarity index 78% rename from libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt rename to features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt index 2a4f9b8ac1..12d4973c2d 100644 --- a/libraries/di/src/main/kotlin/io/element/android/libraries/di/DefaultPreferences.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/QrCodeLoginScope.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,8 +14,6 @@ * limitations under the License. */ -package io.element.android.libraries.di +package io.element.android.features.login.impl.di -import javax.inject.Qualifier - -@Qualifier annotation class DefaultPreferences +abstract class QrCodeLoginScope private constructor() diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt index a151b34303..ff83283ff2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun SlidingSyncNotSupportedDialog( - onLearnMoreClicked: () -> Unit, + onLearnMoreClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -35,8 +35,8 @@ internal fun SlidingSyncNotSupportedDialog( modifier = modifier, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_learn_more), - onSubmitClicked = onLearnMoreClicked, - onCancelClicked = onDismiss, + onSubmitClick = onLearnMoreClick, + onCancelClick = onDismiss, title = stringResource(CommonStrings.dialog_title_error), content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), ) @@ -46,7 +46,7 @@ internal fun SlidingSyncNotSupportedDialog( @Composable internal fun SlidingSyncNotSupportedDialogPreview() = ElementPreview { SlidingSyncNotSupportedDialog( - onLearnMoreClicked = {}, + onLearnMoreClick = {}, onDismiss = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt index fb86ae18a5..c07078cdff 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcView.kt @@ -89,6 +89,6 @@ fun OidcView( internal fun OidcViewPreview(@PreviewParameter(OidcStateProvider::class) state: OidcState) = ElementPreview { OidcView( state = state, - onNavigateBack = { }, + onNavigateBack = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt index 0da6e16ee0..a9cd576e6d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt @@ -16,8 +16,6 @@ package io.element.android.features.login.impl.oidc.webview -import android.annotation.TargetApi -import android.os.Build import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -25,7 +23,6 @@ import android.webkit.WebViewClient class OidcWebViewClient( private val eventListener: WebViewEventListener, ) : WebViewClient() { - @TargetApi(Build.VERSION_CODES.N) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return shouldOverrideUrl(request.url.toString()) } @@ -36,7 +33,6 @@ class OidcWebViewClient( } private fun shouldOverrideUrl(url: String): Boolean { - // Timber.d("shouldOverrideUrl: $url") return eventListener.shouldOverrideUrlLoading(url) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt new file mode 100644 index 0000000000..9ab6494d95 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManager.kt @@ -0,0 +1,54 @@ +/* + * 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.login.impl.qrcode + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@SingleIn(QrCodeLoginScope::class) +@ContributesBinding(QrCodeLoginScope::class) +class DefaultQrCodeLoginManager @Inject constructor( + private val authenticationService: MatrixAuthenticationService, +) : QrCodeLoginManager { + private val _currentLoginStep = MutableStateFlow(QrCodeLoginStep.Uninitialized) + override val currentLoginStep: StateFlow = _currentLoginStep + + override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result { + reset() + + return authenticationService.loginWithQrCode(qrCodeLoginData) { step -> + _currentLoginStep.value = step + }.onFailure { throwable -> + if (throwable is QrLoginException) { + _currentLoginStep.value = QrCodeLoginStep.Failed(throwable) + } + } + } + + override fun reset() { + _currentLoginStep.value = QrCodeLoginStep.Uninitialized + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt new file mode 100644 index 0000000000..4d5f0e6a95 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNode.kt @@ -0,0 +1,248 @@ +/* + * 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.login.impl.qrcode + +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.replace +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.di.QrCodeLoginBindings +import io.element.android.features.login.impl.di.QrCodeLoginComponent +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationNode +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep +import io.element.android.features.login.impl.screens.qrcode.error.QrCodeErrorNode +import io.element.android.features.login.impl.screens.qrcode.intro.QrCodeIntroNode +import io.element.android.features.login.impl.screens.qrcode.scan.QrCodeScanNode +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +@ContributesNode(AppScope::class) +class QrCodeLoginFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + qrCodeLoginComponentBuilder: QrCodeLoginComponent.Builder, + private val defaultLoginUserStory: DefaultLoginUserStory, + private val coroutineDispatchers: CoroutineDispatchers, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Initial, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +), DaggerComponentOwner { + private var authenticationJob: Job? = null + + override val daggerComponent = qrCodeLoginComponentBuilder.build() + private val qrCodeLoginManager by lazy { bindings().qrCodeLoginManager() } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Initial : NavTarget + + @Parcelize + data object QrCodeScan : NavTarget + + @Parcelize + data class QrCodeConfirmation(val step: QrCodeConfirmationStep) : NavTarget + + @Parcelize + data class Error(val errorType: QrCodeErrorScreenType) : NavTarget + } + + override fun onBuilt() { + super.onBuilt() + + observeLoginStep() + } + + fun isLoginInProgress(): Boolean { + return authenticationJob?.isActive == true + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun observeLoginStep() { + lifecycleScope.launch { + qrCodeLoginManager.currentLoginStep + .collect { step -> + when (step) { + is QrCodeLoginStep.EstablishingSecureChannel -> { + backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode(step.checkCode))) + } + is QrCodeLoginStep.WaitingForToken -> { + backstack.replace(NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode(step.userCode))) + } + is QrCodeLoginStep.Failed -> { + when (val error = step.error) { + is QrLoginException.OtherDeviceNotSignedIn -> { + // Do nothing here, it'll be handled in the scan QR screen + } + is QrLoginException.Cancelled -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Cancelled)) + } + is QrLoginException.Expired -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Expired)) + } + is QrLoginException.Declined -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.Declined)) + } + is QrLoginException.ConnectionInsecure -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected)) + } + is QrLoginException.LinkingNotSupported -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported)) + } + is QrLoginException.SlidingSyncNotAvailable -> { + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable)) + } + is QrLoginException.OidcMetadataInvalid -> { + Timber.e(error, "OIDC metadata is invalid") + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + else -> { + Timber.e(error, "Unknown error found") + backstack.replace(NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + } + } + else -> Unit + } + } + } + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Initial -> { + val callback = object : QrCodeIntroNode.Callback { + override fun onCancelClicked() { + navigateUp() + } + + override fun onContinue() { + backstack.push(NavTarget.QrCodeScan) + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.QrCodeScan -> { + val callback = object : QrCodeScanNode.Callback { + override fun onScannedCode(qrCodeLoginData: MatrixQrCodeLoginData) { + lifecycleScope.startAuthentication(qrCodeLoginData) + } + + override fun onCancelClicked() { + backstack.pop() + } + } + createNode(buildContext, plugins = listOf(callback)) + } + is NavTarget.QrCodeConfirmation -> { + val callback = object : QrCodeConfirmationNode.Callback { + override fun onCancel() = reset() + } + createNode(buildContext, plugins = listOf(navTarget.step, callback)) + } + is NavTarget.Error -> { + val callback = object : QrCodeErrorNode.Callback { + override fun onRetry() = reset() + } + createNode(buildContext, plugins = listOf(navTarget.errorType, callback)) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun reset() { + authenticationJob?.cancel() + authenticationJob = null + qrCodeLoginManager.reset() + backstack.newRoot(NavTarget.Initial) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun CoroutineScope.startAuthentication(qrCodeLoginData: MatrixQrCodeLoginData) { + authenticationJob = launch(coroutineDispatchers.main) { + qrCodeLoginManager.authenticate(qrCodeLoginData) + .onSuccess { + defaultLoginUserStory.setLoginFlowIsDone(true) + authenticationJob = null + } + .onFailure { throwable -> + Timber.e(throwable, "QR code authentication failed") + authenticationJob = null + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView() + } +} + +@Immutable +sealed interface QrCodeErrorScreenType : NodeInputs, Parcelable { + @Parcelize + data object Cancelled : QrCodeErrorScreenType + + @Parcelize + data object Expired : QrCodeErrorScreenType + + @Parcelize + data object InsecureChannelDetected : QrCodeErrorScreenType + + @Parcelize + data object Declined : QrCodeErrorScreenType + + @Parcelize + data object ProtocolNotSupported : QrCodeErrorScreenType + + @Parcelize + data object SlidingSyncNotAvailable : QrCodeErrorScreenType + + @Parcelize + data object UnknownError : QrCodeErrorScreenType +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt new file mode 100644 index 0000000000..cd0010dc01 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginManager.kt @@ -0,0 +1,41 @@ +/* + * 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.login.impl.qrcode + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import kotlinx.coroutines.flow.StateFlow + +/** + * Helper to handle the QR code login flow after the QR code data has been provided. + */ +interface QrCodeLoginManager { + /** + * The current QR code login step. + */ + val currentLoginStep: StateFlow + + /** + * Authenticate using the provided [qrCodeLoginData]. + * @param qrCodeLoginData the QR code login data from the scanned QR code. + * @return the logged in [SessionId] if the authentication was successful or a failure result. + */ + suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result + + fun reset() +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt index 66025a2f1e..e9dd021ae5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt @@ -37,15 +37,15 @@ class ChangeAccountProviderNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onDone() - fun onOtherClicked() + fun onOtherClick() } private fun onDone() { plugins().forEach { it.onDone() } } - private fun onOtherClicked() { - plugins().forEach { it.onOtherClicked() } + private fun onOtherClick() { + plugins().forEach { it.onOtherClick() } } @Composable @@ -55,10 +55,10 @@ class ChangeAccountProviderNode @AssistedInject constructor( ChangeAccountProviderView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, - onLearnMoreClicked = { openLearnMorePage(context) }, - onDone = ::onDone, - onOtherProviderClicked = ::onOtherClicked, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = ::onDone, + onOtherProviderClick = ::onOtherClick, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt index 5daf4e8f1c..3d9b73bc87 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -55,10 +55,10 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun ChangeAccountProviderView( state: ChangeAccountProviderState, - onBackPressed: () -> Unit, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, - onOtherProviderClicked: () -> Unit, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, + onOtherProviderClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -66,7 +66,7 @@ fun ChangeAccountProviderView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) } ) { padding -> @@ -111,14 +111,14 @@ fun ChangeAccountProviderView( url = "", title = stringResource(id = R.string.screen_change_account_provider_other), ), - onClick = onOtherProviderClicked + onClick = onOtherProviderClick ) Spacer(Modifier.height(32.dp)) } ChangeServerView( state = state.changeServerState, - onLearnMoreClicked = onLearnMoreClicked, - onDone = onDone, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, ) } } @@ -129,9 +129,9 @@ fun ChangeAccountProviderView( internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview { ChangeAccountProviderView( state = state, - onBackPressed = { }, - onLearnMoreClicked = { }, - onDone = { }, - onOtherProviderClicked = { }, + onBackClick = { }, + onLearnMoreClick = { }, + onSuccess = { }, + onOtherProviderClick = { }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index 8ba488d9f4..fe80d06e16 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -75,9 +75,9 @@ class ConfirmAccountProviderNode @AssistedInject constructor( state = state, modifier = modifier, onOidcDetails = ::onOidcDetails, - onLoginPasswordNeeded = ::onLoginPasswordNeeded, + onNeedLoginPassword = ::onLoginPasswordNeeded, onChange = ::onChangeAccountProvider, - onLearnMoreClicked = { openLearnMorePage(context) }, + onLearnMoreClick = { openLearnMorePage(context) }, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index dc4e7f3b7b..f8a2f09e2c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -49,8 +49,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun ConfirmAccountProviderView( state: ConfirmAccountProviderState, onOidcDetails: (OidcDetails) -> Unit, - onLoginPasswordNeeded: () -> Unit, - onLearnMoreClicked: () -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, onChange: () -> Unit, modifier: Modifier = Modifier, ) { @@ -118,8 +118,8 @@ fun ConfirmAccountProviderView( ) } is ChangeServerError.SlidingSyncAlert -> { - SlidingSyncNotSupportedDialog(onLearnMoreClicked = { - onLearnMoreClicked() + SlidingSyncNotSupportedDialog(onLearnMoreClick = { + onLearnMoreClick() eventSink(ConfirmAccountProviderEvents.ClearError) }, onDismiss = { eventSink(ConfirmAccountProviderEvents.ClearError) @@ -131,7 +131,7 @@ fun ConfirmAccountProviderView( is AsyncData.Success -> { when (val loginFlowState = state.loginFlow.data) { is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) - LoginFlow.PasswordLogin -> onLoginPasswordNeeded() + LoginFlow.PasswordLogin -> onNeedLoginPassword() } } AsyncData.Uninitialized -> Unit @@ -147,8 +147,8 @@ internal fun ConfirmAccountProviderViewPreview( ConfirmAccountProviderView( state = state, onOidcDetails = {}, - onLoginPasswordNeeded = {}, - onLearnMoreClicked = {}, + onNeedLoginPassword = {}, + onLearnMoreClick = {}, onChange = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt index f444c7a7dc..e0f611834a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -47,7 +47,7 @@ class LoginPasswordNode @AssistedInject constructor( LoginPasswordView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, onWaitListError = ::onWaitListError, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 51ed3fcf6f..55f4d9e028 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -80,7 +80,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LoginPasswordView( state: LoginPasswordState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, onWaitListError: (LoginFormState) -> Unit, modifier: Modifier = Modifier, ) { @@ -103,7 +103,7 @@ fun LoginPasswordView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } ) { padding -> @@ -141,7 +141,7 @@ fun LoginPasswordView( // Submit Box( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp) ) { ButtonColumnMolecule { Button( @@ -201,11 +201,14 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginEmailUsername) - .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { - val sanitized = it.sanitize() - loginFieldState = sanitized - eventSink(LoginPasswordEvents.SetLogin(sanitized)) - }), + .autofill( + autofillTypes = listOf(AutofillType.Username), + onFill = { + val sanitized = it.sanitize() + loginFieldState = sanitized + eventSink(LoginPasswordEvents.SetLogin(sanitized)) + } + ), placeholder = { Text(text = stringResource(CommonStrings.common_username)) }, @@ -247,11 +250,14 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) - .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { - val sanitized = it.sanitize() - passwordFieldState = sanitized - eventSink(LoginPasswordEvents.SetPassword(sanitized)) - }), + .autofill( + autofillTypes = listOf(AutofillType.Password), + onFill = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(LoginPasswordEvents.SetPassword(sanitized)) + } + ), onValueChange = { val sanitized = it.sanitize() passwordFieldState = sanitized @@ -304,7 +310,7 @@ private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreview { LoginPasswordView( state = state, - onBackPressed = {}, + onBackClick = {}, onWaitListError = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt new file mode 100644 index 0000000000..038d9b1998 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationNode.kt @@ -0,0 +1,53 @@ +/* + * 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.login.impl.screens.qrcode.confirmation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.architecture.inputs + +@ContributesNode(QrCodeLoginScope::class) +class QrCodeConfirmationNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onCancel() + } + + private val step = inputs() + + private fun onCancel() { + plugins().forEach { it.onCancel() } + } + + @Composable + override fun View(modifier: Modifier) { + QrCodeConfirmationView( + step = step, + onCancel = ::onCancel, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt new file mode 100644 index 0000000000..b41433d5c8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStep.kt @@ -0,0 +1,31 @@ +/* + * 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.login.impl.screens.qrcode.confirmation + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface QrCodeConfirmationStep : NodeInputs, Parcelable { + @Parcelize + data class DisplayCheckCode(val code: String) : QrCodeConfirmationStep + + @Parcelize + data class DisplayVerificationCode(val code: String) : QrCodeConfirmationStep +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewProvider.kt new file mode 100644 index 0000000000..cd6125fee8 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationStepPreviewProvider.kt @@ -0,0 +1,28 @@ +/* + * 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.login.impl.screens.qrcode.confirmation + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class QrCodeConfirmationStepPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + QrCodeConfirmationStep.DisplayCheckCode("12"), + QrCodeConfirmationStep.DisplayVerificationCode("123456"), + QrCodeConfirmationStep.DisplayVerificationCode("123456789"), + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt new file mode 100644 index 0000000000..825dbe3ad1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationView.kt @@ -0,0 +1,167 @@ +/* + * 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.login.impl.screens.qrcode.confirmation + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun QrCodeConfirmationView( + step: QrCodeConfirmationStep, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler { + onCancel() + } + val icon = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> CompoundIcons.Computer() + is QrCodeConfirmationStep.DisplayVerificationCode -> CompoundIcons.LockSolid() + } + val title = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_title) + is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_title) + } + val subtitle = when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> stringResource(R.string.screen_qr_code_login_device_code_subtitle) + is QrCodeConfirmationStep.DisplayVerificationCode -> stringResource(R.string.screen_qr_code_login_verify_code_subtitle) + } + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.Default(icon), + title = title, + subTitle = subtitle, + content = { Content(step = step) }, + buttons = { Buttons(onCancel = onCancel) } + ) +} + +@Composable +private fun Content(step: QrCodeConfirmationStep) { + Column( + modifier = Modifier.padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (step) { + is QrCodeConfirmationStep.DisplayCheckCode -> { + Digits(code = step.code) + Spacer(modifier = Modifier.height(32.dp)) + WaitingForOtherDevice() + } + is QrCodeConfirmationStep.DisplayVerificationCode -> { + Digits(code = step.code) + Spacer(modifier = Modifier.height(32.dp)) + WaitingForOtherDevice() + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun Digits(code: String) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + code.forEach { + Text( + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)) + .background(ElementTheme.colors.bgActionSecondaryPressed) + .padding(horizontal = 16.dp, vertical = 17.dp), + text = it.toString() + ) + } + } +} + +@Composable +private fun WaitingForOtherDevice() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .padding(2.dp), + strokeWidth = 2.dp, + ) + Text( + text = stringResource(R.string.screen_qr_code_login_verify_code_loading), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun Buttons( + onCancel: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_cancel), + onClick = onCancel + ) + } +} + +@PreviewsDayNight +@Composable +internal fun QrCodeConfirmationViewPreview(@PreviewParameter(QrCodeConfirmationStepPreviewProvider::class) step: QrCodeConfirmationStep) { + ElementPreview { + QrCodeConfirmationView( + step = step, + onCancel = {}, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt new file mode 100644 index 0000000000..66ddd8bfbe --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorNode.kt @@ -0,0 +1,58 @@ +/* + * 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.login.impl.screens.qrcode.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.core.meta.BuildMeta + +@ContributesNode(QrCodeLoginScope::class) +class QrCodeErrorNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val buildMeta: BuildMeta, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onRetry() + } + + private fun onRetry() { + plugins().forEach { it.onRetry() } + } + + private val qrCodeErrorScreenType = inputs() + + @Composable + override fun View(modifier: Modifier) { + QrCodeErrorView( + modifier = modifier, + errorScreenType = qrCodeErrorScreenType, + appName = buildMeta.productionApplicationName, + onRetry = ::onRetry, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt new file mode 100644 index 0000000000..04aac518e1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorView.kt @@ -0,0 +1,161 @@ +/* + * 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.login.impl.screens.qrcode.error + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun QrCodeErrorView( + errorScreenType: QrCodeErrorScreenType, + appName: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler { + onRetry() + } + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.AlertSolid, + title = titleText(errorScreenType, appName), + subTitle = subtitleText(errorScreenType, appName), + content = { Content(errorScreenType) }, + buttons = { Buttons(onRetry) } + ) +} + +@Composable +private fun titleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) { + QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title) + QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title) + QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title) + QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title) + QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title) + QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName) + is QrCodeErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong) +} + +@Composable +private fun subtitleText(errorScreenType: QrCodeErrorScreenType, appName: String) = when (errorScreenType) { + QrCodeErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle) + QrCodeErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle) + QrCodeErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle) + QrCodeErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName) + QrCodeErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description) + QrCodeErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName) + is QrCodeErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description) +} + +@Composable +private fun ColumnScope.InsecureChannelDetectedError() { + Text( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header), + style = ElementTheme.typography.fontBodyLgMedium, + textAlign = TextAlign.Center, + ) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)), + ) + ) +} + +@Composable +private fun Content(errorScreenType: QrCodeErrorScreenType) { + when (errorScreenType) { + QrCodeErrorScreenType.InsecureChannelDetected -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + InsecureChannelDetectedError() + } + } + else -> Unit + } +} + +@Composable +private fun Buttons(onRetry: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_qr_code_login_start_over_button), + onClick = onRetry + ) +} + +@PreviewsDayNight +@Composable +internal fun QrCodeErrorViewPreview(@PreviewParameter(QrCodeErrorScreenTypeProvider::class) errorScreenType: QrCodeErrorScreenType) { + ElementPreview { + QrCodeErrorView( + errorScreenType = errorScreenType, + appName = "Element X", + onRetry = {} + ) + } +} + +class QrCodeErrorScreenTypeProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + QrCodeErrorScreenType.Cancelled, + QrCodeErrorScreenType.Declined, + QrCodeErrorScreenType.Expired, + QrCodeErrorScreenType.ProtocolNotSupported, + QrCodeErrorScreenType.InsecureChannelDetected, + QrCodeErrorScreenType.SlidingSyncNotAvailable, + QrCodeErrorScreenType.UnknownError + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt new file mode 100644 index 0000000000..982d7ac1af --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroEvents.kt @@ -0,0 +1,21 @@ +/* + * 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.login.impl.screens.qrcode.intro + +sealed interface QrCodeIntroEvents { + data object Continue : QrCodeIntroEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt new file mode 100644 index 0000000000..b207462bd1 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroNode.kt @@ -0,0 +1,59 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope + +@ContributesNode(QrCodeLoginScope::class) +class QrCodeIntroNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: QrCodeIntroPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onCancelClicked() + fun onContinue() + } + + private fun onCancelClicked() { + plugins().forEach { it.onCancelClicked() } + } + + private fun onContinue() { + plugins().forEach { it.onContinue() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + QrCodeIntroView( + state = state, + onBackClick = ::onCancelClicked, + onContinue = ::onContinue, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt new file mode 100644 index 0000000000..cf51dc9c30 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt @@ -0,0 +1,69 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import android.Manifest +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsPresenter +import javax.inject.Inject + +class QrCodeIntroPresenter @Inject constructor( + private val buildMeta: BuildMeta, + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingPermissionRequest by mutableStateOf(false) + + @Composable + override fun present(): QrCodeIntroState { + val cameraPermissionState = cameraPermissionPresenter.present() + var canContinue by remember { mutableStateOf(false) } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + canContinue = true + } + } + + fun handleEvents(event: QrCodeIntroEvents) { + when (event) { + QrCodeIntroEvents.Continue -> if (cameraPermissionState.permissionGranted) { + canContinue = true + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + } + } + } + + return QrCodeIntroState( + appName = buildMeta.applicationName, + desktopAppName = buildMeta.desktopApplicationName, + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = ::handleEvents + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt new file mode 100644 index 0000000000..a385797882 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroState.kt @@ -0,0 +1,27 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import io.element.android.libraries.permissions.api.PermissionsState + +data class QrCodeIntroState( + val appName: String, + val desktopAppName: String, + val cameraPermissionState: PermissionsState, + val canContinue: Boolean, + val eventSink: (QrCodeIntroEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt new file mode 100644 index 0000000000..ba1ebeab48 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroStateProvider.kt @@ -0,0 +1,48 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState + +open class QrCodeIntroStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aQrCodeIntroState(), + aQrCodeIntroState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)), + // Add other state here + ) +} + +fun aQrCodeIntroState( + appName: String = "AppName", + desktopAppName: String = "Element", + cameraPermissionState: PermissionsState = aPermissionsState( + showDialog = false, + permission = Manifest.permission.CAMERA, + ), + canContinue: Boolean = false, + eventSink: (QrCodeIntroEvents) -> Unit = {}, +) = QrCodeIntroState( + appName = appName, + desktopAppName = desktopAppName, + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = eventSink +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt new file mode 100644 index 0000000000..9d06e4e00d --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroView.kt @@ -0,0 +1,115 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.permissions.api.PermissionsView +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun QrCodeIntroView( + state: QrCodeIntroState, + onBackClick: () -> Unit, + onContinue: () -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnContinue by rememberUpdatedState(onContinue) + LaunchedEffect(state.canContinue) { + if (state.canContinue) { + latestOnContinue() + } + } + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(id = R.string.screen_qr_code_login_initial_state_title, state.desktopAppName), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) + + PermissionsView( + title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title), + content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, state.appName), + icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) }, + state = state.cameraPermissionState, + ) +} + +@Composable +private fun Content(state: QrCodeIntroState) { + NumberedListOrganism( + modifier = Modifier.padding(top = 50.dp, start = 20.dp, end = 20.dp), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_1, state.desktopAppName)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_2)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_qr_code_login_initial_state_item_3, + stringResource(R.string.screen_qr_code_login_initial_state_item_3_action), + ), + boldText = stringResource(R.string.screen_qr_code_login_initial_state_item_3_action) + ), + AnnotatedString(stringResource(R.string.screen_qr_code_login_initial_state_item_4)), + ), + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: QrCodeIntroState, +) { + Button( + text = stringResource(id = CommonStrings.action_continue), + modifier = Modifier.fillMaxWidth(), + onClick = { + state.eventSink.invoke(QrCodeIntroEvents.Continue) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun QrCodeIntroViewPreview(@PreviewParameter(QrCodeIntroStateProvider::class) state: QrCodeIntroState) = ElementPreview { + QrCodeIntroView( + state = state, + onBackClick = {}, + onContinue = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt new file mode 100644 index 0000000000..d4b24d68d6 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanEvents.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.login.impl.screens.qrcode.scan + +sealed interface QrCodeScanEvents { + data class QrCodeScanned(val code: ByteArray) : QrCodeScanEvents + data object TryAgain : QrCodeScanEvents +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt new file mode 100644 index 0000000000..d2c7a418b0 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanNode.kt @@ -0,0 +1,60 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.login.impl.di.QrCodeLoginScope +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData + +@ContributesNode(QrCodeLoginScope::class) +class QrCodeScanNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: QrCodeScanPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onScannedCode(qrCodeLoginData: MatrixQrCodeLoginData) + fun onCancelClicked() + } + + private fun onQrCodeDataReady(qrCodeLoginData: MatrixQrCodeLoginData) { + plugins().forEach { it.onScannedCode(qrCodeLoginData) } + } + + private fun onCancelClicked() { + plugins().forEach { it.onCancelClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + QrCodeScanView( + state = state, + onQrCodeDataReady = ::onQrCodeDataReady, + onBackClick = ::onCancelClicked, + modifier = modifier + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt new file mode 100644 index 0000000000..aeedc32542 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenter.kt @@ -0,0 +1,110 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +class QrCodeScanPresenter @Inject constructor( + private val qrCodeLoginDataFactory: MatrixQrCodeLoginDataFactory, + private val qrCodeLoginManager: QrCodeLoginManager, + private val coroutineDispatchers: CoroutineDispatchers, +) : Presenter { + private var isScanning by mutableStateOf(true) + + private val isProcessingCode = AtomicBoolean(false) + + @Composable + override fun present(): QrCodeScanState { + val coroutineScope = rememberCoroutineScope() + val authenticationAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + + ObserveQRCodeLoginFailures { + authenticationAction.value = AsyncAction.Failure(it) + } + + fun handleEvents(event: QrCodeScanEvents) { + when (event) { + QrCodeScanEvents.TryAgain -> { + isScanning = true + authenticationAction.value = AsyncAction.Uninitialized + } + is QrCodeScanEvents.QrCodeScanned -> { + isScanning = false + coroutineScope.getQrCodeData(authenticationAction, event.code) + } + } + } + + return QrCodeScanState( + isScanning = isScanning, + authenticationAction = authenticationAction.value, + eventSink = ::handleEvents + ) + } + + @Composable + private fun ObserveQRCodeLoginFailures(onQrCodeLoginError: (QrLoginException) -> Unit) { + LaunchedEffect(onQrCodeLoginError) { + qrCodeLoginManager.currentLoginStep + .onEach { state -> + if (state is QrCodeLoginStep.Failed) { + onQrCodeLoginError(state.error) + // The error was handled here, reset the login state + qrCodeLoginManager.reset() + } + } + .launchIn(this) + } + } + + private fun CoroutineScope.getQrCodeData(codeScannedAction: MutableState>, code: ByteArray) { + if (codeScannedAction.value.isSuccess() || isProcessingCode.compareAndSet(true, true)) return + + launch(coroutineDispatchers.computation) { + suspend { + qrCodeLoginDataFactory.parseQrCodeData(code).onFailure { + Timber.e(it, "Error parsing QR code data") + }.getOrThrow() + }.runCatchingUpdatingState(codeScannedAction) + }.invokeOnCompletion { + isProcessingCode.set(false) + } + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt new file mode 100644 index 0000000000..45657c0226 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanState.kt @@ -0,0 +1,26 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData + +data class QrCodeScanState( + val isScanning: Boolean, + val authenticationAction: AsyncAction, + val eventSink: (QrCodeScanEvents) -> Unit +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt new file mode 100644 index 0000000000..764c46643a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanStateProvider.kt @@ -0,0 +1,43 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException + +open class QrCodeScanStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aQrCodeScanState(), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Loading), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(Exception("Error"))), + aQrCodeScanState(isScanning = false, authenticationAction = AsyncAction.Failure(QrLoginException.OtherDeviceNotSignedIn)), + // Add other state here + ) +} + +fun aQrCodeScanState( + isScanning: Boolean = true, + authenticationAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (QrCodeScanEvents) -> Unit = {}, +) = QrCodeScanState( + isScanning = isScanning, + authenticationAction = authenticationAction, + eventSink = eventSink +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt new file mode 100644 index 0000000000..97925c11ad --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt @@ -0,0 +1,214 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.login.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.modifiers.cornerBorder +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.qrcode.QrCodeCameraView + +@Composable +fun QrCodeScanView( + state: QrCodeScanState, + onBackClick: () -> Unit, + onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit, + modifier: Modifier = Modifier, +) { + val updatedOnQrCodeDataReady by rememberUpdatedState(onQrCodeDataReady) + // QR code data parsed successfully, notify the parent node + if (state.authenticationAction is AsyncAction.Success) { + LaunchedEffect(state.authenticationAction, updatedOnQrCodeDataReady) { + updatedOnQrCodeDataReady(state.authenticationAction.data) + } + } + + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(R.string.screen_qr_code_login_scanning_state_title), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) +} + +@Composable +private fun Content( + state: QrCodeScanState, +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val modifier = if (constraints.maxWidth > constraints.maxHeight) { + Modifier.fillMaxHeight() + } else { + Modifier.fillMaxWidth() + }.then( + Modifier + .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp) + .squareSize() + .cornerBorder( + strokeWidth = 4.dp, + color = ElementTheme.colors.textPrimary, + cornerSizeDp = 42.dp, + ) + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + QrCodeCameraView( + modifier = Modifier.fillMaxSize(), + onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) }, + renderPreview = state.isScanning, + ) + } + } +} + +@Composable +private fun ColumnScope.Buttons( + state: QrCodeScanState, +) { + Column(Modifier.heightIn(min = 130.dp)) { + when (state.authenticationAction) { + is AsyncAction.Failure -> { + Button( + text = stringResource(id = R.string.screen_qr_code_login_invalid_scan_state_retry_button), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + onClick = { + state.eventSink.invoke(QrCodeScanEvents.TryAgain) + } + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + val error = state.authenticationAction.error + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Error(), + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = when (error) { + is QrLoginException.OtherDeviceNotSignedIn -> { + stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_subtitle) + } + else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle) + }, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + style = ElementTheme.typography.fontBodySmMedium, + ) + } + Text( + text = when (error) { + is QrLoginException.OtherDeviceNotSignedIn -> { + stringResource(R.string.screen_qr_code_login_device_not_signed_in_scan_state_description) + } + else -> stringResource(R.string.screen_qr_code_login_invalid_scan_state_description) + }, + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + AsyncAction.Loading, is AsyncAction.Success -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + Text( + text = stringResource(R.string.screen_qr_code_login_connecting_subtitle), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + AsyncAction.Uninitialized, + AsyncAction.Confirming -> Unit + } + } +} + +@PreviewsDayNight +@Composable +internal fun QrCodeScanViewPreview(@PreviewParameter(QrCodeScanStateProvider::class) state: QrCodeScanState) = ElementPreview { + QrCodeScanView( + state = state, + onQrCodeDataReady = {}, + onBackClick = {}, + ) +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt index 834d0400da..ca432cc619 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt @@ -50,9 +50,9 @@ class SearchAccountProviderNode @AssistedInject constructor( SearchAccountProviderView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, - onLearnMoreClicked = { openLearnMorePage(context) }, - onDone = ::onDone, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = ::onDone, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 69b145ffd1..9cfa64ba98 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -77,9 +77,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SearchAccountProviderView( state: SearchAccountProviderState, - onBackPressed: () -> Unit, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -88,7 +88,7 @@ fun SearchAccountProviderView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) } ) { padding -> @@ -188,8 +188,8 @@ fun SearchAccountProviderView( } ChangeServerView( state = state.changeServerState, - onLearnMoreClicked = onLearnMoreClicked, - onDone = onDone, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, ) } } @@ -214,8 +214,8 @@ private fun HomeserverData.toAccountProvider(): AccountProvider { internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview { SearchAccountProviderView( state = state, - onBackPressed = {}, - onLearnMoreClicked = {}, - onDone = {}, + onBackClick = {}, + onLearnMoreClick = {}, + onSuccess = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt index e27d5f5608..237e7ed8ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt @@ -42,11 +42,11 @@ class WaitListNode @AssistedInject constructor( private val presenter = presenterFactory.create(inputs.loginFormState) interface Callback : Plugin { - fun onCancelClicked() + fun onCancelClick() } - private fun onCancelClicked() { - plugins().forEach { it.onCancelClicked() } + private fun onCancelClick() { + plugins().forEach { it.onCancelClick() } } @Composable @@ -54,7 +54,7 @@ class WaitListNode @AssistedInject constructor( val state = presenter.present() WaitListView( state = state, - onCancelClicked = ::onCancelClicked, + onCancelClick = ::onCancelClick, modifier = modifier ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt index 7a47255b6c..e21be61b5c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -48,7 +48,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun WaitListView( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -57,7 +57,7 @@ fun WaitListView( else -> Unit } } - WaitListContent(state, onCancelClicked, modifier) + WaitListContent(state, onCancelClick, modifier) } @Composable @@ -81,7 +81,7 @@ private fun WaitListError(state: WaitListState) { @Composable private fun WaitListContent( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -109,7 +109,7 @@ private fun WaitListContent( title = title, subtitle = subtitle, ) { - OverallContent(state, onCancelClicked) + OverallContent(state, onCancelClick) } WaitListError(state) } @@ -118,14 +118,14 @@ private fun WaitListContent( @Composable private fun OverallContent( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { if (state.loginAction !is AsyncData.Success) { CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) { TextButton( text = stringResource(CommonStrings.action_cancel), - onClick = onCancelClicked, + onClick = onCancelClick, ) } } @@ -147,6 +147,6 @@ private fun OverallContent( internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview { WaitListView( state = state, - onCancelClicked = {}, + onCancelClick = {}, ) } diff --git a/features/login/impl/src/main/res/values-be/translations.xml b/features/login/impl/src/main/res/values-be/translations.xml index 228deb957c..0163b28c5b 100644 --- a/features/login/impl/src/main/res/values-be/translations.xml +++ b/features/login/impl/src/main/res/values-be/translations.xml @@ -4,7 +4,7 @@ "Адрас хатняга сервера" "Увядзіце пошукавы запыт або адрас дамена." "Пошук кампаніі, супольнасці або прыватнага сервера." - "Знайдзіце правайдара ўліковых запісаў" + "Знайдзіце правайдара ўліковага запісу" "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." "Вы збіраецеся ўвайсці ў %s" "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." @@ -29,14 +29,56 @@ "Увядзіце свае даныя" "Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі." "Сардэчна запрашаем!" - "Увайдзіце ў %1$s" + "Увайсці ў %1$s" + "Ўсталяванне бяспечнага злучэння" + "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх." + "Што зараз?" + "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема" + "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi." + "Калі гэта не дапамагло, увайдзіце ўручную" + "Злучэнне небяспечнае" + "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе." + "Увядзіце наступны нумар на іншай прыладзе." + "Увайдзіце ў сістэму на іншай прыладзе і паспрабуйце яшчэ раз, або выкарыстоўвайце іншую прыладу на якой ужо выкананы ўваход." + "Другая прылада не ўвайшла ў сістэму" + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Уваход на іншай прыладзе быў адхілены." + "Уваход адхілены" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода. + +Паспрабуйце ўвайсці ў сістэму ўручную або адскануйце QR-код з дапамогай іншай прылады." + "QR-код не падтрымліваецца" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" + "Гатовы да сканавання" + "Адкрыйце %1$s на настольнай прыладзе" + "Націсніце на свой аватар" + "Выберыце %1$s" + "“Звязаць новую прыладу”" + "Адсканіруйце QR-код з дапамогай гэтай прылады" + "Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код" + "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе." + "Паўтарыць спробу" + "Няправільны QR-код" + "Перайсці ў налады камеры" + "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады." + "Дазвольце доступ да камеры для сканавання QR-кода" + "Сканаваць QR-код" + "Пачаць спачатку" + "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз." + "У чаканні іншай прылады" + "Ваш правайдэр уліковага запісу можа запытаць наступны код для праверкі ўваходу." + "Ваш код спраўджання" "Змяніць правайдара ўліковага запісу" "Прыватны сервер для супрацоўнікаў Element." "Matrix - гэта адкрытая сетка для бяспечнай, дэцэнтралізаванай сувязі." "Тут будуць захоўвацца вашыя размовы - сапраўды гэтак жа, як вы выкарыстоўваеце паштовага правайдара для захоўвання сваіх лістоў." "Вы збіраецеся ўвайсці ў %1$s" "Вы збіраецеся стварыць уліковы запіс на %1$s" - "Зараз існуе высокі попыт на %1$s на %2$s. Калі ласка, вярніцеся ў дадатак праз некалькі дзён і паспрабуйце зноў. + "Зараз існуе высокі попыт на %1$s на %2$s. Калі ласка, вярніцеся ў праграму праз некалькі дзён і паспрабуйце зноў. Дзякуй за цярпенне!" "Вітаем у %1$s!" diff --git a/features/login/impl/src/main/res/values-bg/translations.xml b/features/login/impl/src/main/res/values-bg/translations.xml index f4e52f7ac6..cfc51c3f8c 100644 --- a/features/login/impl/src/main/res/values-bg/translations.xml +++ b/features/login/impl/src/main/res/values-bg/translations.xml @@ -18,6 +18,7 @@ "Matrix е отворена мрежа за сигурна, децентрализирана комуникация." "Добре дошли отново!" "Влизане в %1$s" + "Повторен опит" "Промяна на доставчика на акаунт" "Matrix е отворена мрежа за сигурна, децентрализирана комуникация." "Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли." diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml index 0616f621b1..41ac3ced66 100644 --- a/features/login/impl/src/main/res/values-cs/translations.xml +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -30,6 +30,48 @@ "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." "Vítejte zpět!" "Přihlaste se k %1$s" + "Navazování zabezpečeného spojení" + "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat." + "Co teď?" + "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí" + "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi" + "Pokud to nefunguje, přihlaste se ručně" + "Připojení není zabezpečené" + "Budete požádáni o zadání dvou níže uvedených číslic." + "Zadejte níže uvedené číslo na svém dalším zařízení" + "Přihlaste se k druhému zařízení a zkuste to znovu, nebo použijte jiné zařízení, které už je přihlášené." + "Druhé zařízení není přihlášeno" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Požadavek na vašem druhém zařízení nebyl přijat." + "Přihlášení odmítnuto" + "Platnost přihlášení vypršela. Zkuste to prosím znovu." + "Přihlášení nebylo dokončeno včas" + "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu. + +Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení." + "QR kód není podporován" + "Váš poskytovatel účtu nepodporuje %1$s." + "%1$s není podporováno" + "Připraveno ke skenování" + "Otevřete %1$s na stolním počítači" + "Klikněte na svůj avatar" + "Vybrat %1$s" + "\"Připojit nové zařízení\"" + "Naskenujte QR kód pomocí tohoto zařízení" + "Otevřete %1$s na jiném zařízení pro získání QR kódu" + "Použijte QR kód zobrazený na druhém zařízení." + "Zkusit znovu" + "Špatný QR kód" + "Přejděte na nastavení fotoaparátu" + "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení." + "Povolte přístup k fotoaparátu a naskenujte QR kód" + "Naskenujte QR kód" + "Začít znovu" + "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu." + "Čekání na vaše další zařízení" + "Váš poskytovatel účtu může požádat o následující kód pro ověření přihlášení." + "Váš ověřovací kód" "Změnit poskytovatele účtu" "Soukromý server pro zaměstnance Elementu." "Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci." diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index 27fd226517..398bacb306 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -30,6 +30,34 @@ "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation." "Willkommen zurück!" "Anmelden bei %1$s" + "Sichere Verbindung aufbauen" + "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden." + "Und jetzt?" + "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war." + "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN." + "Wenn das nicht funktioniert, melde dich manuell an" + "Die Verbindung ist nicht sicher" + "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben." + "Trage die unten angezeigte Zahl auf einem anderen Device ein" + "Bereit zum Scannen" + "%1$s auf einem Desktop-Gerät öffnen" + "Klick auf deinen Avatar" + "Wähle %1$s" + "\"Neues Gerät verknüpfen\"" + "Scanne den QR-Code mit diesem Gerät" + "Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten" + "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird." + "Erneut versuchen" + "Falscher QR-Code" + "Gehe zu den Kameraeinstellungen" + "Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren." + "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes" + "QR-Code scannen" + "Neu beginnen" + "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut." + "Warten auf dein anderes Gerät" + "Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen." + "Dein Verifizierungscode" "Kontoanbieter wechseln" "Ein privater Server für die Mitarbeiter von Element." "Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation." diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 31df1a3d04..c7537c9d7e 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -30,6 +30,7 @@ "Matrix es una red abierta para una comunicación segura y descentralizada." "¡Hola de nuevo!" "Iniciar sesión en %1$s" + "Inténtalo de nuevo" "Cambiar el proveedor de la cuenta" "Un servidor privado para los empleados de Element." "Matrix es una red abierta para una comunicación segura y descentralizada." diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 01f11edaf6..49e513c2a8 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -30,6 +30,46 @@ "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée." "Content de vous revoir !" "Connectez-vous à %1$s" + "Établissement d’une connexion sécurisée" + "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier." + "Et maintenant ?" + "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau" + "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi" + "Si cela ne fonctionne pas, connectez-vous manuellement" + "La connexion n’est pas sécurisée" + "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil." + "Saisissez le nombre ci-dessous sur votre autre appareil" + "Connectez-vous à votre autre appareil, puis réessayez, ou utilisez un autre appareil déjà connecté." + "Autre appareil non connecté" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "La connexion a été refusée sur l’autre appareil." + "Connexion refusée" + "Connexion expirée. Veuillez essayer à nouveau." + "La connexion a pris trop de temps." + "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil." + "Code QR non supporté" + "Votre fournisseur de compte ne supporte pas %1$s." + "%1$s n’est pas supporté" + "Prêt à scanner" + "Ouvrez %1$s sur un ordinateur" + "Cliquez sur votre image de profil" + "Choisissez %1$s" + "“Associer une nouvelle session”" + "Scanner le code QR avec cet appareil" + "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" + "Scannez le QR code affiché sur l’autre appareil." + "Essayer à nouveau" + "QR code erroné" + "Accéder aux paramètres de l’appareil photo" + "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer." + "Autoriser l’usage de la caméra pour scanner le code QR" + "Scannez le QR code" + "Recommencer" + "Une erreur inattendue s’est produite. Veuillez réessayer." + "En attente de votre autre session" + "Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion." + "Votre code de vérification" "Changer de fournisseur de compte" "Un serveur privé pour les employés d’Element." "Matrix est un réseau ouvert pour une communication sécurisée et décentralisée." diff --git a/features/login/impl/src/main/res/values-hu/translations.xml b/features/login/impl/src/main/res/values-hu/translations.xml index f81e84a86f..4baccf3c51 100644 --- a/features/login/impl/src/main/res/values-hu/translations.xml +++ b/features/login/impl/src/main/res/values-hu/translations.xml @@ -30,6 +30,34 @@ "A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz." "Örülünk, hogy visszatért!" "Bejelentkezés ide: %1$s" + "Biztonságos kapcsolat létesítése" + "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk." + "Most mi lesz?" + "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt." + "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát" + "Ha ez nem működik, jelentkezzen be kézileg" + "A kapcsolat nem biztonságos" + "A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén." + "Adja meg az alábbi számot a másik eszközén" + "Készen áll a beolvasásra" + "Nyissa meg az %1$set egy asztali eszközön" + "Kattintson a profilképére" + "Válassza ezt: %1$s" + "„Új eszköz összekapcsolása”" + "Olvassa be a QR-kódot ezzel az eszközzel" + "Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez." + "Használja a másik eszközön látható QR-kódot." + "Próbálja újra" + "Hibás QR-kód" + "Ugrás a kamerabeállításokhoz" + "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját." + "Engedélyezze a kamera elérését a QR-kód beolvasásához" + "Olvassa be a QR-kódot" + "Újrakezdés" + "Váratlan hiba történt. Próbálja meg újra." + "Várakozás a másik eszközre" + "A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez." + "Az Ön ellenőrzőkódja" "Fiókszolgáltató módosítása" "Egy privát kiszolgáló az Element alkalmazottai számára." "A Matrix egy nyitott hálózat a biztonságos, decentralizált kommunikációhoz." diff --git a/features/login/impl/src/main/res/values-in/translations.xml b/features/login/impl/src/main/res/values-in/translations.xml index 17d61faae2..4f32383f20 100644 --- a/features/login/impl/src/main/res/values-in/translations.xml +++ b/features/login/impl/src/main/res/values-in/translations.xml @@ -30,6 +30,32 @@ "Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi." "Selamat datang kembali!" "Masuk ke %1$s" + "Membuat koneksi" + "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka." + "Apa sekarang?" + "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan" + "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi" + "Jika tidak berhasil, masuk secara manual" + "Koneksi tidak aman" + "Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini." + "Masukkan nomor di perangkat Anda" + "Buka %1$s di perangkat desktop" + "Klik pada avatar Anda" + "Pilih %1$s" + "“Tautkan perangkat baru”" + "Buka %1$s di perangkat lain untuk mendapatkan kode QR" + "Gunakan kode QR yang ditampilkan di perangkat lain." + "Coba lagi" + "Kode QR salah" + "Pergi ke pengaturan kamera" + "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan." + "Izinkan akses kamera untuk memindai kode QR" + "Pindai kode QR" + "Mulai dari awal" + "Terjadi kesalahan tak terduga. Silakan coba lagi." + "Menunggu perangkat Anda yang lain" + "Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk." + "Kode verifikasi Anda" "Ubah penyedia akun" "Server pribadi untuk karyawan Element." "Matrix adalah jaringan terbuka untuk komunikasi yang aman dan terdesentralisasi." diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml index dcfc5c7b08..7581d61336 100644 --- a/features/login/impl/src/main/res/values-it/translations.xml +++ b/features/login/impl/src/main/res/values-it/translations.xml @@ -30,6 +30,33 @@ "Matrix è una rete aperta per comunicazioni sicure e decentralizzate." "Bentornato!" "Accedi a %1$s" + "Stabilendo la connessione" + "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro." + "E adesso?" + "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete." + "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi." + "Se il problema persiste, accedi manualmente" + "La connessione non è sicura" + "Ti verrà chiesto di inserire le due cifre mostrate su questo dispositivo." + "Inserisci il numero qui sotto sull\'altro dispositivo" + "Apri %1$s su un dispositivo desktop" + "Clicca sul tuo avatar" + "Seleziona %1$s" + "\"Collega un nuovo dispositivo\"" + "Segui le istruzioni mostrate" + "Apri %1$s su un altro dispositivo per ottenere il codice QR" + "Usa il codice QR mostrato sull\'altro dispositivo." + "Riprova" + "Codice QR sbagliato" + "Vai alle impostazioni della fotocamera" + "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo." + "Consenti l\'accesso alla fotocamera per la scansione del codice QR" + "Scansiona il codice QR" + "Ricomincia" + "Si è verificato un errore inatteso. Riprova." + "In attesa dell\'altro dispositivo" + "Il fornitore dell\'account potrebbe richiedere il seguente codice per verificare l\'accesso." + "Il tuo codice di verifica" "Cambia fornitore dell\'account" "Un server privato per i dipendenti di Element." "Matrix è una rete aperta per comunicazioni sicure e decentralizzate." diff --git a/features/login/impl/src/main/res/values-pt/translations.xml b/features/login/impl/src/main/res/values-pt/translations.xml index 09e8b8ba01..9720234463 100644 --- a/features/login/impl/src/main/res/values-pt/translations.xml +++ b/features/login/impl/src/main/res/values-pt/translations.xml @@ -30,6 +30,35 @@ "A Matrix é uma rede aberta de comunicação descentralizada e segura." "Bem-vindo(a) de volta!" "Iniciar sessão em %1$s" + "A estabelecer uma ligação segura" + "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles." + "E agora?" + "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede" + "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis." + "Se isso não funcionar, inicia sessão manualmente" + "Ligação insegura" + "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo." + "Insere o número abaixo no teu dispositivo" + "Pedido de início de sessão cancelado" + "Pronto para ler" + "Abre a %1$s num computador" + "Carrega no teu avatar" + "Seleciona %1$s" + "“Ligar novo dispositivo”" + "Lê o código QR com este dispositivo" + "Abre a %1$s noutro dispositivo para obteres o código QR" + "Lê o código QR apresentado no outro dispositivo." + "Tentar novamente" + "Código QR inválido" + "Ir para as configurações da câmara" + "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo." + "Permitir o acesso à câmara para ler o código QR" + "Ler o código QR" + "Começar de novo" + "Ocorreu um erro inesperado. Tenta novamente." + "À espera do teu outro dispositivo" + "O teu fornecedor de conta pode pedir o seguinte código para verificar o início de sessão." + "O teu código de verificação" "Alterar operador de conta" "Um servidor privado para funcionários da Element." "A Matrix é uma rede aberta de comunicação descentralizada e segura." diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index 8087fc7c44..e978485541 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -30,6 +30,34 @@ "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." "Bine ați revenit!" "Conectați-vă la %1$s" + "Se stabilește o conexiune securizată" + "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." + "Și acum?" + "Încercați să vă conectați din nou cu un cod QR în cazul în care a fost o problemă de rețea." + "Dacă întâmpinați aceeași problemă, încercați o altă rețea Wi-Fi sau utilizați datele mobile în loc de Wi-Fi." + "Dacă nu funcționează, conectați-vă manual" + "Conexiunea nu este sigură" + "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." + "Introduceți numărul de mai jos pe celălalt dispozitiv" + "Gata de scanare" + "Deschideți %1$s pe un dispozitiv desktop" + "Faceți clic pe avatarul dumneavoastră" + "Selectați %1$s" + "„Conectați un dispozitiv nou”" + "Scanați codul QR cu acest dispozitiv" + "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" + "Utilizați codul QR afișat pe celălalt dispozitiv." + "Încercați din nou" + "Cod QR greșit" + "Mergeți la setările camerei" + "Trebuie să acordați permisiunea ca %1$s să folosească camera dispozitivului pentru a continua." + "Permiteți accesul la cameră pentru a scana codul QR" + "Scanați codul QR" + "Începeți din nou" + "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." + "În așteptarea celuilalt dispozitiv" + "Furnizorul dumneavoastră de cont poate solicita următorul cod pentru a verifica conectarea." + "Codul dumneavoastră de verificare" "Schimbați furnizorul contului" "Un server privat pentru angajații Element." "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml index ef643cd8a1..ed5940dd26 100644 --- a/features/login/impl/src/main/res/values-ru/translations.xml +++ b/features/login/impl/src/main/res/values-ru/translations.xml @@ -30,6 +30,46 @@ "Matrix — это открытая сеть для безопасной децентрализованной связи." "Рады видеть вас снова!" "Войти в %1$s" + "Установление безопасного соединения" + "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них." + "Что теперь?" + "Попробуйте снова войти в систему с помощью QR-кода, если это была сетевая проблема" + "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные" + "Если это не помогло, войдите вручную" + "Соединение не защищено" + "Вам нужно будет ввести две цифры, показанные на этом устройстве." + "Введите показанный номер на своем другом устройстве" + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Запрос не был принят на другом устройстве." + "Вход отклонен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Другое устройство не поддерживает вход в %s с помощью QR-кода. + +Попробуйте войти вручную или отсканируйте QR-код на другом устройстве." + "QR-код не поддерживается" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "Готово к сканированию" + "Откройте %1$s на настольном устройстве" + "Нажмите на свое изображение" + "Выбрать %1$s" + "\"Привязать новое устройство\"" + "Отсканируйте QR-код с помощью этого устройства" + "Откройте %1$s на другом устройстве, чтобы получить QR-код" + "Используйте QR-код, показанный на другом устройстве." + "Повторить попытку" + "Неверный QR-код" + "Перейдите в настройки камеры" + "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства." + "Разрешите доступ к камере для сканирования QR-кода" + "Сканировать QR-код" + "Начать заново" + "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз." + "В ожидании другого устройства" + "Поставщик учетной записи может запросить следующий код для подтверждения входа." + "Ваш код подтверждения" "Сменить учетную запись" "Частный сервер для сотрудников Element." "Matrix — это открытая сеть для безопасной децентрализованной связи." diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml index fcc55b038e..fea7879d01 100644 --- a/features/login/impl/src/main/res/values-sk/translations.xml +++ b/features/login/impl/src/main/res/values-sk/translations.xml @@ -30,6 +30,48 @@ "Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu." "Vitajte späť!" "Prihlásiť sa do %1$s" + "Nadväzovanie bezpečného spojenia" + "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať." + "Čo teraz?" + "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou" + "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta" + "Ak to nefunguje, prihláste sa manuálne" + "Pripojenie nie je bezpečené" + "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení." + "Zadajte nižšie uvedené číslo na vašom druhom zariadení" + "Prihláste sa do svojho druhého zariadenia a skúste to znova alebo použite iné zariadenie, ktoré už je prihlásené." + "Druhé zariadenie nie je prihlásené" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Prihlásenie bolo zamietnuté na druhom zariadení." + "Prihlásenie bolo odmietnuté" + "Platnosť prihlásenia vypršala. Skúste to prosím znova." + "Prihlásenie nebolo včas dokončené" + "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu. + +Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia." + "QR kód nie je podporovaný" + "Poskytovateľ vášho účtu nepodporuje %1$s." + "%1$s nie je podporovaný" + "Pripravené na skenovanie" + "Otvorte %1$s na stolnom zariadení" + "Kliknite na svoj obrázok" + "Vyberte %1$s" + "„Prepojiť nové zariadenie“" + "Naskenujte QR kód pomocou tohto zariadenia" + "Ak chcete získať QR kód, otvorte %1$s na inom zariadení" + "Použite QR kód zobrazený na druhom zariadení." + "Skúste to znova" + "Nesprávny QR kód" + "Prejsť na nastavenia fotoaparátu" + "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia." + "Povoľte prístup k fotoaparátu na naskenovanie QR kódu" + "Naskenovať QR kód" + "Začať odznova" + "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova." + "Čaká sa na vaše druhé zariadenie" + "Váš poskytovateľ účtu môže požiadať o nasledujúci kód na overenie prihlásenia." + "Váš overovací kód" "Zmeniť poskytovateľa účtu" "Súkromný server pre zamestnancov spoločnosti Element." "Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu." diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml index 011552465a..0362c6c0d0 100644 --- a/features/login/impl/src/main/res/values-sv/translations.xml +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -14,6 +14,8 @@ "Använd en annan kontoleverantör, till exempel din egen privata server eller ett jobbkonto." "Byt kontoleverantör" "Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp." + "Sliding Sync är inte tillgängligt på grund av ett problem i well-known-filen: +%1$s" "Den här servern stöder för närvarande inte sliding sync." "Hemserverns URL" "Du kan bara ansluta till en befintlig server som stöder sliding sync. Din hemserveradministratör måste konfigurera det. %1$s" @@ -22,11 +24,13 @@ "Detta konto har avaktiverats." "Felaktigt användarnamn och/eller lösenord" "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" + "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." "Den valda hemservern stöder inte lösenord eller OIDC-inloggning. Kontakta administratören eller välj en annan hemserver." "Ange dina uppgifter" "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." "Välkommen tillbaka!" "Logga in på %1$s" + "Försök igen" "Byt kontoleverantör" "En privat server för Element-anställda." "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." diff --git a/features/login/impl/src/main/res/values-uk/translations.xml b/features/login/impl/src/main/res/values-uk/translations.xml index df952171fc..1e01a8eba0 100644 --- a/features/login/impl/src/main/res/values-uk/translations.xml +++ b/features/login/impl/src/main/res/values-uk/translations.xml @@ -30,6 +30,7 @@ "Matrix — це відкрита мережа для безпечної, децентралізованої комунікації." "З поверненням!" "Увійти в %1$s" + "Спробуйте ще раз" "Змінити провайдера облікового запису" "Приватний сервер для співробітників Element." "Matrix — це відкрита мережа для безпечної, децентралізованої комунікації." diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml index 0127d21f8f..d345095c4b 100644 --- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml @@ -23,6 +23,7 @@ "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。" "歡迎回來!" "登入 %1$s" + "再試一次" "更改帳號提供者" "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。" "您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。" diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml index 76bbabb021..0311d9cd82 100644 --- a/features/login/impl/src/main/res/values-zh/translations.xml +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -30,6 +30,33 @@ "Matrix 是一个用于安全、去中心化通信的开放网络。" "欢迎回来!" "登录到 %1$s" + "建立安全连接" + "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。" + "现在怎么办?" + "如果这是网络问题,请尝试使用二维码再次登录" + "如果你遇到同样的问题,请尝试使用不同的 WiFi 网络或使用你的移动数据代替 WiFi" + "如果不起作用,请手动登录" + "连接不安全" + "您会被要求输入此设备上显示的两位数。" + "在您的其他设备上输入下面的数字" + "在桌面设备上打开 %1$s" + "点击你的头像" + "选择 %1$s" + "「连接新设备」" + "按照说明进行操作" + "在另一台设备上打开 %1$s 以获取二维码" + "使用其他设备上显示的二维码。" + "再试一次" + "二维码错误" + "转到摄像头设置" + "您需要授予 %1$s 使用设备摄像头的权限才能继续。" + "允许摄像头权限以扫描 QR 码" + "扫描二维码" + "重新开始" + "发生了意外错误。请再试一次。" + "等着您的其他设备" + "您的账户提供商可能会要求您提供以下代码来验证登录。" + "您的验证码" "更改账户提供者" "专为 Element 员工提供的私人服务器。" "Matrix 是一个用于安全、去中心化通信的开放网络。" diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index f268b464f1..cade81c4f2 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -30,6 +30,48 @@ "Matrix is an open network for secure, decentralised communication." "Welcome back!" "Sign in to %1$s" + "Establishing a secure connection" + "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them." + "What now?" + "Try signing in again with a QR code in case this was a network problem" + "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" + "If that doesn’t work, sign in manually" + "Connection not secure" + "You’ll be asked to enter the two digits shown on this device." + "Enter the number below on your other device" + "Sign in to your other device and then try again, or use another device that’s already signed in." + "Other device not signed in" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The sign in was declined on the other device." + "Sign in declined" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Your other device does not support signing in to %s with a QR code. + +Try signing in manually, or scan the QR code with another device." + "QR code not supported" + "Your account provider does not support %1$s." + "%1$s not supported" + "Ready to scan" + "Open %1$s on a desktop device" + "Click on your avatar" + "Select %1$s" + "“Link new device”" + "Scan the QR code with this device" + "Open %1$s on another device to get the QR code" + "Use the QR code shown on the other device." + "Try again" + "Wrong QR code" + "Go to camera settings" + "You need to give permission for %1$s to use your device’s camera in order to continue." + "Allow camera access to scan the QR code" + "Scan the QR code" + "Start over" + "An unexpected error occurred. Please try again." + "Waiting for your other device" + "Your account provider may ask for the following code to verify the sign in." + "Your verification code" "Change account provider" "A private server for Element employees." "Matrix is an open network for secure, decentralised communication." diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index f8518f6033..e6969921f3 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -25,7 +25,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -38,7 +38,7 @@ class ChangeServerPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) moleculeFlow(RecompositionMode.Immediate) { @@ -51,7 +51,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server ok`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() @@ -72,7 +72,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginComponent.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginComponent.kt new file mode 100644 index 0000000000..f3a8dbd694 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/di/FakeQrCodeLoginComponent.kt @@ -0,0 +1,49 @@ +/* + * 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.login.impl.di + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager +import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode +import io.element.android.features.login.impl.qrcode.QrCodeLoginManager +import io.element.android.libraries.architecture.AssistedNodeFactory +import io.element.android.libraries.architecture.createNode + +internal class FakeQrCodeLoginComponent(private val qrCodeLoginManager: QrCodeLoginManager) : + QrCodeLoginComponent { + // Ignore this error, it does override a method once code generation is done + override fun qrCodeLoginManager(): QrCodeLoginManager = qrCodeLoginManager + + class Builder(private val qrCodeLoginManager: QrCodeLoginManager = FakeQrCodeLoginManager()) : + QrCodeLoginComponent.Builder { + override fun build(): QrCodeLoginComponent { + return FakeQrCodeLoginComponent(qrCodeLoginManager) + } + } + + override fun nodeFactories(): Map, AssistedNodeFactory<*>> { + return mapOf( + QrCodeLoginFlowNode::class.java to object : AssistedNodeFactory { + override fun create(buildContext: BuildContext, plugins: List): QrCodeLoginFlowNode { + return createNode(buildContext, plugins) + } + } + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt index 34b497866a..38d0506dd8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt @@ -26,7 +26,7 @@ import io.element.android.features.login.api.oidc.OidcAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class OidcPresenterTest { fun `present - initial state`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -56,7 +56,7 @@ class OidcPresenterTest { fun `present - go back`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -72,7 +72,7 @@ class OidcPresenterTest { @Test fun `present - go back with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, @@ -95,7 +95,7 @@ class OidcPresenterTest { fun `present - user cancels from webview`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -113,7 +113,7 @@ class OidcPresenterTest { fun `present - login success`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -128,7 +128,7 @@ class OidcPresenterTest { @Test fun `present - login error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManagerTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManagerTest.kt new file mode 100644 index 0000000000..a3ad568cf5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/DefaultQrCodeLoginManagerTest.kt @@ -0,0 +1,75 @@ +/* + * 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.login.impl.qrcode + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultQrCodeLoginManagerTest { + @Test + fun `authenticate - returns success if the login succeeded`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, _ -> Result.success(A_SESSION_ID) } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + val result = manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(A_SESSION_ID) + } + + @Test + fun `authenticate - returns failure if the login failed`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, _ -> Result.failure(IllegalStateException("Auth failed")) } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + val result = manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isNotNull() + } + + @Test + fun `authenticate - emits the auth steps`() = runTest { + val authenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progressListener -> + progressListener(QrCodeLoginStep.EstablishingSecureChannel("00")) + progressListener(QrCodeLoginStep.Starting) + progressListener(QrCodeLoginStep.WaitingForToken("000000")) + progressListener(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + val manager = DefaultQrCodeLoginManager(authenticationService) + manager.currentLoginStep.test { + manager.authenticate(FakeMatrixQrCodeLoginData()) + + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Uninitialized) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.EstablishingSecureChannel("00")) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Starting) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.WaitingForToken("000000")) + assertThat(awaitItem()).isEqualTo(QrCodeLoginStep.Finished) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt new file mode 100644 index 0000000000..cc15a80e46 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/FakeQrCodeLoginManager.kt @@ -0,0 +1,41 @@ +/* + * 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.login.impl.qrcode + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeQrCodeLoginManager( + var authenticateResult: (MatrixQrCodeLoginData) -> Result = + lambdaRecorder> { Result.success(A_SESSION_ID) }, + var resetAction: () -> Unit = lambdaRecorder { }, +) : QrCodeLoginManager { + override val currentLoginStep: MutableStateFlow = + MutableStateFlow(QrCodeLoginStep.Uninitialized) + + override suspend fun authenticate(qrCodeLoginData: MatrixQrCodeLoginData): Result { + return authenticateResult(qrCodeLoginData) + } + + override fun reset() { + resetAction() + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt new file mode 100644 index 0000000000..f53e9c74f7 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/qrcode/QrCodeLoginFlowNodeTest.kt @@ -0,0 +1,219 @@ +/* + * 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.login.impl.qrcode + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.DefaultLoginUserStory +import io.element.android.features.login.impl.di.FakeQrCodeLoginComponent +import io.element.android.features.login.impl.screens.qrcode.confirmation.QrCodeConfirmationStep +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeLoginFlowNodeTest { + @Test + fun `backstack changes when confirmation steps are received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.EstablishingSecureChannel("12") + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayCheckCode("12"))) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.WaitingForToken("123456") + assertThat(flowNode.currentNavTarget()) + .isEqualTo(QrCodeLoginFlowNode.NavTarget.QrCodeConfirmation(QrCodeConfirmationStep.DisplayVerificationCode("123456"))) + } + + @Test + fun `backstack changes when failure step is received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + // Only case when this doesn't happen, since it's handled by the already displayed UI + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Expired) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Expired)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Declined) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Declined)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Cancelled) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.Cancelled)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.SlidingSyncNotAvailable) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.SlidingSyncNotAvailable)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.LinkingNotSupported) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.ProtocolNotSupported)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.ConnectionInsecure) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.InsecureChannelDetected)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OidcMetadataInvalid) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.Unknown) + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Error(QrCodeErrorScreenType.UnknownError)) + } + + @Test + fun `backstack doesn't change when other steps are received`() = runTest { + val qrCodeLoginManager = FakeQrCodeLoginManager() + val flowNode = createLoginFlowNode(qrCodeLoginManager = qrCodeLoginManager) + flowNode.observeLoginStep() + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Starting + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Finished + assertThat(flowNode.currentNavTarget()).isEqualTo(QrCodeLoginFlowNode.NavTarget.Initial) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - success marks the login flow as done`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val defaultLoginUserStory = DefaultLoginUserStory().apply { + loginFlowIsDone.value = false + } + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + defaultLoginUserStory = defaultLoginUserStory, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Finished) + assertThat(defaultLoginUserStory.loginFlowIsDone.value).isTrue() + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - failure is correctly handled`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Failed(QrLoginException.Unknown)) + Result.failure(IllegalStateException("Failed")) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val defaultLoginUserStory = DefaultLoginUserStory().apply { + loginFlowIsDone.value = false + } + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + defaultLoginUserStory = defaultLoginUserStory, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Failed(QrLoginException.Unknown)) + assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse() + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `startAuthentication - then reset`() = runTest { + val fakeAuthenticationService = FakeMatrixAuthenticationService( + loginWithQrCodeResult = { _, progress -> + progress(QrCodeLoginStep.Finished) + Result.success(A_SESSION_ID) + } + ) + // Test with a real manager to ensure the flow is correctly done + val qrCodeLoginManager = DefaultQrCodeLoginManager(fakeAuthenticationService) + val defaultLoginUserStory = DefaultLoginUserStory().apply { + loginFlowIsDone.value = false + } + val flowNode = createLoginFlowNode( + qrCodeLoginManager = qrCodeLoginManager, + defaultLoginUserStory = defaultLoginUserStory, + coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + ) + + flowNode.run { startAuthentication(FakeMatrixQrCodeLoginData()) } + assertThat(flowNode.isLoginInProgress()).isTrue() + flowNode.reset() + + advanceUntilIdle() + + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized) + assertThat(defaultLoginUserStory.loginFlowIsDone.value).isFalse() + assertThat(flowNode.isLoginInProgress()).isFalse() + } + + private fun TestScope.createLoginFlowNode( + qrCodeLoginManager: QrCodeLoginManager = FakeQrCodeLoginManager(), + defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers() + ): QrCodeLoginFlowNode { + val buildContext = BuildContext( + ancestryInfo = AncestryInfo.Root, + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl() + ) + return QrCodeLoginFlowNode( + buildContext = buildContext, + plugins = emptyList(), + qrCodeLoginComponentBuilder = FakeQrCodeLoginComponent.Builder(qrCodeLoginManager), + defaultLoginUserStory = defaultLoginUserStory, + coroutineDispatchers = coroutineDispatchers, + ) + } + + private fun QrCodeLoginFlowNode.currentNavTarget() = backstack.elements.value.last().key.navTarget +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt index 04680ede17..f7696652d0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -23,7 +23,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerPresenter -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -36,7 +36,7 @@ class ChangeAccountProviderPresenterTest { @Test fun `present - initial state`() = runTest { val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = ChangeAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 57f18d7eb4..5a02797bdb 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.waitForPredicate import kotlinx.coroutines.test.runTest @@ -57,7 +57,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue password login`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -79,7 +79,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue oidc`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -101,7 +101,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -129,7 +129,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -156,7 +156,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -186,7 +186,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val defaultLoginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) @@ -219,7 +219,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - submit fails`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -238,7 +238,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -267,7 +267,7 @@ class ConfirmAccountProviderPresenterTest { private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(), - matrixAuthenticationService: MatrixAuthenticationService = FakeAuthenticationService(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(), defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), ) = ConfirmAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 4658df0ab9..db73c25687 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -42,7 +42,7 @@ class LoginPasswordPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -63,7 +63,7 @@ class LoginPasswordPresenterTest { @Test fun `present - enter login and password`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -89,7 +89,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = LoginPasswordPresenter( @@ -118,7 +118,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit with error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -146,7 +146,7 @@ class LoginPasswordPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt new file mode 100644 index 0000000000..78eae62377 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/confirmation/QrCodeConfirmationViewTest.kt @@ -0,0 +1,70 @@ +/* + * 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.login.impl.screens.qrcode.confirmation + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeConfirmationViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeConfirmationView( + step = QrCodeConfirmationStep.DisplayCheckCode("12"), + onCancel = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on Cancel button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeConfirmationView( + step = QrCodeConfirmationStep.DisplayVerificationCode("123456"), + onCancel = callback + ) + rule.clickOn(CommonStrings.action_cancel) + } + } + + private fun AndroidComposeTestRule.setQrCodeConfirmationView( + step: QrCodeConfirmationStep, + onCancel: () -> Unit + ) { + setContent { + QrCodeConfirmationView( + step = step, + onCancel = onCancel + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt new file mode 100644 index 0000000000..f68e686f1a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/error/QrCodeErrorViewTest.kt @@ -0,0 +1,71 @@ +/* + * 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.login.impl.screens.qrcode.error + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.login.impl.R +import io.element.android.features.login.impl.qrcode.QrCodeErrorScreenType +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeErrorViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeErrorView( + onRetry = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on try again button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeErrorView( + onRetry = callback + ) + rule.clickOn(R.string.screen_qr_code_login_start_over_button) + } + } + + private fun AndroidComposeTestRule.setQrCodeErrorView( + onRetry: () -> Unit, + errorScreenType: QrCodeErrorScreenType = QrCodeErrorScreenType.UnknownError, + appName: String = "Element X", + ) { + setContent { + QrCodeErrorView( + errorScreenType = errorScreenType, + appName = appName, + onRetry = onRetry + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt new file mode 100644 index 0000000000..6e51109097 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenterTest.kt @@ -0,0 +1,84 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class QrCodeIntroPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createQrCodeIntroPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().run { + assertThat(appName).isEqualTo("AppName") + assertThat(desktopAppName).isEqualTo("DesktopAppName") + assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS") + assertThat(canContinue).isFalse() + } + } + } + + @Test + fun `present - Continue with camera permissions can continue`() = runTest { + val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(QrCodeIntroEvents.Continue) + assertThat(awaitItem().canContinue).isTrue() + } + } + + @Test + fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest { + val permissionsPresenter = FakePermissionsPresenter() + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createQrCodeIntroPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().eventSink(QrCodeIntroEvents.Continue) + assertThat(awaitItem().cameraPermissionState.showDialog).isTrue() + } + } + + private fun createQrCodeIntroPresenter( + buildMeta: BuildMeta = aBuildMeta( + applicationName = "AppName", + desktopApplicationName = "DesktopAppName", + ), + permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(), + ): QrCodeIntroPresenter { + return QrCodeIntroPresenter( + buildMeta = buildMeta, + permissionsPresenterFactory = permissionsPresenterFactory, + ) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt new file mode 100644 index 0000000000..6e258a4602 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroViewTest.kt @@ -0,0 +1,95 @@ +/* + * 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.login.impl.screens.qrcode.intro + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeIntroViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(), + onBackClicked = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(), + onBackClicked = callback + ) + rule.pressBack() + } + } + + @Test + fun `when can continue - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeIntroView( + state = aQrCodeIntroState(canContinue = true), + onContinue = callback + ) + } + } + + @Test + fun `on continue button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setQrCodeIntroView( + state = aQrCodeIntroState(eventSink = eventRecorder), + ) + rule.clickOn(CommonStrings.action_continue) + eventRecorder.assertSingle(QrCodeIntroEvents.Continue) + } + + private fun AndroidComposeTestRule.setQrCodeIntroView( + state: QrCodeIntroState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + onContinue: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + QrCodeIntroView( + state = state, + onBackClick = onBackClicked, + onContinue = onContinue, + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt new file mode 100644 index 0000000000..df79a51a41 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanPresenterTest.kt @@ -0,0 +1,120 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.login.impl.qrcode.FakeQrCodeLoginManager +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginDataFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class QrCodeScanPresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createQrCodeScanPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + awaitItem().run { + assertThat(isScanning).isTrue() + assertThat(authenticationAction.isUninitialized()).isTrue() + } + } + } + + @Test + fun `present - scanned QR code successfully`() = runTest { + val presenter = createQrCodeScanPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf())) + assertThat(awaitItem().isScanning).isFalse() + assertThat(awaitItem().authenticationAction.isLoading()).isTrue() + assertThat(awaitItem().authenticationAction.isSuccess()).isTrue() + } + } + + @Test + fun `present - scanned QR code failed and can be retried`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory( + parseQrCodeLoginDataResult = { Result.failure(Exception("Failed to parse QR code")) } + ) + val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(QrCodeScanEvents.QrCodeScanned(byteArrayOf())) + assertThat(awaitItem().isScanning).isFalse() + assertThat(awaitItem().authenticationAction.isLoading()).isTrue() + + val errorState = awaitItem() + assertThat(errorState.authenticationAction.isFailure()).isTrue() + + errorState.eventSink(QrCodeScanEvents.TryAgain) + assertThat(awaitItem().isScanning).isTrue() + assertThat(awaitItem().authenticationAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - login failed with so we display the error and recover from it`() = runTest { + val qrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory() + val qrCodeLoginManager = FakeQrCodeLoginManager() + val resetAction = lambdaRecorder { + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Uninitialized + } + qrCodeLoginManager.resetAction = resetAction + val presenter = createQrCodeScanPresenter(qrCodeLoginDataFactory = qrCodeLoginDataFactory, qrCodeLoginManager = qrCodeLoginManager) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Skip initial item + skipItems(1) + + qrCodeLoginManager.currentLoginStep.value = QrCodeLoginStep.Failed(QrLoginException.OtherDeviceNotSignedIn) + + val errorState = awaitItem() + // The state for this screen is failure + assertThat(errorState.authenticationAction.isFailure()).isTrue() + // However, the QrCodeLoginManager is reset + resetAction.assertions().isCalledOnce() + assertThat(qrCodeLoginManager.currentLoginStep.value).isEqualTo(QrCodeLoginStep.Uninitialized) + } + } + + private fun TestScope.createQrCodeScanPresenter( + qrCodeLoginDataFactory: FakeMatrixQrCodeLoginDataFactory = FakeMatrixQrCodeLoginDataFactory(), + coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), + qrCodeLoginManager: FakeQrCodeLoginManager = FakeQrCodeLoginManager(), + ) = QrCodeScanPresenter( + qrCodeLoginDataFactory = qrCodeLoginDataFactory, + qrCodeLoginManager = qrCodeLoginManager, + coroutineDispatchers = coroutineDispatchers, + ) +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt new file mode 100644 index 0000000000..aece85f2de --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanViewTest.kt @@ -0,0 +1,76 @@ +/* + * 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.login.impl.screens.qrcode.scan + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.test.auth.qrlogin.FakeMatrixQrCodeLoginData +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QrCodeScanViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setQrCodeScanView( + state = aQrCodeScanState(), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on QR code data ready - calls the expected callback`() { + val data = FakeMatrixQrCodeLoginData() + ensureCalledOnceWithParam(data) { callback -> + rule.setQrCodeScanView( + state = aQrCodeScanState(authenticationAction = AsyncAction.Success(data)), + onQrCodeDataReady = callback + ) + } + } + + private fun AndroidComposeTestRule.setQrCodeScanView( + state: QrCodeScanState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onQrCodeDataReady: (MatrixQrCodeLoginData) -> Unit = EnsureNeverCalledWithParam(), + ) { + setContent { + QrCodeScanView( + state = state, + onBackClick = onBackClick, + onQrCodeDataReady = onQrCodeDataReady + ) + } + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt index 28aa1090a9..5288746583 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -29,7 +29,7 @@ import io.element.android.features.login.impl.resolver.network.WellKnownBaseConf import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest @@ -44,7 +44,7 @@ class SearchAccountProviderPresenterTest { fun `present - initial state`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -64,7 +64,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter text no result`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -88,7 +88,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter valid url no wellknown`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -123,7 +123,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -158,7 +158,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt index f88f47ae26..4f751a5c9e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class WaitListPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenHomeserver(A_HOMESERVER) } val loginUserStory = DefaultLoginUserStory() @@ -63,7 +63,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with error`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenLoginError(A_THROWABLE) } val loginUserStory = DefaultLoginUserStory() @@ -94,7 +94,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = WaitListPresenter( LoginFormState.Default, diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt index b750a47e6d..89ccfb227e 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt @@ -30,6 +30,6 @@ interface LogoutEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onChangeRecoveryKeyClicked() + fun onChangeRecoveryKeyClick() } } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt index 14c7199416..4f5bab10b9 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt @@ -38,8 +38,8 @@ class LogoutNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: LogoutPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onChangeRecoveryKeyClicked() { - plugins().forEach { it.onChangeRecoveryKeyClicked() } + private fun onChangeRecoveryKeyClick() { + plugins().forEach { it.onChangeRecoveryKeyClick() } } private fun onSuccessLogout(activity: Activity, url: String?) { @@ -55,9 +55,9 @@ class LogoutNode @AssistedInject constructor( val activity = LocalContext.current as Activity LogoutView( state = state, - onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked, + onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick, onSuccessLogout = { onSuccessLogout(activity, it) }, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index 19160236ac..3549956844 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -33,6 +33,7 @@ import io.element.android.features.logout.impl.tools.isBackingUp import io.element.android.features.logout.impl.ui.LogoutActionDialog import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -51,37 +52,38 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutView( state: LogoutState, - onChangeRecoveryKeyClicked: () -> Unit, - onBackClicked: () -> Unit, + onChangeRecoveryKeyClick: () -> Unit, + onBackClick: () -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink FlowStepPage( - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = title(state), subTitle = subtitle(state), - iconVector = CompoundIcons.KeySolid(), + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), modifier = modifier, - content = { Content(state) }, buttons = { Buttons( state = state, - onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, - onLogoutClicked = { + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onLogoutClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = false)) } ) }, - ) + ) { + Content(state) + } LogoutActionDialog( state.logoutAction, - onConfirmClicked = { + onConfirmClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = false)) }, - onForceLogoutClicked = { + onForceLogoutClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = true)) }, onDismissDialog = { @@ -124,15 +126,15 @@ private fun subtitle(state: LogoutState): String? { @Composable private fun ColumnScope.Buttons( state: LogoutState, - onLogoutClicked: () -> Unit, - onChangeRecoveryKeyClicked: () -> Unit, + onLogoutClick: () -> Unit, + onChangeRecoveryKeyClick: () -> Unit, ) { val logoutAction = state.logoutAction if (state.isLastDevice) { OutlinedButton( text = stringResource(id = CommonStrings.common_settings), modifier = Modifier.fillMaxWidth(), - onClick = onChangeRecoveryKeyClicked, + onClick = onChangeRecoveryKeyClick, ) } val signOutSubmitRes = when { @@ -147,7 +149,7 @@ private fun ColumnScope.Buttons( modifier = Modifier .fillMaxWidth() .testTag(TestTags.signOut), - onClick = onLogoutClicked, + onClick = onLogoutClick, ) } @@ -183,8 +185,8 @@ internal fun LogoutViewPreview( ) = ElementPreview { LogoutView( state, - onChangeRecoveryKeyClicked = {}, + onChangeRecoveryKeyClick = {}, onSuccessLogout = {}, - onBackClicked = {}, + onBackClick = {}, ) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt index 0cc5cfe476..b0c6c4b70e 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -39,10 +39,10 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView { val eventSink = state.eventSink LogoutActionDialog( state.logoutAction, - onConfirmClicked = { + onConfirmClick = { eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) }, - onForceLogoutClicked = { + onForceLogoutClick = { eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true)) }, onDismissDialog = { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt index 18e2809034..36fff3afee 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutActionDialog( state: AsyncAction, - onConfirmClicked: () -> Unit, - onForceLogoutClicked: () -> Unit, + onConfirmClick: () -> Unit, + onForceLogoutClick: () -> Unit, onDismissDialog: () -> Unit, onSuccessLogout: (String?) -> Unit, ) { @@ -40,7 +40,7 @@ fun LogoutActionDialog( Unit AsyncAction.Confirming -> LogoutConfirmationDialog( - onSubmitClicked = onConfirmClicked, + onSubmitClick = onConfirmClick, onDismiss = onDismissDialog ) is AsyncAction.Loading -> @@ -50,7 +50,7 @@ fun LogoutActionDialog( title = stringResource(id = CommonStrings.dialog_title_error), content = stringResource(id = CommonStrings.error_unknown), retryText = stringResource(id = CommonStrings.action_signout_anyway), - onRetry = onForceLogoutClicked, + onRetry = onForceLogoutClick, onDismiss = onDismissDialog, ) is AsyncAction.Success -> { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt index caf04a2752..ea2a9656e0 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt @@ -24,14 +24,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutConfirmationDialog( - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, onDismiss: () -> Unit, ) { ConfirmationDialog( title = stringResource(id = CommonStrings.action_signout), content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), submitText = stringResource(id = CommonStrings.action_signout), - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, onDismiss = onDismiss, ) } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt index 8ebe6c175b..95b87886d7 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -73,7 +73,7 @@ class LogoutViewTest { aLogoutState( eventSink = eventsRecorder ), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBack() } @@ -129,7 +129,7 @@ class LogoutViewTest { isLastDevice = true, eventSink = eventsRecorder ), - onChangeRecoveryKeyClicked = callback, + onChangeRecoveryKeyClick = callback, ) rule.clickOn(CommonStrings.common_settings) } @@ -138,15 +138,15 @@ class LogoutViewTest { private fun AndroidComposeTestRule.setLogoutView( state: LogoutState, - onChangeRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), - onBackClicked: () -> Unit = EnsureNeverCalled(), + onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onSuccessLogout: (logoutUrlResult: String?) -> Unit = EnsureNeverCalledWithParam() ) { setContent { LogoutView( state = state, - onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, - onBackClicked = onBackClicked, + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onBackClick = onBackClick, onSuccessLogout = onSuccessLogout, ) } diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index 15d6e5fd95..6bd1045c12 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -39,9 +39,9 @@ interface MessagesEntryPoint : FeatureEntryPoint { ) interface Callback : Plugin { - fun onRoomDetailsClicked() - fun onUserDataClicked(userId: UserId) - fun onPermalinkClicked(data: PermalinkData) + fun onRoomDetailsClick() + fun onUserDataClick(userId: UserId) + fun onPermalinkClick(data: PermalinkData) fun onForwardedToSingleRoom(roomId: RoomId) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt index 28ef69226b..3d61d43063 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt @@ -58,6 +58,7 @@ import kotlin.math.roundToInt * @param modifier The modifier for the layout. * @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured. */ +@Suppress("ContentTrailingLambda") @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExpandableBottomSheetScaffold( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index ca35ac7fee..6665c5fa2a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -29,6 +29,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.CallType import io.element.android.features.call.ui.ElementCallActivity @@ -68,6 +69,8 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.ImmutableList import kotlinx.parcelize.Parcelize @@ -80,6 +83,7 @@ class MessagesFlowNode @AssistedInject constructor( private val sendLocationEntryPoint: SendLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint, + private val analyticsService: AnalyticsService, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Messages, @@ -139,31 +143,31 @@ class MessagesFlowNode @AssistedInject constructor( return when (navTarget) { is NavTarget.Messages -> { val callback = object : MessagesNode.Callback { - override fun onRoomDetailsClicked() { - callback?.onRoomDetailsClicked() + override fun onRoomDetailsClick() { + callback?.onRoomDetailsClick() } - override fun onEventClicked(event: TimelineItem.Event): Boolean { - return processEventClicked(event) + override fun onEventClick(event: TimelineItem.Event): Boolean { + return processEventClick(event) } override fun onPreviewAttachments(attachments: ImmutableList) { backstack.push(NavTarget.AttachmentPreview(attachments.first())) } - override fun onUserDataClicked(userId: UserId) { - callback?.onUserDataClicked(userId) + override fun onUserDataClick(userId: UserId) { + callback?.onUserDataClick(userId) } - override fun onPermalinkClicked(data: PermalinkData) { - callback?.onPermalinkClicked(data) + override fun onPermalinkClick(data: PermalinkData) { + callback?.onPermalinkClick(data) } - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun onForwardEventClicked(eventId: EventId) { + override fun onForwardEventClick(eventId: EventId) { backstack.push(NavTarget.ForwardEvent(eventId)) } @@ -171,23 +175,24 @@ class MessagesFlowNode @AssistedInject constructor( backstack.push(NavTarget.ReportMessage(eventId, senderId)) } - override fun onSendLocationClicked() { + override fun onSendLocationClick() { backstack.push(NavTarget.SendLocation) } - override fun onCreatePollClicked() { + override fun onCreatePollClick() { backstack.push(NavTarget.CreatePoll) } - override fun onEditPollClicked(eventId: EventId) { + override fun onEditPollClick(eventId: EventId) { backstack.push(NavTarget.EditPoll(eventId)) } - override fun onJoinCallClicked(roomId: RoomId) { + override fun onJoinCallClick(roomId: RoomId) { val inputs = CallType.RoomCall( sessionId = matrixClient.sessionId, roomId = roomId, ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) ElementCallActivity.start(context, inputs) } } @@ -250,7 +255,7 @@ class MessagesFlowNode @AssistedInject constructor( } } - private fun processEventClicked(event: TimelineItem.Event): Boolean { + private fun processEventClick(event: TimelineItem.Event): Boolean { return when (event.content) { is TimelineItemImageContent -> { val navTarget = NavTarget.MediaViewer( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index d40a323f43..a9326c5e7f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -21,9 +21,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo interface MessagesNavigator { - fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun onForwardEventClicked(eventId: EventId) - fun onReportContentClicked(eventId: EventId, senderId: UserId) - fun onEditPollClicked(eventId: EventId) - fun onBackPressed() + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) + fun onReportContentClick(eventId: EventId, senderId: UserId) + fun onEditPollClick(eventId: EventId) + fun onBackPressed() // SC } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 2899fd0b07..6fd8fedb4d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -84,18 +84,18 @@ class MessagesNode @AssistedInject constructor( private val inputs = inputs() interface Callback : Plugin { - fun onRoomDetailsClicked() - fun onEventClicked(event: TimelineItem.Event): Boolean + fun onRoomDetailsClick() + fun onEventClick(event: TimelineItem.Event): Boolean fun onPreviewAttachments(attachments: ImmutableList) - fun onUserDataClicked(userId: UserId) - fun onPermalinkClicked(data: PermalinkData) - fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun onForwardEventClicked(eventId: EventId) + fun onUserDataClick(userId: UserId) + fun onPermalinkClick(data: PermalinkData) + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) fun onReportMessage(eventId: EventId, senderId: UserId) - fun onSendLocationClicked() - fun onCreatePollClicked() - fun onEditPollClicked(eventId: EventId) - fun onJoinCallClicked(roomId: RoomId) + fun onSendLocationClick() + fun onCreatePollClick() + fun onEditPollClick(eventId: EventId) + fun onJoinCallClick(roomId: RoomId) } override fun onBuilt() { @@ -111,23 +111,23 @@ class MessagesNode @AssistedInject constructor( ) } - private fun onRoomDetailsClicked() { - callback?.onRoomDetailsClicked() + private fun onRoomDetailsClick() { + callback?.onRoomDetailsClick() } - private fun onEventClicked(event: TimelineItem.Event): Boolean { - return callback?.onEventClicked(event).orFalse() + private fun onEventClick(event: TimelineItem.Event): Boolean { + return callback?.onEventClick(event).orFalse() } private fun onPreviewAttachments(attachments: ImmutableList) { callback?.onPreviewAttachments(attachments) } - private fun onUserDataClicked(userId: UserId) { - callback?.onUserDataClicked(userId) + private fun onUserDataClick(userId: UserId) { + callback?.onUserDataClick(userId) } - private fun onLinkClicked( + private fun onLinkClick( context: Context, url: String, eventSink: (TimelineEvents) -> Unit, @@ -136,10 +136,10 @@ class MessagesNode @AssistedInject constructor( is PermalinkData.UserLink -> { // Open the room member profile, it will fallback to // the user profile if the user is not in the room - callback?.onUserDataClicked(permalink.userId) + callback?.onUserDataClick(permalink.userId) } is PermalinkData.RoomLink -> { - handleRoomLinkClicked(permalink, eventSink) + handleRoomLinkClick(permalink, eventSink) } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { @@ -148,7 +148,7 @@ class MessagesNode @AssistedInject constructor( } } - private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { + private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { if (room.matches(roomLink.roomIdOrAlias)) { val eventId = roomLink.eventId if (eventId != null) { @@ -158,40 +158,40 @@ class MessagesNode @AssistedInject constructor( context.toast("Already viewing this room!") } } else { - callback?.onPermalinkClicked(roomLink) + callback?.onPermalinkClick(roomLink) } } - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { - callback?.onShowEventDebugInfoClicked(eventId, debugInfo) + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback?.onShowEventDebugInfoClick(eventId, debugInfo) } - override fun onForwardEventClicked(eventId: EventId) { - callback?.onForwardEventClicked(eventId) + override fun onForwardEventClick(eventId: EventId) { + callback?.onForwardEventClick(eventId) } - override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + override fun onReportContentClick(eventId: EventId, senderId: UserId) { callback?.onReportMessage(eventId, senderId) } - override fun onEditPollClicked(eventId: EventId) { - callback?.onEditPollClicked(eventId) + override fun onEditPollClick(eventId: EventId) { + callback?.onEditPollClick(eventId) } override fun onBackPressed() { navigateUp() } - private fun onSendLocationClicked() { - callback?.onSendLocationClicked() + private fun onSendLocationClick() { + callback?.onSendLocationClick() } - private fun onCreatePollClicked() { - callback?.onCreatePollClicked() + private fun onCreatePollClick() { + callback?.onCreatePollClick() } - private fun onJoinCallClicked() { - callback?.onJoinCallClicked(room.roomId) + private fun onJoinCallClick() { + callback?.onJoinCallClick(room.roomId) } @Composable @@ -203,16 +203,16 @@ class MessagesNode @AssistedInject constructor( val state = presenter.present() MessagesView( state = state, - recentEmojiDataSource = recentEmojiDataSource, - onBackPressed = this::navigateUp, - onRoomDetailsClicked = this::onRoomDetailsClicked, - onEventClicked = this::onEventClicked, + recentEmojiDataSource = recentEmojiDataSource, // SC + onBackClick = this::navigateUp, + onRoomDetailsClick = this::onRoomDetailsClick, + onEventClick = this::onEventClick, onPreviewAttachments = this::onPreviewAttachments, - onUserDataClicked = this::onUserDataClicked, - onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) }, - onSendLocationClicked = this::onSendLocationClicked, - onCreatePollClicked = this::onCreatePollClicked, - onJoinCallClicked = this::onJoinCallClicked, + onUserDataClick = this::onUserDataClick, + onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) }, + onSendLocationClick = this::onSendLocationClick, + onCreatePollClick = this::onCreatePollClick, + onJoinCallClick = this::onJoinCallClick, modifier = modifier, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 579ea87868..69ae082620 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -324,7 +324,7 @@ class MessagesPresenter @AssistedInject constructor( when (targetEvent.content) { is TimelineItemPollContent -> { if (targetEvent.eventId == null) return - navigator.onEditPollClicked(targetEvent.eventId) + navigator.onEditPollClick(targetEvent.eventId) } else -> { val composerMode = MessageComposerMode.Edit( @@ -407,24 +407,24 @@ class MessagesPresenter @AssistedInject constructor( } private fun handleShowDebugInfoAction(event: TimelineItem.Event) { - navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo) + navigator.onShowEventDebugInfoClick(event.eventId, event.debugInfo) } private fun handleForwardAction(event: TimelineItem.Event) { if (event.eventId == null) return - navigator.onForwardEventClicked(event.eventId) + navigator.onForwardEventClick(event.eventId) } private fun handleReportAction(event: TimelineItem.Event) { if (event.eventId == null) return - navigator.onReportContentClicked(event.eventId, event.senderId) + navigator.onReportContentClick(event.eventId, event.senderId) } private fun handleEndPollAction( event: TimelineItem.Event, timelineState: TimelineState, ) { - event.eventId?.let { timelineState.eventSink(TimelineEvents.PollEndClicked(it)) } + event.eventId?.let { timelineState.eventSink(TimelineEvents.EndPoll(it)) } } private suspend fun handleCopyLink(event: TimelineItem.Event) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 31bdcdaa72..e4636fcc6d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -119,15 +119,15 @@ import androidx.compose.material3.Button as Material3Button @Composable fun MessagesView( state: MessagesState, - onBackPressed: () -> Unit, - onRoomDetailsClicked: () -> Unit, - onEventClicked: (event: TimelineItem.Event) -> Boolean, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, + onBackClick: () -> Unit, + onRoomDetailsClick: () -> Unit, + onEventClick: (event: TimelineItem.Event) -> Boolean, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, onPreviewAttachments: (ImmutableList) -> Unit, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, - onJoinCallClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + onJoinCallClick: () -> Unit, modifier: Modifier = Modifier, recentEmojiDataSource: RecentEmojiDataSource? = null, forceJumpToBottomVisibility: Boolean = false @@ -149,15 +149,15 @@ fun MessagesView( // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose val localView = LocalView.current - fun onMessageClicked(event: TimelineItem.Event) { - Timber.v("OnMessageClicked= ${event.id}") - val hideKeyboard = onEventClicked(event) + fun onMessageClick(event: TimelineItem.Event) { + Timber.v("onMessageClick= ${event.id}") + val hideKeyboard = onEventClick(event) if (hideKeyboard) { localView.hideKeyboard() } } - fun onMessageLongClicked(event: TimelineItem.Event) { + fun onMessageLongClick(event: TimelineItem.Event) { Timber.v("OnMessageLongClicked= ${event.id}") localView.hideKeyboard() state.actionListState.eventSink( @@ -175,17 +175,17 @@ fun MessagesView( state.eventSink(MessagesEvents.HandleAction(action, event)) } - fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { + fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) } - fun onEmojiReactionLongClicked(emoji: String, event: TimelineItem.Event) { + fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji)) } - fun onMoreReactionsClicked(event: TimelineItem.Event) { + fun onMoreReactionsClick(event: TimelineItem.Event) { state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) } @@ -199,15 +199,15 @@ fun MessagesView( roomName = state.roomName.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(), callState = state.callState, - onBackPressed = { + onBackClick = { // Since the textfield is now based on an Android view, this is no longer done automatically. // We need to hide the keyboard when navigating out of this screen. localView.hideKeyboard() - onBackPressed() + onBackClick() }, - onRoomDetailsClicked = onRoomDetailsClicked, - onJoinCallClicked = onJoinCallClicked, - state = state, + onRoomDetailsClick = onRoomDetailsClick, + onJoinCallClick = onJoinCallClick, + state = state, // SC ) ScReadMarkerDebug(state.timelineState.scReadState) } @@ -218,23 +218,23 @@ fun MessagesView( modifier = Modifier .padding(padding) .consumeWindowInsets(padding), - onMessageClicked = ::onMessageClicked, - onMessageLongClicked = ::onMessageLongClicked, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onTimestampClicked = { event -> + onMessageClick = ::onMessageClick, + onMessageLongClick = ::onMessageLongClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onTimestampClick = { event -> if (event.localSendState is LocalEventSendState.SendingFailed) { state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) } }, - onReactionClicked = ::onEmojiReactionClicked, - onReactionLongClicked = ::onEmojiReactionLongClicked, - onMoreReactionsClicked = ::onMoreReactionsClicked, + onReactionClick = ::onEmojiReactionClick, + onReactionLongClick = ::onEmojiReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, onReadReceiptClick = { event -> state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event)) }, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, onSwipeToReply = { targetEvent -> state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) }, @@ -251,21 +251,21 @@ fun MessagesView( ActionListView( state = state.actionListState, - onActionSelected = ::onActionSelected, - onCustomReactionClicked = { event -> + onSelectAction = ::onActionSelected, + onCustomReactionClick = { event -> if (event.eventId == null) return@ActionListView state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) }, - onEmojiReactionClicked = ::onEmojiReactionClicked, + onEmojiReactionClick = ::onEmojiReactionClick, ) CustomReactionBottomSheet( state = state.customReactionState, recentEmojiDataSource = recentEmojiDataSource, - onEmojiSelected = { eventId, emoji -> + onSelectEmoji = { eventId, emoji -> state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) }, - onCustomEmojiSelected = { eventId, emoji -> + onSelectCustomEmoji = { eventId, emoji -> state.eventSink(MessagesEvents.ToggleReaction(emoji, eventId)) } ) @@ -274,7 +274,7 @@ fun MessagesView( RetrySendMessageMenu(state = state.retrySendMenuState) ReadReceiptBottomSheet( state = state.readReceiptBottomSheetState, - onUserDataClicked = onUserDataClicked, + onUserDataClick = onUserDataClick, ) ReinviteDialog(state = state) } @@ -287,7 +287,7 @@ private fun ReinviteDialog(state: MessagesState) { content = stringResource(id = R.string.screen_room_invite_again_alert_message), cancelText = stringResource(id = CommonStrings.action_cancel), submitText = stringResource(id = CommonStrings.action_invite), - onSubmitClicked = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, + onSubmitClick = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } ) } @@ -324,17 +324,17 @@ private fun AttachmentStateView( @Composable private fun MessagesViewContent( state: MessagesState, - onMessageClicked: (TimelineItem.Event) -> Unit, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onReactionClicked: (key: String, TimelineItem.Event) -> Unit, - onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, - onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + onMessageClick: (TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - onMessageLongClicked: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, forceJumpToBottomVisibility: Boolean, modifier: Modifier = Modifier, onSwipeToReply: (TimelineItem.Event) -> Unit, @@ -347,8 +347,8 @@ private fun MessagesViewContent( ) { AttachmentsBottomSheet( state = state.composerState, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, enableTextFormatting = state.enableTextFormatting, ) @@ -397,15 +397,15 @@ private fun MessagesViewContent( TimelineView( state = state.timelineState, typingNotificationState = state.typingNotificationState, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onMessageClicked = onMessageClicked, - onMessageLongClicked = onMessageLongClicked, - onTimestampClicked = onTimestampClicked, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onTimestampClick = onTimestampClick, onSwipeToReply = onSwipeToReply, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, modifier = Modifier.padding(paddingValues), forceJumpToBottomVisibility = forceJumpToBottomVisibility, @@ -448,7 +448,7 @@ private fun MessagesViewComposerBottomSheetContents( roomName = state.roomName.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(), memberSuggestions = state.composerState.memberSuggestions, - onSuggestionSelected = { + onSelectSuggestion = { state.composerState.eventSink(MessageComposerEvents.InsertMention(it)) } ) @@ -472,17 +472,17 @@ private fun MessagesViewTopBar( roomName: String?, roomAvatar: AvatarData?, callState: RoomCallState, - onRoomDetailsClicked: () -> Unit, - onJoinCallClicked: () -> Unit, - onBackPressed: () -> Unit, - state: MessagesState, + onRoomDetailsClick: () -> Unit, + onJoinCallClick: () -> Unit, + onBackClick: () -> Unit, + state: MessagesState, // SC ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { - val titleModifier = Modifier.clickable { onRoomDetailsClicked() } + val titleModifier = Modifier.clickable { onRoomDetailsClick() } if (roomName != null && roomAvatar != null) { RoomAvatarAndNameRow( roomName = roomName, @@ -498,9 +498,9 @@ private fun MessagesViewTopBar( }, actions = { if (callState == RoomCallState.ONGOING) { - JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked) - } else if (!moveCallButtonToOverflow()) { - IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) { + JoinCallMenuItem(onJoinCallClick = onJoinCallClick) + } else if (!moveCallButtonToOverflow()) { // SC-condition + IconButton(onClick = onJoinCallClick, enabled = callState != RoomCallState.DISABLED) { Icon( imageVector = CompoundIcons.VideoCallSolid(), contentDescription = stringResource(CommonStrings.a11y_start_call), @@ -508,7 +508,7 @@ private fun MessagesViewTopBar( } } //Spacer(Modifier.width(8.dp)) // SC: moved to scMessagesViewTopBarActions() - scMessagesViewTopBarActions(state, callState, onJoinCallClicked) + scMessagesViewTopBarActions(state, callState, onJoinCallClick) }, windowInsets = WindowInsets(0.dp) ) @@ -516,10 +516,10 @@ private fun MessagesViewTopBar( @Composable private fun JoinCallMenuItem( - onJoinCallClicked: () -> Unit, + onJoinCallClick: () -> Unit, ) { Material3Button( - onClick = onJoinCallClicked, + onClick = onJoinCallClick, colors = ButtonDefaults.buttonColors( contentColor = ElementTheme.colors.bgCanvasDefault, containerColor = ElementTheme.colors.iconAccentTertiary @@ -587,15 +587,15 @@ private fun CantSendMessageBanner() { internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview { MessagesView( state = state, - onBackPressed = {}, - onRoomDetailsClicked = {}, - onEventClicked = { false }, + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, onPreviewAttachments = {}, - onUserDataClicked = {}, - onLinkClicked = {}, - onSendLocationClicked = {}, - onCreatePollClicked = {}, - onJoinCallClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, forceJumpToBottomVisibility = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 531f5f5cfb..64bb1c31fa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -72,7 +72,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent -import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl +import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -94,38 +94,38 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun ActionListView( state: ActionListState, - onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, - onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, - onCustomReactionClicked: (TimelineItem.Event) -> Unit, + onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit, + onEmojiReactionClick: (String, TimelineItem.Event) -> Unit, + onCustomReactionClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() val targetItem = (state.target as? ActionListState.Target.Success)?.event - fun onItemActionClicked( + fun onItemActionClick( itemAction: TimelineItemAction ) { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onActionSelected(itemAction, targetItem) + onSelectAction(itemAction, targetItem) } } - fun onEmojiReactionClicked(emoji: String) { + fun onEmojiReactionClick(emoji: String) { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onEmojiReactionClicked(emoji, targetItem) + onEmojiReactionClick(emoji, targetItem) } } - fun onCustomReactionClicked() { + fun onCustomReactionClick() { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onCustomReactionClicked(targetItem) + onCustomReactionClick(targetItem) } } @@ -141,9 +141,9 @@ fun ActionListView( ) { SheetContent( state = state, - onActionClicked = ::onItemActionClicked, - onEmojiReactionClicked = ::onEmojiReactionClicked, - onCustomReactionClicked = ::onCustomReactionClicked, + onActionClick = ::onItemActionClick, + onEmojiReactionClick = ::onEmojiReactionClick, + onCustomReactionClick = ::onCustomReactionClick, modifier = Modifier .navigationBarsPadding() .imePadding() @@ -155,9 +155,9 @@ fun ActionListView( @Composable private fun SheetContent( state: ActionListState, - onActionClicked: (TimelineItemAction) -> Unit, - onEmojiReactionClicked: (String) -> Unit, - onCustomReactionClicked: () -> Unit, + onActionClick: (TimelineItemAction) -> Unit, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, modifier: Modifier = Modifier, ) { when (val target = state.target) { @@ -188,9 +188,9 @@ private fun SheetContent( item { EmojiReactionsRow( highlightedEmojis = target.event.reactionsState.highlightedKeys, - onEmojiReactionClicked = onEmojiReactionClicked, - onCustomReactionClicked = onCustomReactionClicked, - recentEmojis = target.recentEmojis, + onEmojiReactionClick = onEmojiReactionClick, + onCustomReactionClick = onCustomReactionClick, + recentEmojis = target.recentEmojis, // SC modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() @@ -201,7 +201,7 @@ private fun SheetContent( ) { action -> ListItem( modifier = Modifier.clickable { - onActionClicked(action) + onActionClick(action) }, headlineContent = { Text(text = stringResource(id = action.titleRes)) @@ -230,7 +230,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif } val context = LocalContext.current - val formatter = remember(context) { MessageSummaryFormatterImpl(context) } + val formatter = remember(context) { DefaultMessageSummaryFormatter(context) } val textContent = remember(event.content) { formatter.format(event) } when (event.content) { @@ -293,9 +293,9 @@ private val emojiRippleRadius = 24.dp @Composable private fun EmojiReactionsRow( highlightedEmojis: ImmutableList, - onEmojiReactionClicked: (String) -> Unit, - onCustomReactionClicked: () -> Unit, - recentEmojis: List, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, + recentEmojis: List, // SC modifier: Modifier = Modifier, ) { Row( @@ -312,7 +312,7 @@ private fun EmojiReactionsRow( )).take(EMOJI_COUNT_QUICK_PICKER) for (emoji in defaultEmojis) { val isHighlighted = highlightedEmojis.contains(emoji) - EmojiButton(emoji, isHighlighted, onEmojiReactionClicked) + EmojiButton(emoji, isHighlighted, onEmojiReactionClick) } Box( modifier = Modifier @@ -327,7 +327,7 @@ private fun EmojiReactionsRow( .size(24.dp) .clickable( enabled = true, - onClick = onCustomReactionClicked, + onClick = onCustomReactionClick, indication = rememberRipple(bounded = false, radius = emojiRippleRadius), interactionSource = remember { MutableInteractionSource() } ) @@ -340,7 +340,7 @@ private fun EmojiReactionsRow( private fun EmojiButton( emoji: String, isHighlighted: Boolean, - onClicked: (String) -> Unit, + onClick: (String) -> Unit, ) { val backgroundColor = if (isHighlighted) { ElementTheme.colors.bgActionPrimaryRest @@ -367,7 +367,7 @@ private fun EmojiButton( modifier = Modifier .clickable( enabled = true, - onClick = { onClicked(emoji) }, + onClick = { onClick(emoji) }, indication = rememberRipple(bounded = false, radius = emojiRippleRadius), interactionSource = remember { MutableInteractionSource() } ) @@ -382,8 +382,8 @@ internal fun SheetContentPreview( ) = ElementPreview { SheetContent( state = state, - onActionClicked = {}, - onEmojiReactionClicked = {}, - onCustomReactionClicked = {}, + onActionClick = {}, + onEmojiReactionClick = {}, + onCustomReactionClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index ad44a1368e..8b013f165a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -16,6 +16,7 @@ package io.element.android.features.messages.impl.attachments.preview +import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment data class AttachmentsPreviewState( @@ -24,8 +25,11 @@ data class AttachmentsPreviewState( val eventSink: (AttachmentsPreviewEvents) -> Unit ) +@Immutable sealed interface SendActionState { data object Idle : SendActionState + + @Immutable sealed interface Sending : SendActionState { data object Processing : Sending data class Uploading(val progress: Float) : Sending diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index e440a8a9cb..dc13758bd1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -73,22 +73,22 @@ fun AttachmentsPreviewView( Scaffold(modifier) { AttachmentPreviewContent( attachment = state.attachment, - onSendClicked = ::postSendAttachment, + onSendClick = ::postSendAttachment, onDismiss = onDismiss ) } AttachmentSendStateView( sendActionState = state.sendActionState, - onDismissClicked = ::postClearSendState, - onRetryClicked = ::postSendAttachment + onDismissClick = ::postClearSendState, + onRetryClick = ::postSendAttachment ) } @Composable private fun AttachmentSendStateView( sendActionState: SendActionState, - onDismissClicked: () -> Unit, - onRetryClicked: () -> Unit + onDismissClick: () -> Unit, + onRetryClick: () -> Unit ) { when (sendActionState) { is SendActionState.Sending -> { @@ -99,14 +99,14 @@ private fun AttachmentSendStateView( }, text = stringResource(id = CommonStrings.common_sending), isCancellable = true, - onDismissRequest = onDismissClicked, + onDismissRequest = onDismissClick, ) } is SendActionState.Failure -> { RetryDialog( content = stringResource(sendAttachmentError(sendActionState.error)), - onDismiss = onDismissClicked, - onRetry = onRetryClicked + onDismiss = onDismissClick, + onRetry = onRetryClick ) } else -> Unit @@ -116,7 +116,7 @@ private fun AttachmentSendStateView( @Composable private fun AttachmentPreviewContent( attachment: Attachment, - onSendClicked: () -> Unit, + onSendClick: () -> Unit, onDismiss: () -> Unit, ) { Box( @@ -146,8 +146,8 @@ private fun AttachmentPreviewContent( } } AttachmentsPreviewBottomActions( - onCancelClicked = onDismiss, - onSendClicked = onSendClicked, + onCancelClick = onDismiss, + onSendClick = onSendClick, modifier = Modifier .fillMaxWidth() .background(Color.Black.copy(alpha = 0.7f)) @@ -159,13 +159,13 @@ private fun AttachmentPreviewContent( @Composable private fun AttachmentsPreviewBottomActions( - onCancelClicked: () -> Unit, - onSendClicked: () -> Unit, + onCancelClick: () -> Unit, + onSendClick: () -> Unit, modifier: Modifier = Modifier ) { ButtonRowMolecule(modifier = modifier) { - TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked) - TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked) + TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) + TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt index 4f58cfa274..37aabfd4b8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -36,7 +36,6 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectMode -import kotlinx.collections.immutable.ImmutableList import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -94,12 +93,12 @@ class ForwardMessagesNode @AssistedInject constructor( val state = presenter.present() ForwardMessagesView( state = state, - onForwardingSucceeded = ::onSucceeded, + onForwardSuccess = ::onForwardSuccess, ) } } - private fun onSucceeded(roomIds: ImmutableList) { + private fun onForwardSuccess(roomIds: List) { navigateUp() if (roomIds.size == 1) { val targetRoomId = roomIds.first() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt index 9cfdbcd14d..3b311c7ced 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenter.kt @@ -18,15 +18,13 @@ package io.element.android.features.messages.impl.forward import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState 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.timeline.TimelineProvider @@ -38,7 +36,7 @@ import kotlinx.coroutines.launch class ForwardMessagesPresenter @AssistedInject constructor( @Assisted eventId: String, - private val matrixCoroutineScope: CoroutineScope, + private val appCoroutineScope: CoroutineScope, private val timelineProvider: TimelineProvider, ) : Presenter { private val eventId: EventId = EventId(eventId) @@ -48,28 +46,22 @@ class ForwardMessagesPresenter @AssistedInject constructor( fun create(eventId: String): ForwardMessagesPresenter } - private val forwardingActionState: MutableState>> = mutableStateOf(AsyncData.Uninitialized) + private val forwardingActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) fun onRoomSelected(roomIds: List) { - matrixCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState) + appCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState) } @Composable override fun present(): ForwardMessagesState { - val forwardingSucceeded by remember { - derivedStateOf { forwardingActionState.value.dataOrNull() } - } - fun handleEvents(event: ForwardMessagesEvents) { when (event) { - ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncData.Uninitialized + ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized } } return ForwardMessagesState( - isForwarding = forwardingActionState.value.isLoading(), - error = (forwardingActionState.value as? AsyncData.Failure)?.error, - forwardingSucceeded = forwardingSucceeded, + forwardAction = forwardingActionState.value, eventSink = { handleEvents(it) } ) } @@ -77,12 +69,11 @@ class ForwardMessagesPresenter @AssistedInject constructor( private fun CoroutineScope.forwardEvent( eventId: EventId, roomIds: ImmutableList, - isForwardMessagesState: MutableState>>, + isForwardMessagesState: MutableState>>, ) = launch { - isForwardMessagesState.value = AsyncData.Loading() - timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold( - { isForwardMessagesState.value = AsyncData.Success(roomIds) }, - { isForwardMessagesState.value = AsyncData.Failure(it) } - ) + suspend { + timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow() + roomIds + }.runCatchingUpdatingState(isForwardMessagesState) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt index d64c4dd7e9..166ab7b65d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesState.kt @@ -16,13 +16,10 @@ package io.element.android.features.messages.impl.forward +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.collections.immutable.ImmutableList data class ForwardMessagesState( - // TODO Migrate to an Async - val isForwarding: Boolean, - val error: Throwable?, - val forwardingSucceeded: ImmutableList?, + val forwardAction: AsyncAction>, val eventSink: (ForwardMessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt index aa33b1cbd9..11d0f7ae8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesStateProvider.kt @@ -17,34 +17,31 @@ package io.element.android.features.messages.impl.forward import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf open class ForwardMessagesStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aForwardMessagesState(), aForwardMessagesState( - isForwarding = true, + forwardAction = AsyncAction.Loading, ), aForwardMessagesState( - forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), + forwardAction = AsyncAction.Success( + listOf(RoomId("!room2:domain")), + ) ), aForwardMessagesState( - error = Throwable("error"), + forwardAction = AsyncAction.Failure(Throwable("error")), ), - // Add other states here ) } fun aForwardMessagesState( - isForwarding: Boolean = false, - error: Throwable? = null, - forwardingSucceeded: ImmutableList? = null, + forwardAction: AsyncAction> = AsyncAction.Uninitialized, + eventSink: (ForwardMessagesEvents) -> Unit = {} ) = ForwardMessagesState( - isForwarding = isForwarding, - error = error, - forwardingSucceeded = forwardingSucceeded, - eventSink = {} + forwardAction = forwardAction, + eventSink = eventSink ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt index 089046544b..310e0c85fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -17,45 +17,25 @@ package io.element.android.features.messages.impl.forward import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter -import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults +import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.matrix.api.core.RoomId -import kotlinx.collections.immutable.ImmutableList @Composable fun ForwardMessagesView( state: ForwardMessagesState, - onForwardingSucceeded: (ImmutableList) -> Unit, - modifier: Modifier = Modifier, + onForwardSuccess: (List) -> Unit, ) { - if (state.forwardingSucceeded != null) { - onForwardingSucceeded(state.forwardingSucceeded) - return - } - - if (state.isForwarding) { - ProgressDialog(modifier) - } - - if (state.error != null) { - ForwardingErrorDialog( - modifier = modifier, - onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) }, - ) - } -} - -@Composable -private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) { - ErrorDialog( - content = ErrorDialogDefaults.title, - onDismiss = onDismiss, - modifier = modifier, + AsyncActionView( + async = state.forwardAction, + onSuccess = { + onForwardSuccess(it) + }, + onErrorDismiss = { + state.eventSink(ForwardMessagesEvents.ClearError) + }, ) } @@ -64,6 +44,6 @@ private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Mo internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview { ForwardMessagesView( state = state, - onForwardingSucceeded = {} + onForwardSuccess = {} ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index 6639910ec9..9af2407657 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -53,7 +53,7 @@ fun MentionSuggestionsPickerView( roomName: String?, roomAvatarData: AvatarData?, memberSuggestions: ImmutableList, - onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit, + onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -74,7 +74,7 @@ fun MentionSuggestionsPickerView( roomId = roomId.value, roomName = roomName, roomAvatar = roomAvatarData, - onSuggestionSelected = onSuggestionSelected, + onSelectSuggestion = onSelectSuggestion, modifier = Modifier.fillMaxWidth() ) HorizontalDivider(modifier = Modifier.fillMaxWidth()) @@ -89,10 +89,10 @@ private fun RoomMemberSuggestionItemView( roomId: String, roomName: String?, roomAvatar: AvatarData?, - onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit, + onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { val avatarSize = AvatarSize.TimelineRoom val avatarData = when (memberSuggestion) { is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) @@ -164,7 +164,7 @@ internal fun MentionSuggestionsPickerViewPreview() { ResolvedMentionSuggestion.Member(roomMember), ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), - onSuggestionSelected = {} + onSelectSuggestion = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt index 8845854602..8ba8ceec01 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -48,8 +48,8 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable internal fun AttachmentsBottomSheet( state: MessageComposerState, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, enableTextFormatting: Boolean, modifier: Modifier = Modifier, ) { @@ -87,8 +87,8 @@ internal fun AttachmentsBottomSheet( AttachmentSourcePickerMenu( state = state, enableTextFormatting = enableTextFormatting, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, ) } } @@ -97,8 +97,8 @@ internal fun AttachmentsBottomSheet( @Composable private fun AttachmentSourcePickerMenu( state: MessageComposerState, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, enableTextFormatting: Boolean, ) { Column( @@ -134,7 +134,7 @@ private fun AttachmentSourcePickerMenu( ListItem( modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.Location) - onSendLocationClicked() + onSendLocationClick() }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, @@ -145,7 +145,7 @@ private fun AttachmentSourcePickerMenu( ListItem( modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll) - onCreatePollClicked() + onCreatePollClick() }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())), headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) }, @@ -170,8 +170,8 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { state = aMessageComposerState( canShareLocation = true, ), - onSendLocationClicked = {}, - onCreatePollClicked = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, enableTextFormatting = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt similarity index 93% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt index 2353285499..0eba289355 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/DefaultMessageComposerContext.kt @@ -28,7 +28,7 @@ import javax.inject.Inject @SingleIn(RoomScope::class) @ContributesBinding(RoomScope::class) -class MessageComposerContextImpl @Inject constructor() : MessageComposerContext { +class DefaultMessageComposerContext @Inject constructor() : MessageComposerContext { override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal) internal set } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 2c4e180bd7..f008f78d66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor @@ -62,12 +63,13 @@ import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider -import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException @@ -100,7 +102,7 @@ class MessageComposerPresenter @Inject constructor( private val mediaSender: MediaSender, private val snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, - private val messageComposerContext: MessageComposerContextImpl, + private val messageComposerContext: DefaultMessageComposerContext, private val richTextEditorStateFactory: RichTextEditorStateFactory, private val currentSessionIdHolder: CurrentSessionIdHolder, private val permalinkParser: PermalinkParser, @@ -132,7 +134,7 @@ class MessageComposerPresenter @Inject constructor( if (isTesting) { richTextEditorState.isReadyToProcessActions = true } - val markdownTextEditorState = remember { MarkdownTextEditorState(initialText = null, initialFocus = false) } + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -388,6 +390,9 @@ class MessageComposerPresenter @Inject constructor( is MessageComposerEvents.ToggleTextFormatting -> { showAttachmentSourcePicker = false showTextFormatting = event.enabled + if (showTextFormatting) { + analyticsService.captureInteraction(Interaction.Name.MobileRoomComposerFormattingEnabled) + } } is MessageComposerEvents.Error -> { analyticsService.trackError(event.error) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index e8aeb7e6ed..af2ecf231a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -125,11 +125,11 @@ internal fun MessageComposerView( onVoicePlayerEvent = onVoicePlayerEvent, onSendVoiceMessage = onSendVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage, - onSuggestionReceived = ::onSuggestionReceived, + onReceiveSuggestion = ::onSuggestionReceived, onError = ::onError, onTyping = ::onTyping, currentUserId = state.currentUserId, - onRichContentSelected = ::sendUri, + onSelectRichContent = ::sendUri, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt index a1157e2830..910de10ff2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt @@ -52,7 +52,7 @@ class ReportMessageNode @AssistedInject constructor( val state = presenter.present() ReportMessageView( state = state, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt index 2791ee632c..720c65dfc1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -58,7 +58,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ReportMessageView( state: ReportMessageState, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -66,7 +66,7 @@ fun ReportMessageView( AsyncActionView( async = state.result, progressDialog = {}, - onSuccess = { onBackClicked() }, + onSuccess = { onBackClick() }, errorMessage = { stringResource(CommonStrings.error_unknown) }, onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) } ) @@ -81,7 +81,7 @@ fun ReportMessageView( ) }, navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) } ) }, @@ -160,7 +160,7 @@ fun ReportMessageView( @Composable internal fun ReportMessageViewPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = ElementPreview { ReportMessageView( - onBackClicked = {}, + onBackClick = {}, state = state, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt index 6b83480650..c61033500a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/sender/SenderNameMode.kt @@ -16,8 +16,10 @@ package io.element.android.features.messages.impl.sender +import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +@Immutable sealed interface SenderNameMode { data class Timeline(val mainColor: Color) : SenderNameMode data object Reply : SenderNameMode diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index d2c530bb52..95eb80f927 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -41,16 +41,16 @@ sealed interface TimelineEvents { */ sealed interface TimelineItemPollEvents : EventFromTimelineItem - data class PollAnswerSelected( + data class SelectPollAnswer( val pollStartId: EventId, val answerId: String ) : TimelineItemPollEvents - data class PollEndClicked( + data class EndPoll( val pollStartId: EventId, ) : TimelineItemPollEvents - data class PollEditClicked( + data class EditPoll( val pollStartId: EventId, ) : TimelineItemPollEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index e89611b620..1f45c0dce1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -143,19 +143,19 @@ class TimelinePresenter @AssistedInject constructor( newEventState.value = NewEventState.None } } - is TimelineEvents.PollAnswerSelected -> appScope.launch { + is TimelineEvents.SelectPollAnswer -> appScope.launch { sendPollResponseAction.execute( pollStartId = event.pollStartId, answerId = event.answerId ) } - is TimelineEvents.PollEndClicked -> appScope.launch { + is TimelineEvents.EndPoll -> appScope.launch { endPollAction.execute( pollStartId = event.pollStartId, ) } - is TimelineEvents.PollEditClicked -> { - navigator.onEditPollClicked(event.pollStartId) + is TimelineEvents.EditPoll -> { + navigator.onEditPollClick(event.pollStartId) } is TimelineEvents.FocusOnEvent -> localScope.launch { focusedEventId.value = event.eventId diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index 3b08edf5f8..5675568fe7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -83,15 +83,15 @@ import kotlin.math.abs fun TimelineView( state: TimelineState, typingNotificationState: TypingNotificationState, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onMessageClicked: (TimelineItem.Event) -> Unit, - onMessageLongClicked: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + onMessageClick: (TimelineItem.Event) -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, - onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, - onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit, - onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false @@ -100,8 +100,7 @@ fun TimelineView( state.eventSink(TimelineEvents.ClearFocusRequestState) } - fun onScrollFinishedAt(firstVisibleIndex: Int, visibleItemCount: Int) { - timber.log.Timber.i("oncrollFinishedAt") + fun onScrollFinishAt(firstVisibleIndex: Int, visibleItemCount: Int) { state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) val timeline = state.timelineItems val firstVisibleTimelineIndex = effectiveVisibleTimelineItemIndex(firstVisibleIndex) @@ -120,7 +119,7 @@ fun TimelineView( accessibilityManager.isTouchExplorationEnabled.not() } - fun inReplyToClicked(eventId: EventId) { + fun inReplyToClick(eventId: EventId) { state.eventSink(TimelineEvents.FocusOnEvent(eventId)) } @@ -150,16 +149,16 @@ fun TimelineView( isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && state.timelineItems.first().identifier() == timelineItem.identifier(), focusedEventId = state.focusedEventId, - onClick = onMessageClicked, - onLongClick = onMessageLongClicked, - onUserDataClick = onUserDataClicked, - onLinkClicked = onLinkClicked, - inReplyToClick = ::inReplyToClicked, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, + onClick = onMessageClick, + onLongClick = onMessageLongClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + inReplyToClick = ::inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, + onTimestampClick = onTimestampClick, eventSink = state.eventSink, onSwipeToReply = onSwipeToReply, ) @@ -178,7 +177,7 @@ fun TimelineView( newEventState = state.newEventState, isLive = state.isLive, focusRequestState = state.focusRequestState, - onScrollFinishedAt = ::onScrollFinishedAt, + onScrollFinishAt = ::onScrollFinishAt, onClearFocusRequestState = ::clearFocusRequestState, onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) }, ) @@ -198,7 +197,7 @@ private fun BoxScope.TimelineScrollHelper( forceJumpToBottomVisibility: Boolean, focusRequestState: FocusRequestState, onClearFocusRequestState: () -> Unit, - onScrollFinishedAt: (Int, Int) -> Unit, + onScrollFinishAt: (Int, Int) -> Unit, onJumpToLive: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -246,12 +245,12 @@ private fun BoxScope.TimelineScrollHelper( } } - //val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt) + //val latestOnScrollFinishAt by rememberUpdatedState(onScrollFinishAt) LaunchedEffect(isScrollFinished, hasAnyEvent) { if (isScrollFinished && hasAnyEvent) { // Notify the parent composable about the first visible item index when scrolling finishes - //latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex, lazyListState.layoutInfo.visibleItemsInfo.size) - onScrollFinishedAt(lazyListState.firstVisibleItemIndex, lazyListState.layoutInfo.visibleItemsInfo.size) + //latestOnScrollFinishAt(lazyListState.firstVisibleItemIndex) + onScrollFinishAt(lazyListState.firstVisibleItemIndex, lazyListState.layoutInfo.visibleItemsInfo.size) } } @@ -311,15 +310,15 @@ internal fun TimelineViewPreview( focusedEventIndex = 0, ), typingNotificationState = aTypingNotificationState(), - onUserDataClicked = {}, - onLinkClicked = {}, - onMessageClicked = {}, - onMessageLongClicked = {}, - onTimestampClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onMessageClick = {}, + onMessageLongClick = {}, + onTimestampClick = {}, onSwipeToReply = {}, - onReactionClicked = { _, _ -> }, - onReactionLongClicked = { _, _ -> }, - onMoreReactionsClicked = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, onReadReceiptClick = {}, forceJumpToBottomVisibility = true, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index 92b12b2dc8..b6e6646c1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -38,13 +38,13 @@ internal fun ATimelineItemEventRow( onClick = {}, onLongClick = {}, onUserDataClick = {}, - onLinkClicked = {}, + onLinkClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, onSwipeToReply = {}, - onTimestampClicked = {}, + onTimestampClick = {}, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 86248bd0be..c4966447ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -135,10 +135,10 @@ fun TimelineItemEventRow( isHighlighted: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, @@ -150,11 +150,11 @@ fun TimelineItemEventRow( val coroutineScope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } - fun onUserDataClicked() { + fun onUserDataClick() { onUserDataClick(event.senderId) } - fun inReplyToClicked() { + fun inReplyToClick() { val inReplyToEventId = event.inReplyTo?.eventId() ?: return inReplyToClick(inReplyToEventId) } @@ -199,13 +199,13 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick, - onTimestampClicked = onTimestampClicked, - inReplyToClicked = ::inReplyToClicked, - onUserDataClicked = ::onUserDataClicked, - onReactionClicked = { emoji -> onReactionClick(emoji, event) }, - onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, - onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onLinkClicked = onLinkClicked, + onTimestampClick = onTimestampClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -218,13 +218,13 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick, - onTimestampClicked = onTimestampClicked, - inReplyToClicked = ::inReplyToClicked, - onUserDataClicked = ::onUserDataClicked, - onReactionClicked = { emoji -> onReactionClick(emoji, event) }, - onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, - onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onLinkClicked = onLinkClicked, + onTimestampClick = onTimestampClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -236,7 +236,7 @@ fun TimelineItemEventRow( receipts = event.readReceiptState.receipts, ), renderReadReceipts = renderReadReceipts, - onReadReceiptsClicked = { onReadReceiptClick(event) }, + onReadReceiptsClick = { onReadReceiptClick(event) }, modifier = Modifier.padding(top = 4.dp), ) } @@ -274,13 +274,13 @@ private fun TimelineItemEventRowContent( interactionSource: MutableInteractionSource, onClick: () -> Unit, onLongClick: () -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, - inReplyToClicked: () -> Unit, - onUserDataClicked: () -> Unit, - onReactionClicked: (emoji: String) -> Unit, - onReactionLongClicked: (emoji: String) -> Unit, - onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, - onLinkClicked: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, + inReplyToClick: () -> Unit, + onUserDataClick: () -> Unit, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: (event: TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -315,7 +315,7 @@ private fun TimelineItemEventRowContent( } .padding(horizontal = 16.dp) .zIndex(1f) - .clickable(onClick = onUserDataClicked) + .clickable(onClick = onUserDataClick) // This is redundant when using talkback .clearAndSetSemantics { invisibleToUser() @@ -346,11 +346,11 @@ private fun TimelineItemEventRowContent( MessageEventBubbleContent( event = event, onMessageLongClick = onLongClick, - inReplyToClick = inReplyToClicked, - onTimestampClicked = { - onTimestampClicked(event) + inReplyToClick = inReplyToClick, + onTimestampClick = { + onTimestampClick(event) }, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -361,9 +361,9 @@ private fun TimelineItemEventRowContent( reactionsState = event.reactionsState, userCanSendReaction = timelineRoomInfo.userHasPermissionToSendReaction, isOutgoing = event.isMine, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = { onMoreReactionsClicked(event) }, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = { onMoreReactionsClick(event) }, modifier = Modifier .constrainAs(reactions) { top.linkTo(message.bottom, margin = (-4).dp) @@ -433,8 +433,8 @@ private fun MessageEventBubbleContent( event: TimelineItem.Event, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, - onTimestampClicked: () -> Unit, - onLinkClicked: (String) -> Unit, + onTimestampClick: () -> Unit, + onLinkClick: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @SuppressLint("ModifierParameter") // need to rename this modifier to prevent linter false positives @@ -474,7 +474,7 @@ private fun MessageEventBubbleContent( timestampPosition: TimestampPosition, modifier: Modifier = Modifier, canShrinkContent: Boolean = false, - content: @Composable (onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit) -> Unit, + content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { when (timestampPosition) { TimestampPosition.Overlay -> @@ -482,7 +482,7 @@ private fun MessageEventBubbleContent( content {} TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier .scOrElse( @@ -506,11 +506,11 @@ private fun MessageEventBubbleContent( spacing = (-4).dp, overlayOffset = DpOffset(0.dp, -1.dp), shrinkContent = canShrinkContent, - content = { content(this::onContentLayoutChanged) }, + content = { content(this::onContentLayoutChange) }, overlay = { TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier .padding(horizontal = 8.dp, vertical = 4.dp) @@ -522,7 +522,7 @@ private fun MessageEventBubbleContent( content {} TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier .align(Alignment.End) @@ -573,13 +573,13 @@ private fun MessageEventBubbleContent( timestampPosition = timestampPosition, canShrinkContent = canShrinkContent, modifier = timestampLayoutModifier, - ) { onContentLayoutChanged -> + ) { onContentLayoutChange -> TimelineItemEventContentView( content = event.content, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, onLongClick = onMessageLongClick, eventSink = eventSink, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = contentModifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 2dcc0c1b55..6f9f8146a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -48,8 +48,8 @@ fun TimelineItemGroupedEventsRow( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -75,8 +75,8 @@ fun TimelineItemGroupedEventsRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, @@ -99,8 +99,8 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -132,8 +132,8 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, @@ -151,7 +151,7 @@ private fun TimelineItemGroupedEventsRowContent( receipts = timelineItem.aggregatedReadReceipts, ), renderReadReceipts = true, - onReadReceiptsClicked = onExpandGroupClick + onReadReceiptsClick = onExpandGroupClick ) } } @@ -173,8 +173,8 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, - onLinkClicked = {}, - onTimestampClicked = {}, + onLinkClick = {}, + onTimestampClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, @@ -198,8 +198,8 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, - onLinkClicked = {}, - onTimestampClicked = {}, + onLinkClick = {}, + onTimestampClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index ba357ab573..0b9d43d285 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -42,9 +42,9 @@ fun TimelineItemReactionsView( reactionsState: TimelineItemReactions, isOutgoing: Boolean, userCanSendReaction: Boolean, - onReactionClicked: (emoji: String) -> Unit, - onReactionLongClicked: (emoji: String) -> Unit, - onMoreReactionsClicked: () -> Unit, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: () -> Unit, modifier: Modifier = Modifier, ) { var expanded: Boolean by rememberSaveable { mutableStateOf(false) } @@ -54,9 +54,9 @@ fun TimelineItemReactionsView( userCanSendReaction = userCanSendReaction, expanded = expanded, isOutgoing = isOutgoing, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onToggleExpandClick = { expanded = !expanded }, ) } @@ -179,8 +179,8 @@ private fun ContentToPreview( ), userCanSendReaction = true, isOutgoing = isOutgoing, - onReactionClicked = {}, - onReactionLongClicked = {}, - onMoreReactionsClicked = {}, + onReactionClick = {}, + onReactionLongClick = {}, + onMoreReactionsClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 78e39f5981..5a07b7a0be 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -46,7 +46,7 @@ internal fun TimelineItemRow( isLastOutgoingMessage: Boolean, focusedEventId: EventId?, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, @@ -54,7 +54,7 @@ internal fun TimelineItemRow( onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier @@ -100,13 +100,13 @@ internal fun TimelineItemRow( onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, inReplyToClick = inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, + onTimestampClick = onTimestampClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, eventSink = eventSink, ) @@ -123,8 +123,8 @@ internal fun TimelineItemRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index 7d499cfaba..f3bef11723 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -79,7 +79,7 @@ fun TimelineItemStateEventRow( ) { TimelineItemEventContentView( content = event.content, - onLinkClicked = {}, + onLinkClick = {}, onLongClick = onLongClick, eventSink = eventSink, modifier = Modifier.defaultTimelineContentPadding() @@ -93,7 +93,7 @@ fun TimelineItemStateEventRow( receipts = event.readReceiptState.receipts, ), renderReadReceipts = renderReadReceipts, - onReadReceiptsClicked = { onReadReceiptsClick(event) }, + onReadReceiptsClick = { onReadReceiptsClick(event) }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index 988324568d..9c530e6c4b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -34,9 +34,9 @@ import io.element.android.libraries.matrix.api.core.EventId @Composable fun CustomReactionBottomSheet( state: CustomReactionState, - onEmojiSelected: (EventId, Emoji) -> Unit, - onCustomEmojiSelected: (EventId, String) -> Unit, - recentEmojiDataSource: RecentEmojiDataSource?, + onSelectEmoji: (EventId, Emoji) -> Unit, + onSelectCustomEmoji: (EventId, String) -> Unit, // SC + recentEmojiDataSource: RecentEmojiDataSource?, // SC modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = ScPrefs.PREFER_FULLSCREEN_REACTION_SHEET.value()) @@ -54,7 +54,7 @@ fun CustomReactionBottomSheet( sheetState.hide(coroutineScope) { state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) - onEmojiSelected(target.event.eventId, emoji) + onSelectEmoji(target.event.eventId, emoji) if (!wasSelected) recentEmojiDataSource?.recordEmoji(emoji.unicode) } @@ -67,7 +67,7 @@ fun CustomReactionBottomSheet( sheetState.hide(coroutineScope) { state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) - onCustomEmojiSelected(target.event.eventId, emoji) + onSelectCustomEmoji(target.event.eventId, emoji) if (!wasSelected) recentEmojiDataSource?.recordEmoji(emoji) } @@ -80,8 +80,8 @@ fun CustomReactionBottomSheet( modifier = modifier ) { EmojiPicker( - onEmojiSelected = ::onEmojiSelectedDismiss, - onCustomEmojiSelected = ::onCustomEmojiSelectedDismiss, + onSelectEmoji = ::onEmojiSelectedDismiss, + onSelectCustomEmoji = ::onCustomEmojiSelectedDismiss, emojibaseStore = target.emojibaseStore, selectedEmojis = state.selectedEmoji, recentEmojiDataSource = recentEmojiDataSource, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 8b720ce390..25bc605e73 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -49,7 +49,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun EmojiItem( item: Emoji, isSelected: Boolean, - onEmojiSelected: (Emoji) -> Unit, + onSelectEmoji: (Emoji) -> Unit, modifier: Modifier = Modifier, emojiSize: TextUnit = 20.sp, ) { @@ -69,7 +69,7 @@ fun EmojiItem( .background(backgroundColor, CircleShape) .clickable( enabled = true, - onClick = { onEmojiSelected(item) }, + onClick = { onSelectEmoji(item) }, indication = rememberRipple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), interactionSource = remember { MutableInteractionSource() } ) @@ -102,7 +102,7 @@ internal fun EmojiItemPreview() = ElementPreview { skins = null ), isSelected = isSelected, - onEmojiSelected = {}, + onSelectEmoji = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt index 56cfd867f7..a8ea4a93a3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt @@ -56,8 +56,8 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun EmojiPicker( - onEmojiSelected: (Emoji) -> Unit, - onCustomEmojiSelected: (String) -> Unit, + onSelectEmoji: (Emoji) -> Unit, + onSelectCustomEmoji: (String) -> Unit, emojibaseStore: EmojibaseStore, selectedEmojis: ImmutableSet, recentEmojiDataSource: RecentEmojiDataSource? = null, @@ -96,7 +96,7 @@ fun EmojiPicker( modifier = Modifier.fillMaxWidth(), ) { scIndex -> val index = scIndex.removeScPickerOffset() - if (scEmojiPickerPage(scIndex, pagerState.currentPage, selectedEmojis, recentEmojiDataSource, onCustomEmojiSelected)) { + if (scEmojiPickerPage(scIndex, pagerState.currentPage, selectedEmojis, recentEmojiDataSource, onSelectCustomEmoji)) { return@HorizontalPager } val category = EmojibaseCategory.entries[index] @@ -113,7 +113,7 @@ fun EmojiPicker( modifier = Modifier.aspectRatio(1f), item = item, isSelected = selectedEmojis.contains(item.unicode), - onEmojiSelected = onEmojiSelected, + onSelectEmoji = onSelectEmoji, emojiSize = 32.dp.toSp(), ) } @@ -126,8 +126,8 @@ fun EmojiPicker( @Composable internal fun EmojiPickerPreview() = ElementPreview { EmojiPicker( - onEmojiSelected = {}, - onCustomEmojiSelected = {}, + onSelectEmoji = {}, + onSelectCustomEmoji = {}, emojibaseStore = EmojibaseDatasource().load(LocalContext.current), selectedEmojis = persistentSetOf("😀", "😄", "😃"), modifier = Modifier.fillMaxWidth(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ScEmojiPickerExtensions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ScEmojiPickerExtensions.kt index e727cd9597..b466f3c4e5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ScEmojiPickerExtensions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/ScEmojiPickerExtensions.kt @@ -102,7 +102,7 @@ fun scEmojiPickerPage( selectedIndex: Int, selectedEmojis: ImmutableSet, recentEmojiDataSource: RecentEmojiDataSource?, - onCustomEmojiSelected: (String) -> Unit + onSelectCustomEmoji: (String) -> Unit ): Boolean { return when (index) { PAGE_RECENT_EMOJI -> { @@ -127,7 +127,7 @@ fun scEmojiPickerPage( modifier = Modifier.aspectRatio(1f), item = Emoji("", "", null, emptyList(), item, null), isSelected = selectedEmojis.contains(item), - onEmojiSelected = { onCustomEmojiSelected(it.unicode) }, + onSelectEmoji = { onSelectCustomEmoji(it.unicode) }, emojiSize = 32.dp.toSp(), ) } @@ -157,10 +157,10 @@ fun scEmojiPickerPage( unfocusedContainerColor = Color.Transparent, ), keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { onCustomEmojiSelected(text.value) }) + keyboardActions = KeyboardActions(onSend = { onSelectCustomEmoji(text.value) }) ) SendButton { - onCustomEmojiSelected(text.value) + onSelectCustomEmoji(text.value) } } LaunchedEffect(selectedIndex == PAGE_FREEFORM_REACTION) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ScTimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ScTimelineItemLocationView.kt index 7f1126108d..b912264768 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ScTimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ScTimelineItemLocationView.kt @@ -51,7 +51,7 @@ private val ICON_RESERVED_WIDTH = ICON_SIZE + ICON_PADDING * 2 + ICON_MARGIN + 4 @Composable fun ScTimelineItemLocationView( content: TimelineItemLocationContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -78,7 +78,7 @@ fun ScTimelineItemLocationView( text = text, style = ElementRichTextEditorStyle.textStyle(), onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine( - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, extraWidth = ICON_RESERVED_WIDTH ), releaseOnDetach = false, @@ -98,7 +98,7 @@ fun ScTimelineItemLocationPreview() = ElementPreview { Location(0.0, 0.0, 0f), "Description" ), - onContentLayoutChanged = {}, + onContentLayoutChange = {}, modifier = Modifier.padding(8.dp) ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d4a2dbabc9..d618fe4244 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 32.dp @@ -85,7 +85,7 @@ fun TimelineItemAudioView( maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, extraWidth = iconSize + spacing ) ) @@ -99,6 +99,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon ElementPreview { TimelineItemAudioView( content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt index b0753be9b1..8cb9c9e58f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemEncryptedView( content: TimelineItemEncryptedContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { val isMembershipUtd = (content.data as? UnableToDecryptContent.Data.MegolmV1AesSha2)?.utdCause == UtdCause.Membership @@ -46,7 +46,7 @@ fun TimelineItemEncryptedView( text = stringResource(id = textId), iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconResourceId = iconId, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -58,6 +58,6 @@ internal fun TimelineItemEncryptedViewPreview( ) = ElementPreview { TimelineItemEncryptedView( content = content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 95279fd0ad..ee30f215cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -46,44 +46,44 @@ import io.element.android.libraries.architecture.Presenter @Composable fun TimelineItemEventContentView( content: TimelineItemEventContent, - onLinkClicked: (url: String) -> Unit, - onLongClick: () -> Unit, + onLinkClick: (url: String) -> Unit, + onLongClick: () -> Unit, // SC eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {}, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemRedactedContent -> TimelineItemRedactedView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemTextBasedContent -> TimelineItemTextView( content = content, - modifier = modifier.thenIf(content is TimelineItemNoticeContent) { alpha(0.65f) }, - onLinkClicked = onLinkClicked, - onLongClick = onLongClick, - onContentLayoutChanged = onContentLayoutChanged + modifier = modifier.thenIf(content is TimelineItemNoticeContent) { alpha(0.65f) }, // SC + onLinkClick = onLinkClick, + onLongClick = onLongClick, // SC + onContentLayoutChange = onContentLayoutChange ) is TimelineItemUnknownContent -> TimelineItemUnknownView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemLocationContent -> ScTimelineItemLocationView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemImageContent -> TimelineItemImageView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) is TimelineItemStickerContent -> TimelineItemStickerView( @@ -92,17 +92,17 @@ fun TimelineItemEventContentView( ) is TimelineItemVideoContent -> TimelineItemVideoView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemFileContent -> TimelineItemFileView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemAudioContent -> TimelineItemAudioView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) @@ -120,7 +120,7 @@ fun TimelineItemEventContentView( TimelineItemVoiceView( state = presenter.present(), content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index c30a3aa89b..bd395c01f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemFileView( content: TimelineItemFileContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 32.dp @@ -86,7 +86,7 @@ fun TimelineItemFileView( maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, extraWidth = iconSize + spacing ) ) @@ -99,6 +99,6 @@ fun TimelineItemFileView( internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = ElementPreview { TimelineItemFileView( content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index 861025a2d0..875124b51c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -68,7 +68,7 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemImageView( content: TimelineItemImageContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val description = stringResource(CommonStrings.common_image) @@ -118,7 +118,7 @@ fun TimelineItemImageView( text = caption, style = ElementRichTextEditorStyle.textStyle(), releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt index b6f5d0e23b..6d627cf360 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -41,12 +41,12 @@ fun TimelineItemInformativeView( text: String, iconDescription: String, @DrawableRes iconResourceId: Int, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.onSizeChanged { size -> - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = size.width, contentHeight = size.height, @@ -78,6 +78,6 @@ internal fun TimelineItemInformativeViewPreview() = ElementPreview { text = "Info", iconDescription = "", iconResourceId = CompoundDrawables.ic_compound_delete, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index 47f4aa7da6..65c42c8934 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -34,16 +34,16 @@ fun TimelineItemPollView( eventSink: (TimelineEvents.TimelineItemPollEvents) -> Unit, modifier: Modifier = Modifier, ) { - fun onAnswerSelected(pollStartId: EventId, answerId: String) { - eventSink(TimelineEvents.PollAnswerSelected(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + eventSink(TimelineEvents.SelectPollAnswer(pollStartId, answerId)) } - fun onPollEnd(pollStartId: EventId) { - eventSink(TimelineEvents.PollEndClicked(pollStartId)) + fun onEndPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EndPoll(pollStartId)) } - fun onPollEdit(pollStartId: EventId) { - eventSink(TimelineEvents.PollEditClicked(pollStartId)) + fun onEditPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EditPoll(pollStartId)) } PollContentView( @@ -54,9 +54,9 @@ fun TimelineItemPollView( isPollEnded = content.isEnded, isPollEditable = content.isEditable, isMine = content.isMine, - onAnswerSelected = ::onAnswerSelected, - onPollEdit = ::onPollEdit, - onPollEnd = ::onPollEnd, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = ::onEditPoll, + onEndPoll = ::onEndPoll, modifier = modifier, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt index 0c001fae26..8859f93ceb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -29,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemRedactedView( @Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { TimelineItemInformativeView( text = stringResource(id = CommonStrings.common_message_removed), iconDescription = stringResource(id = CommonStrings.common_message_removed), iconResourceId = CompoundDrawables.ic_compound_delete, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -46,6 +46,6 @@ fun TimelineItemRedactedView( internal fun TimelineItemRedactedViewPreview() = ElementPreview { TimelineItemRedactedView( TimelineItemRedactedContent, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index b72b24db25..ecc0817574 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -51,10 +51,10 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemTextView( content: TimelineItemTextBasedContent, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {}, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { val canCollapse = content.formattedCollapsedBody != null val emojiOnly = !canCollapse && SC_TIMELINE_LAYOUT.value() && @@ -78,9 +78,9 @@ fun TimelineItemTextView( ) { EditorStyledText( text = body, - onLinkClickedListener = onLinkClicked, + onLinkClickedListener = onLinkClick, style = ElementRichTextEditorStyle.textStyle(), - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), releaseOnDetach = false, ) } @@ -94,7 +94,7 @@ internal fun TimelineItemTextViewPreview( ) = ElementPreview { TimelineItemTextView( content = content, - onLinkClicked = {}, + onLinkClick = {}, onLongClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt index 0c0961a2a6..d4d0b69427 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -29,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemUnknownView( @Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { TimelineItemInformativeView( text = stringResource(id = CommonStrings.common_unsupported_event), iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconResourceId = CompoundDrawables.ic_compound_info_solid, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -46,6 +46,6 @@ fun TimelineItemUnknownView( internal fun TimelineItemUnknownViewPreview() = ElementPreview { TimelineItemUnknownView( content = TimelineItemUnknownContent, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 6711c18118..5aea80356a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -73,7 +73,7 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemVideoView( content: TimelineItemVideoContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val description = stringResource(CommonStrings.common_image) @@ -131,7 +131,7 @@ fun TimelineItemVideoView( text = caption, style = ElementRichTextEditorStyle.textStyle(), releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index 927e38371c..19fb526b87 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -67,7 +67,7 @@ import kotlinx.coroutines.delay fun TimelineItemVoiceView( state: VoiceMessageState, content: TimelineItemVoiceContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { fun playPause() { @@ -81,7 +81,7 @@ fun TimelineItemVoiceView( contentDescription = a11y } .onSizeChanged { - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = it.width, contentHeight = it.height, @@ -258,7 +258,7 @@ internal fun TimelineItemVoiceViewPreview( TimelineItemVoiceView( state = timelineItemVoiceViewParameters.state, content = timelineItemVoiceViewParameters.content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } @@ -271,7 +271,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview { TimelineItemVoiceView( state = it.state, content = it.content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt index 334899288d..4ff8c66e8b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt @@ -136,26 +136,26 @@ interface ContentAvoidingLayoutScope { /** * It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly. */ - fun onContentLayoutChanged(data: ContentAvoidingLayoutData) + fun onContentLayoutChange(data: ContentAvoidingLayoutData) } private class ContentAvoidingLayoutScopeInstance( val data: MutableState = mutableStateOf(ContentAvoidingLayoutData()), ) : ContentAvoidingLayoutScope { - override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) { + override fun onContentLayoutChange(data: ContentAvoidingLayoutData) { this.data.value = data } } object ContentAvoidingLayout { /** - * Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData]. + * Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. * * This is supposed to be used in the `onTextLayout` parameter of a Text based component. */ @Composable internal fun measureLastTextLine( - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((TextLayoutResult) -> Unit) { val layoutDirection = LocalLayoutDirection.current @@ -167,7 +167,7 @@ object ContentAvoidingLayout { LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt() } val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt() - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = textLayout.size.width + extraWidthPx, contentHeight = textLayout.size.height, @@ -179,13 +179,13 @@ object ContentAvoidingLayout { } /** - * Measures the last line of a [Layout] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData]. + * Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. * * This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component. */ @Composable internal fun measureLegacyLastTextLine( - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((Layout) -> Unit) { val extraWidthPx = extraWidth.roundToPx() @@ -193,7 +193,7 @@ object ContentAvoidingLayout { // We need to add the external extra width so it's not taken into account as 'free space' val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt() val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1) - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = textLayout.width + extraWidthPx, contentHeight = textLayout.height, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 90de492065..39f650b05f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -62,7 +62,7 @@ import kotlinx.collections.immutable.ImmutableList fun TimelineItemReadReceiptView( state: ReadReceiptViewState, renderReadReceipts: Boolean, - onReadReceiptsClicked: () -> Unit, + onReadReceiptsClick: () -> Unit, modifier: Modifier = Modifier, ) { if (state.receipts.isNotEmpty()) { @@ -74,7 +74,7 @@ fun TimelineItemReadReceiptView( .testTag(TestTags.messageReadReceipts) .clip(RoundedCornerShape(4.dp)) .clickable { - onReadReceiptsClicked() + onReadReceiptsClick() } .padding(2.dp) ) @@ -216,6 +216,6 @@ internal fun TimelineItemReadReceiptViewPreview( TimelineItemReadReceiptView( state = state, renderReadReceipts = true, - onReadReceiptsClicked = {}, + onReadReceiptsClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt index be63fa3409..1062e11977 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.launch @Composable internal fun ReadReceiptBottomSheet( state: ReadReceiptBottomSheetState, - onUserDataClicked: (UserId) -> Unit, + onUserDataClick: (UserId) -> Unit, modifier: Modifier = Modifier, ) { val isVisible = state.selectedEvent != null @@ -69,11 +69,11 @@ internal fun ReadReceiptBottomSheet( ) { ReadReceiptBottomSheetContent( state = state, - onUserDataClicked = { + onUserDataClick = { coroutineScope.launch { sheetState.hide() state.eventSink(ReadReceiptBottomSheetEvents.Dismiss) - onUserDataClicked.invoke(it) + onUserDataClick.invoke(it) } }, ) @@ -86,7 +86,7 @@ internal fun ReadReceiptBottomSheet( @Composable private fun ReadReceiptBottomSheetContent( state: ReadReceiptBottomSheetState, - onUserDataClicked: (UserId) -> Unit, + onUserDataClick: (UserId) -> Unit, ) { LazyColumn { item { @@ -101,7 +101,7 @@ private fun ReadReceiptBottomSheetContent( ) { val userId = UserId(it.avatarData.id) MatrixUserRow( - modifier = Modifier.clickable { onUserDataClicked(userId) }, + modifier = Modifier.clickable { onUserDataClick(userId) }, matrixUser = MatrixUser( userId = userId, displayName = it.avatarData.name, @@ -127,7 +127,7 @@ internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSh Column { ReadReceiptBottomSheetContent( state = state, - onUserDataClicked = {}, + onUserDataClick = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt index 6f53c5a306..824ef24f8b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -42,7 +42,7 @@ class EventDebugInfoNode @AssistedInject constructor( private val inputs = inputs() - private fun onBackPressed() { + private fun onBackClick() { navigateUp() } @@ -53,7 +53,7 @@ class EventDebugInfoNode @AssistedInject constructor( model = timelineItemDebugInfo.model, originalJson = timelineItemDebugInfo.originalJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson, - onBackPressed = ::onBackPressed + onBackClick = ::onBackClick ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt index acff89e97c..0a893c1cea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -73,7 +73,7 @@ fun EventDebugInfoView( model: String, originalJson: String?, latestEditedJson: String?, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, isTest: Boolean = false, ) { @@ -87,7 +87,7 @@ fun EventDebugInfoView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) }, modifier = modifier @@ -190,6 +190,6 @@ internal fun EventDebugInfoViewPreview() = ElementPreview { model = "Rust(\n\tModel()\n)", originalJson = "{\"name\": \"original\"}", latestEditedJson = "{\"name\": \"edited\"}", - onBackPressed = { } + onBackClick = { } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt index 91b62a6d51..0f0c2b8552 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentStickerFactory.kt @@ -20,7 +20,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import javax.inject.Inject @@ -44,7 +43,7 @@ class TimelineItemContentStickerFactory @Inject constructor( return TimelineItemStickerContent( body = content.body, - mediaSource = MediaSource(content.url), + mediaSource = content.source, thumbnailSource = content.info.thumbnailSource, mimeType = content.info.mimetype ?: MimeTypes.OctetStream, blurhash = content.info.blurhash, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt index ee6589f046..e859d5d24b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadata.kt @@ -19,7 +19,6 @@ package io.element.android.features.messages.impl.timeline.model import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource -import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent @@ -113,7 +112,7 @@ internal fun InReplyToDetails.Ready.metadata(): InReplyToMetadata? = when (event } is StickerContent -> InReplyToMetadata.Thumbnail( AttachmentThumbnailInfo( - thumbnailSource = MediaSource(eventContent.url), + thumbnailSource = eventContent.source, textContent = eventContent.body, type = AttachmentThumbnailType.Image, blurHash = eventContent.info.blurhash, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt index 3b09afb7fc..c4a8a88153 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt @@ -30,14 +30,14 @@ internal fun MessagesViewWithTypingPreview( ) = ElementPreview { MessagesView( state = aMessagesState().copy(typingNotificationState = typingState), - onBackPressed = {}, - onRoomDetailsClicked = {}, - onEventClicked = { false }, + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, onPreviewAttachments = {}, - onUserDataClicked = {}, - onLinkClicked = {}, - onSendLocationClicked = {}, - onCreatePollClicked = {}, - onJoinCallClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt similarity index 98% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt index 9f2717b124..c6615dc74b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/DefaultMessageSummaryFormatter.kt @@ -40,7 +40,7 @@ import io.element.android.libraries.ui.strings.CommonStrings import javax.inject.Inject @ContributesBinding(RoomScope::class) -class MessageSummaryFormatterImpl @Inject constructor( +class DefaultMessageSummaryFormatter @Inject constructor( @ApplicationContext private val context: Context, ) : MessageSummaryFormatter { companion object { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt index 9898aba95a..73da921488 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt @@ -29,7 +29,7 @@ internal fun VoiceMessagePermissionRationaleDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/messages/impl/src/main/res/values-be/translations.xml b/features/messages/impl/src/main/res/values-be/translations.xml index f2ea6cfd09..c10a0afad3 100644 --- a/features/messages/impl/src/main/res/values-be/translations.xml +++ b/features/messages/impl/src/main/res/values-be/translations.xml @@ -33,7 +33,7 @@ "Гэта пачатак гэтай размовы." "Паказаць менш" "Паведамленне скапіравана" - "У вас няма дазволу публікаваць паведамленні ў гэтым пакоі" + "У Вас няма дазволу на публікацыю ў гэтым пакоі" "Паказаць менш" "Паказаць больш" "Новы" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 2be20e88bc..254cd1002e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -33,19 +33,19 @@ class FakeMessagesNavigator : MessagesNavigator { var onEditPollClickedCount = 0 private set - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { onShowEventDebugInfoClickedCount++ } - override fun onForwardEventClicked(eventId: EventId) { + override fun onForwardEventClick(eventId: EventId) { onForwardEventClickedCount++ } - override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + override fun onReportContentClick(eventId: EventId, senderId: UserId) { onReportContentClickedCount++ } - override fun onEditPollClicked(eventId: EventId) { + override fun onEditPollClick(eventId: EventId) { onEditPollClickedCount++ } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 14eb3616ad..1732087814 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -27,7 +27,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.fixtures.aTimelineItemsFactory -import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl +import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.impl.textcomposer.TestRichTextEditorStateFactory @@ -793,7 +793,7 @@ class MessagesPresenterTest { mediaSender = mediaSender, snackbarDispatcher = SnackbarDispatcher(), analyticsService = analyticsService, - messageComposerContext = MessageComposerContextImpl(), + messageComposerContext = DefaultMessageComposerContext(), richTextEditorStateFactory = TestRichTextEditorStateFactory(), permissionsPresenterFactory = permissionsPresenterFactory, currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index e176e20805..dd64f0b967 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -88,7 +88,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -103,7 +103,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onRoomDetailsClicked = callback, + onRoomDetailsClick = callback, ) rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick() } @@ -118,7 +118,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onJoinCallClicked = callback, + onJoinCallClick = callback, ) val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call) rule.onNodeWithContentDescription(joinCallContentDescription).performClick() @@ -138,7 +138,7 @@ class MessagesViewTest { ) rule.setMessagesView( state = state, - onEventClicked = callback, + onEventClick = callback, ) // Cannot perform click on "Text", it's not detected. Use tag instead rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() @@ -287,7 +287,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onSendLocationClicked = callback, + onSendLocationClick = callback, ) rule.clickOn(R.string.screen_room_attachment_source_location) } @@ -305,7 +305,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onCreatePollClicked = callback, + onCreatePollClick = callback, ) // Then click on the poll action rule.clickOn(R.string.screen_room_attachment_source_poll) @@ -324,7 +324,7 @@ class MessagesViewTest { ) { callback -> rule.setMessagesView( state = state, - onUserDataClicked = callback, + onUserDataClick = callback, ) rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick() } @@ -474,30 +474,30 @@ class MessagesViewTest { private fun AndroidComposeTestRule.setMessagesView( state: MessagesState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), - onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), - onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), + onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(), - onSendLocationClicked: () -> Unit = EnsureNeverCalled(), - onCreatePollClicked: () -> Unit = EnsureNeverCalled(), - onJoinCallClicked: () -> Unit = EnsureNeverCalled(), + onSendLocationClick: () -> Unit = EnsureNeverCalled(), + onCreatePollClick: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), ) { setContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode CompositionLocalProvider(LocalInspectionMode provides true) { MessagesView( state = state, - onBackPressed = onBackPressed, - onRoomDetailsClicked = onRoomDetailsClicked, - onEventClicked = onEventClicked, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, + onBackClick = onBackClick, + onRoomDetailsClick = onRoomDetailsClick, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, onPreviewAttachments = onPreviewAttachments, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, - onJoinCallClicked = onJoinCallClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + onJoinCallClick = onJoinCallClick, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt similarity index 83% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt index 7715230511..e30011999f 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesPresenterTest.kt @@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -28,15 +29,13 @@ import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider import io.element.android.tests.testutils.WarmUpRule -import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -import java.lang.IllegalStateException -class ForwardMessagesPresenterTests { +class ForwardMessagesPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -47,9 +46,7 @@ class ForwardMessagesPresenterTests { presenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.isForwarding).isFalse() - assertThat(initialState.error).isNull() - assertThat(initialState.forwardingSucceeded).isNull() + assertThat(initialState.forwardAction.isUninitialized()).isTrue() } } @@ -70,11 +67,10 @@ class ForwardMessagesPresenterTests { val summary = aRoomSummaryDetails() presenter.onRoomSelected(listOf(summary.roomId)) val forwardingState = awaitItem() - assertThat(forwardingState.isForwarding).isTrue() + assertThat(forwardingState.forwardAction.isLoading()).isTrue() val successfulForwardState = awaitItem() - assertThat(successfulForwardState.isForwarding).isFalse() - assertThat(successfulForwardState.forwardingSucceeded).isNotNull() - assert(forwardEventLambda).isCalledOnce() + assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId))) + forwardEventLambda.assertions().isCalledOnce() } } @@ -96,11 +92,11 @@ class ForwardMessagesPresenterTests { presenter.onRoomSelected(listOf(summary.roomId)) skipItems(1) val failedForwardState = awaitItem() - assertThat(failedForwardState.error).isNotNull() + assertThat(failedForwardState.forwardAction.isFailure()).isTrue() // Then clear error failedForwardState.eventSink(ForwardMessagesEvents.ClearError) - assertThat(awaitItem().error).isNull() - assert(forwardEventLambda).isCalledOnce() + assertThat(awaitItem().forwardAction.isUninitialized()).isTrue() + forwardEventLambda.assertions().isCalledOnce() } } @@ -111,6 +107,6 @@ class ForwardMessagesPresenterTests { ) = ForwardMessagesPresenter( eventId = eventId.value, timelineProvider = LiveTimelineProvider(fakeMatrixRoom), - matrixCoroutineScope = coroutineScope, + appCoroutineScope = coroutineScope, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesViewTest.kt new file mode 100644 index 0000000000..a510cb0a99 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesViewTest.kt @@ -0,0 +1,80 @@ +/* + * 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.messages.impl.forward + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.testtags.TestTags +import io.element.android.tests.testutils.EnsureNeverCalledWithParam +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.pressTag +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ForwardMessagesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `cancel error emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setForwardMessagesView( + aForwardMessagesState( + forwardAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventsRecorder + ), + ) + rule.pressTag(TestTags.dialogPositive.value) + eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError) + } + + @Test + fun `success invokes onForwardSuccess`() { + val data = listOf(A_ROOM_ID) + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam?>(data) { callback -> + rule.setForwardMessagesView( + aForwardMessagesState( + forwardAction = AsyncAction.Success(data), + eventSink = eventsRecorder + ), + onForwardSuccess = callback, + ) + } + } +} + +private fun AndroidComposeTestRule.setForwardMessagesView( + state: ForwardMessagesState, + onForwardSuccess: (List) -> Unit = EnsureNeverCalledWithParam(), +) { + setContent { + ForwardMessagesView( + state = state, + onForwardSuccess = onForwardSuccess, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt similarity index 91% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt index 5f289faec9..b5408434de 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/report/ReportMessagePresenterTest.kt @@ -31,13 +31,13 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class ReportMessagePresenterTests { +class ReportMessagePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @Test fun `presenter - initial state`() = runTest { - val presenter = aPresenter() + val presenter = createReportMessagePresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -50,7 +50,7 @@ class ReportMessagePresenterTests { @Test fun `presenter - update reason`() = runTest { - val presenter = aPresenter() + val presenter = createReportMessagePresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -64,7 +64,7 @@ class ReportMessagePresenterTests { @Test fun `presenter - toggle block user`() = runTest { - val presenter = aPresenter() + val presenter = createReportMessagePresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -82,7 +82,7 @@ class ReportMessagePresenterTests { @Test fun `presenter - handle successful report and block user`() = runTest { val room = FakeMatrixRoom() - val presenter = aPresenter(matrixRoom = room) + val presenter = createReportMessagePresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -99,7 +99,7 @@ class ReportMessagePresenterTests { @Test fun `presenter - handle successful report`() = runTest { val room = FakeMatrixRoom() - val presenter = aPresenter(matrixRoom = room) + val presenter = createReportMessagePresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -116,7 +116,7 @@ class ReportMessagePresenterTests { val room = FakeMatrixRoom().apply { givenReportContentResult(Result.failure(Exception("Failed to report content"))) } - val presenter = aPresenter(matrixRoom = room) + val presenter = createReportMessagePresenter(matrixRoom = room) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -132,7 +132,7 @@ class ReportMessagePresenterTests { } } - private fun aPresenter( + private fun createReportMessagePresenter( inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID), matrixRoom: MatrixRoom = FakeMatrixRoom(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index f0bf4c42ef..5315cd54e2 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -26,8 +26,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer +import im.vector.app.features.analytics.plan.Interaction import io.element.android.features.messages.impl.messagecomposer.AttachmentsState -import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl +import io.element.android.features.messages.impl.messagecomposer.DefaultMessageComposerContext import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -394,7 +395,7 @@ class MessageComposerPresenterTest { @Test fun `present - reply message`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List -> + val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> Result.success(Unit) } val timeline = FakeTimeline().apply { @@ -425,7 +426,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .isCalledOnce() - .with(any(), value(A_REPLY), value(A_REPLY), any()) + .with(any(), value(A_REPLY), value(A_REPLY), any(), value(false)) assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -768,10 +769,15 @@ class MessageComposerPresenterTest { val showTextFormatting = awaitItem() assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() assertThat(showTextFormatting.showTextFormatting).isTrue() + assertThat(analyticsService.capturedEvents).containsExactly( + Interaction(index = null, interactionType = null, name = Interaction.Name.MobileRoomComposerFormattingEnabled) + ) + analyticsService.capturedEvents.clear() showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false)) skipItems(1) val finished = awaitItem() assertThat(finished.showTextFormatting).isFalse() + assertThat(analyticsService.capturedEvents).isEmpty() } } @@ -903,7 +909,7 @@ class MessageComposerPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - send messages with intentional mentions`() = runTest { - val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List -> + val replyMessageLambda = lambdaRecorder { _: EventId, _: String, _: String?, _: List, _: Boolean -> Result.success(Unit) } val editMessageLambda = lambdaRecorder { _: EventId?, _: TransactionId?, _: String, _: String?, _: List -> @@ -950,7 +956,7 @@ class MessageComposerPresenterTest { assert(replyMessageLambda) .isCalledOnce() - .with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2)))) + .with(any(), any(), any(), value(listOf(Mention.User(A_USER_ID_2))), value(false)) // Check intentional mentions on edit message skipItems(1) @@ -1049,7 +1055,7 @@ class MessageComposerPresenterTest { MediaSender(mediaPreProcessor, room), snackbarDispatcher, analyticsService, - MessageComposerContextImpl(), + DefaultMessageComposerContext(), TestRichTextEditorStateFactory(), currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)), permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt index 5bd1145aa3..4b6d28bd89 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineControllerTest.kt @@ -172,8 +172,11 @@ class TimelineControllerTest { ) matrixRoom.givenTimelineFocusedOnEventResult(Result.success(detachedTimeline)) val sut = TimelineController(matrixRoom) - sut.focusOnEvent(AN_EVENT_ID) sut.activeTimelineFlow().test { + awaitItem().also { state -> + assertThat(state).isEqualTo(liveTimeline) + } + sut.focusOnEvent(AN_EVENT_ID) awaitItem().also { state -> assertThat(state).isEqualTo(detachedTimeline) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index bfca79d714..265e3ef636 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -409,7 +409,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" presenter.present() }.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvents.PollAnswerSelected(AN_EVENT_ID, "anAnswerId")) + initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) } delay(1) sendPollResponseAction.verifyExecutionCount(1) @@ -425,7 +425,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" presenter.present() }.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvents.PollEndClicked(AN_EVENT_ID)) + initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID)) } delay(1) endPollAction.verifyExecutionCount(1) @@ -440,7 +440,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().eventSink(TimelineEvents.PollEditClicked(AN_EVENT_ID)) + awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID)) assertThat(navigator.onEditPollClickedCount).isEqualTo(1) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 29d46d2da2..f5d78860b3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -101,15 +101,15 @@ class TimelineViewTest { private fun AndroidComposeTestRule.setTimelineView( state: TimelineState, typingNotificationState: TypingNotificationState = aTypingNotificationState(), - onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(), - onMessageClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onMessageLongClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onTimestampClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), + onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onTimestampClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), - onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), - onMoreReactionsClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), forceJumpToBottomVisibility: Boolean = false, ) { @@ -117,15 +117,15 @@ private fun AndroidComposeTestRule.setTimel TimelineView( state = state, typingNotificationState = typingNotificationState, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onMessageClicked = onMessageClicked, - onMessageLongClicked = onMessageLongClicked, - onTimestampClicked = onTimestampClicked, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onTimestampClick = onTimestampClick, onSwipeToReply = onSwipeToReply, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt similarity index 98% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt index f7c26e02f7..32c17f0b27 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class CustomReactionPresenterTests { +class CustomReactionPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt index 4ea16a26b0..4c791da406 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -57,7 +57,7 @@ class TimelineItemPollViewTest { } val answer = content.answerItems[answerIndex].answer rule.onNode(hasText(answer.text)).performClick() - eventsRecorder.assertSingle(TimelineEvents.PollAnswerSelected(content.eventId!!, answer.id)) + eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id)) } @Test @@ -74,7 +74,7 @@ class TimelineItemPollViewTest { ) } rule.clickOn(CommonStrings.action_edit_poll) - eventsRecorder.assertSingle(TimelineEvents.PollEditClicked(content.eventId!!)) + eventsRecorder.assertSingle(TimelineEvents.EditPoll(content.eventId!!)) } @Test @@ -93,6 +93,6 @@ class TimelineItemPollViewTest { // A confirmation dialog should be shown eventsRecorder.assertEmpty() rule.pressTag(TestTags.dialogPositive.value) - eventsRecorder.assertSingle(TimelineEvents.PollEndClicked(content.eventId!!)) + eventsRecorder.assertSingle(TimelineEvents.EndPoll(content.eventId!!)) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt similarity index 98% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt index 0159af02ec..5924e4b2eb 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/reactionsummary/ReactionSummaryPresenterTest.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class ReactionSummaryPresenterTests { +class ReactionSummaryPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt similarity index 98% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt index 5f65a9ad0a..6efb36e9c4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheetPresenterTest.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class ReadReceiptBottomSheetPresenterTests { +class ReadReceiptBottomSheetPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTest.kt similarity index 99% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTest.kt index 21bc5b53ac..7881cba6aa 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTests.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/retrysendmenu/RetrySendMenuPresenterTest.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class RetrySendMenuPresenterTests { +class RetrySendMenuPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 6d8fb1ad9a..ec5571df68 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation @@ -706,9 +707,10 @@ class TimelineItemContentMessageFactoryTest { return StickerContent( body = body, info = inImageInfo, - url = inUrl + source = aMediaSource(url = inUrl), ) } + private fun createTimelineItemContentStickerFactory() = TimelineItemContentStickerFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation() diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt index c155325444..134d9ffde3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt @@ -115,7 +115,7 @@ class InReplyToMetadataKtTest { eventContent = StickerContent( body = "body", info = anImageInfo(), - url = "url" + source = aMediaSource(url = "url") ) ).metadata() }.test { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt index a37f2e775f..6e12756df7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.mxc.MxcTools -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -34,12 +34,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - downloads and returns cached file successfully`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -53,12 +53,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -71,7 +71,7 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download succeeds but file move fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } File(temporaryFolder.cachedFilePath).apply { @@ -83,7 +83,7 @@ class DefaultVoiceMessageMediaRepoTest { } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -100,12 +100,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache hit - returns cached file successfully`() = runTest { temporaryFolder.createCachedFile() - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true // so that if we hit the media loader it will crash } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -135,7 +135,7 @@ class DefaultVoiceMessageMediaRepoTest { private fun createDefaultVoiceMessageMediaRepo( temporaryFolder: TemporaryFolder, - matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(), + matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), mxcUri: String = MXC_URI, ) = DefaultVoiceMessageMediaRepo( cacheDir = temporaryFolder.root, diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index 2c8ffa58b3..392a8f3889 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.sessionStorage.implMemory) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt new file mode 100644 index 0000000000..79947b683e --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt @@ -0,0 +1,40 @@ +/* + * 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.migration.impl.migrations + +import android.content.Context +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +/** + * Remove notifications.bin file, used to store notification data locally. + */ +@ContributesMultibinding(AppScope::class) +class AppMigration04 @Inject constructor( + @ApplicationContext private val context: Context, +) : AppMigration { + companion object { + internal const val NOTIFICATION_FILE_NAME = "notifications.bin" + } + override val order: Int = 4 + + override suspend fun migrate() { + runCatching { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt index 3ea0625f76..29be8682e3 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt @@ -37,7 +37,7 @@ class MigrationPresenterTest { @Test fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest { - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val store = InMemoryMigrationStore(migrations.maxOf { it.order }) val presenter = createPresenter( migrationStore = store, @@ -57,7 +57,7 @@ class MigrationPresenterTest { @Test fun `present - testing all migrations`() = runTest { val store = InMemoryMigrationStore(0) - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val presenter = createPresenter( migrationStore = store, migrations = migrations.toSet(), @@ -81,13 +81,13 @@ class MigrationPresenterTest { private fun createPresenter( migrationStore: MigrationStore = InMemoryMigrationStore(0), - migrations: Set = setOf(FakeMigration(1)), + migrations: Set = setOf(FakeAppMigration(1)), ) = MigrationPresenter( migrationStore = migrationStore, migrations = migrations, ) -private class FakeMigration( +private class FakeAppMigration( override val order: Int, var migrateLambda: LambdaNoParamRecorder = lambdaRecorder { -> }, ) : AppMigration { diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt index 1a077fda2e..3df93806d5 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt @@ -17,7 +17,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.preferences.test.FakeSessionPreferenceStoreFactory +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData @@ -33,8 +33,9 @@ class AppMigration02Test { updateData(aSessionData()) } val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false) - val sessionPreferencesStoreFactory = FakeSessionPreferenceStoreFactory( + val sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory( getLambda = lambdaRecorder { _, _, -> sessionPreferencesStore }, + removeLambda = lambdaRecorder { _ -> } ) val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory) diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt new file mode 100644 index 0000000000..5549a8d8a2 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt @@ -0,0 +1,45 @@ +/* + * 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.migration.impl.migrations + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AppMigration04Test { + @Test + fun `test migration`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + + // Create fake temporary file at the path to be deleted + val file = context.getDatabasePath(AppMigration04.NOTIFICATION_FILE_NAME) + file.parentFile?.mkdirs() + file.createNewFile() + assertThat(file.exists()).isTrue() + + val migration = AppMigration04(context) + + migration.migrate() + + // Check that the file has been deleted + assertThat(file.exists()).isFalse() + } +} diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt similarity index 98% rename from features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt rename to features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt index ddc75669fd..d866c43ec0 100644 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/NetworkMonitorImpl.kt +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt @@ -46,7 +46,7 @@ import javax.inject.Inject @ContributesBinding(scope = AppScope::class) @SingleIn(AppScope::class) -class NetworkMonitorImpl @Inject constructor( +class DefaultNetworkMonitor @Inject constructor( @ApplicationContext context: Context, appCoroutineScope: CoroutineScope, ) : NetworkMonitor { diff --git a/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt index a70f14dc43..ddb3643dc1 100644 --- a/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt +++ b/features/onboarding/api/src/main/kotlin/io/element/android/features/onboarding/api/OnBoardingEntryPoint.kt @@ -32,6 +32,7 @@ interface OnBoardingEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onSignUp() fun onSignIn() + fun onSignInWithQrCode() fun onOpenDeveloperSettings() fun onReportProblem() } diff --git a/features/onboarding/impl/build.gradle.kts b/features/onboarding/impl/build.gradle.kts index ca8aaa7862..d5453ba041 100644 --- a/features/onboarding/impl/build.gradle.kts +++ b/features/onboarding/impl/build.gradle.kts @@ -23,6 +23,12 @@ plugins { android { namespace = "io.element.android.features.onboarding.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } anvil { @@ -33,21 +39,28 @@ dependencies { implementation(projects.schildi.lib) implementation(projects.anvilannotations) anvil(projects.anvilcodegen) + implementation(projects.appconfig) implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) - implementation(projects.libraries.matrix.api) implementation(projects.libraries.designsystem) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) - implementation(projects.libraries.androidutils) api(projects.features.onboarding.api) ksp(libs.showkase.processor) testImplementation(libs.test.junit) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.featureflag.test) testImplementation(projects.tests.testutils) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt index 17ed372b72..39b5d13a6e 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingNode.kt @@ -45,6 +45,10 @@ class OnBoardingNode @AssistedInject constructor( plugins().forEach { it.onSignUp() } } + private fun onSignInWithQrCode() { + plugins().forEach { it.onSignInWithQrCode() } + } + private fun onOpenDeveloperSettings() { plugins().forEach { it.onOpenDeveloperSettings() } } @@ -61,7 +65,7 @@ class OnBoardingNode @AssistedInject constructor( modifier = modifier, onSignIn = ::onSignIn, onCreateAccount = ::onSignUp, - onSignInWithQrCode = { /* Not supported yet */ }, + onSignInWithQrCode = ::onSignInWithQrCode, onOpenDeveloperSettings = ::onOpenDeveloperSettings, onReportProblem = ::onReportProblem, ) diff --git a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt index bfe6c06e00..ed7200310f 100644 --- a/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt +++ b/features/onboarding/impl/src/main/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenter.kt @@ -17,9 +17,14 @@ package io.element.android.features.onboarding.impl import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import io.element.android.appconfig.OnBoardingConfig import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import javax.inject.Inject /** @@ -28,13 +33,17 @@ import javax.inject.Inject */ class OnBoardingPresenter @Inject constructor( private val buildMeta: BuildMeta, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): OnBoardingState { - return OnBoardingState( + val canLoginWithQrCode by produceState(initialValue = false) { + value = featureFlagService.isFeatureEnabled(FeatureFlags.QrCodeLogin) + } + return OnBoardingState( isDebugBuild = buildMeta.buildType != BuildType.RELEASE, productionApplicationName = buildMeta.productionApplicationName, - canLoginWithQrCode = OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE, + canLoginWithQrCode = canLoginWithQrCode, canCreateAccount = OnBoardingConfig.CAN_CREATE_ACCOUNT, ) } diff --git a/features/onboarding/impl/src/main/res/values-be/translations.xml b/features/onboarding/impl/src/main/res/values-be/translations.xml index 1ca6c172bb..ab1c5aba87 100644 --- a/features/onboarding/impl/src/main/res/values-be/translations.xml +++ b/features/onboarding/impl/src/main/res/values-be/translations.xml @@ -1,9 +1,9 @@ - "Увайдзіце ўручную" - "Увайдзіце з QR-кодам" + "Увайсці ўручную" + "Увайсці з QR-кодам" "Стварыць уліковы запіс" "Сардэчна запрашаем у самы хуткі %1$s. Перавага ў хуткасці і прастаце." "Сардэчна запрашаем у %1$s. Зараджаны, для хуткасці і прастаты." - " Адчуйце сябе ў сваім element" + "Будзьце ў сваім element" diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt index 1eaf60a8f9..651d8c5cf4 100644 --- a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt +++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnBoardingPresenterTest.kt @@ -21,6 +21,8 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -34,31 +36,38 @@ class OnBoardingPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = OnBoardingPresenter( - aBuildMeta( + buildMeta = aBuildMeta( applicationName = "A", productionApplicationName = "B", desktopApplicationName = "C", - ) + ), + featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.QrCodeLogin.name to true)), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.isDebugBuild).isTrue() - assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.canLoginWithQrCode).isFalse() + assertThat(initialState.productionApplicationName).isEqualTo("B") assertThat(initialState.canCreateAccount).isFalse() + + assertThat(awaitItem().canLoginWithQrCode).isTrue() } } @Test fun `present - initial state release`() = runTest { - val presenter = OnBoardingPresenter(aBuildMeta(buildType = BuildType.RELEASE)) + val presenter = OnBoardingPresenter( + buildMeta = aBuildMeta(buildType = BuildType.RELEASE), + featureFlagService = FakeFeatureFlagService(), + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(initialState.isDebugBuild).isFalse() + cancelAndIgnoreRemainingEvents() } } } diff --git a/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt new file mode 100644 index 0000000000..9a83573807 --- /dev/null +++ b/features/onboarding/impl/src/test/kotlin/io/element/android/features/onboarding/impl/OnboardingViewTest.kt @@ -0,0 +1,127 @@ +/* + * 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.onboarding.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class OnboardingViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `when can create account - clicking on create account calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState(canCreateAccount = true), + onCreateAccount = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_up) + } + } + + @Test + fun `when can login with QR code - clicking on sign in with QR code calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState(canLoginWithQrCode = true), + onSignInWithQrCode = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_in_with_qr_code) + } + } + + @Test + fun `when can login with QR code - clicking on sign in manually calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState(canLoginWithQrCode = true), + onSignIn = callback, + ) + rule.clickOn(R.string.screen_onboarding_sign_in_manually) + } + } + + @Test + fun `when cannot login with QR code or create account - clicking on continue calls the sign in callback`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState( + canLoginWithQrCode = false, + canCreateAccount = false, + ), + onSignIn = callback, + ) + rule.clickOn(CommonStrings.action_continue) + } + } + + @Test + fun `when on debug build - clicking on the settings icon opens the developer settings`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState(isDebugBuild = true), + onOpenDeveloperSettings = callback + ) + rule.onNode(hasContentDescription(rule.activity.getString(CommonStrings.common_settings))).performClick() + } + } + + @Test + fun `clicking on report a problem calls the sign in callback`() { + ensureCalledOnce { callback -> + rule.setOnboardingView( + state = anOnBoardingState(), + onReportProblem = callback, + ) + rule.clickOn(CommonStrings.common_report_a_problem) + } + } + + private fun AndroidComposeTestRule.setOnboardingView( + state: OnBoardingState, + onSignInWithQrCode: () -> Unit = EnsureNeverCalled(), + onSignIn: () -> Unit = EnsureNeverCalled(), + onCreateAccount: () -> Unit = EnsureNeverCalled(), + onOpenDeveloperSettings: () -> Unit = EnsureNeverCalled(), + onReportProblem: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + OnBoardingView( + state = state, + onSignInWithQrCode = onSignInWithQrCode, + onSignIn = onSignIn, + onCreateAccount = onCreateAccount, + onOpenDeveloperSettings = onOpenDeveloperSettings, + onReportProblem = onReportProblem, + ) + } + } +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index b8f77ce3af..e5c445bd4e 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -52,9 +52,9 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun PollContentView( state: PollContentState, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { PollContentView( @@ -65,9 +65,9 @@ fun PollContentView( isPollEditable = state.isPollEditable, isPollEnded = state.isPollEnded, isMine = state.isMine, - onPollEdit = onPollEdit, - onAnswerSelected = onAnswerSelected, - onPollEnd = onPollEnd, + onEditPoll = onEditPoll, + onSelectAnswer = onSelectAnswer, + onEndPoll = onEndPoll, modifier = modifier, ) } @@ -81,23 +81,23 @@ fun PollContentView( isPollEditable: Boolean, isPollEnded: Boolean, isMine: Boolean, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } - fun onAnswerSelected(pollAnswer: PollAnswer) { - eventId?.let { onAnswerSelected(it, pollAnswer.id) } + fun onSelectAnswer(pollAnswer: PollAnswer) { + eventId?.let { onSelectAnswer(it, pollAnswer.id) } } - fun onPollEdit() { - eventId?.let { onPollEdit(it) } + fun onEditPoll() { + eventId?.let { onEditPoll(it) } } - fun onPollEnd() { - eventId?.let { onPollEnd(it) } + fun onEndPoll() { + eventId?.let { onEndPoll(it) } } var showConfirmation: Boolean by remember { mutableStateOf(false) } @@ -105,8 +105,8 @@ fun PollContentView( if (showConfirmation) { ConfirmationDialog( content = stringResource(id = CommonStrings.common_poll_end_confirmation), - onSubmitClicked = { - onPollEnd() + onSubmitClick = { + onEndPoll() showConfirmation = false }, onDismiss = { showConfirmation = false }, @@ -119,7 +119,7 @@ fun PollContentView( ) { PollTitle(title = question, isPollEnded = isPollEnded) - PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected) + PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) if (isPollEnded || pollKind == PollKind.Disclosed) { DisclosedPollBottomNotice(votesCount = votesCount) @@ -131,8 +131,8 @@ fun PollContentView( CreatorView( isPollEnded = isPollEnded, isPollEditable = isPollEditable, - onPollEdit = ::onPollEdit, - onPollEnd = { showConfirmation = true }, + onEditPoll = ::onEditPoll, + onEndPoll = { showConfirmation = true }, modifier = Modifier.fillMaxWidth(), ) } @@ -170,7 +170,7 @@ private fun PollTitle( @Composable private fun PollAnswers( answerItems: ImmutableList, - onAnswerSelected: (PollAnswer) -> Unit, + onSelectAnswer: (PollAnswer) -> Unit, ) { Column( modifier = Modifier.selectableGroup(), @@ -183,7 +183,7 @@ private fun PollAnswers( .selectable( selected = it.isSelected, enabled = it.isEnabled, - onClick = { onAnswerSelected(it.answer) }, + onClick = { onSelectAnswer(it.answer) }, role = Role.RadioButton, ), ) @@ -219,21 +219,21 @@ private fun ColumnScope.UndisclosedPollBottomNotice() { private fun CreatorView( isPollEnded: Boolean, isPollEditable: Boolean, - onPollEdit: () -> Unit, - onPollEnd: () -> Unit, + onEditPoll: () -> Unit, + onEndPoll: () -> Unit, modifier: Modifier = Modifier ) { when { isPollEditable -> Button( text = stringResource(id = CommonStrings.action_edit_poll), - onClick = onPollEdit, + onClick = onEditPoll, modifier = modifier, ) !isPollEnded -> Button( text = stringResource(id = CommonStrings.action_end_poll), - onClick = onPollEnd, + onClick = onEndPoll, modifier = modifier, ) } @@ -250,9 +250,9 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -267,9 +267,9 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -284,9 +284,9 @@ internal fun PollContentViewEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -301,9 +301,9 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview { isPollEnded = false, isPollEditable = true, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -318,9 +318,9 @@ internal fun PollContentViewCreatorPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -335,8 +335,8 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index 65b1d5b38e..81c9b7220e 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -79,7 +79,7 @@ fun CreatePollView( if (state.showBackConfirmation) { ConfirmationDialog( content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android), - onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) }, + onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) }, onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } ) } @@ -87,7 +87,7 @@ fun CreatePollView( ConfirmationDialog( title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title), content = stringResource(id = R.string.screen_edit_poll_delete_confirmation), - onSubmitClicked = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, + onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } ) } @@ -102,8 +102,8 @@ fun CreatePollView( CreatePollTopAppBar( mode = state.mode, saveEnabled = state.canSave, - onBackPress = navBack, - onSaveClicked = { state.eventSink(CreatePollEvents.Save) } + onBackClick = navBack, + onSaveClick = { state.eventSink(CreatePollEvents.Save) } ) }, ) { paddingValues -> @@ -219,8 +219,8 @@ fun CreatePollView( private fun CreatePollTopAppBar( mode: CreatePollState.Mode, saveEnabled: Boolean, - onBackPress: () -> Unit = {}, - onSaveClicked: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onSaveClick: () -> Unit = {}, ) { TopAppBar( title = { @@ -233,7 +233,7 @@ private fun CreatePollTopAppBar( ) }, navigationIcon = { - BackButton(onClick = onBackPress) + BackButton(onClick = onBackClick) }, actions = { TextButton( @@ -241,7 +241,7 @@ private fun CreatePollTopAppBar( CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create) CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done) }, - onClick = onSaveClicked, + onClick = onSaveClick, enabled = saveEnabled, ) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt index edc848da41..c2f3f98d11 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface PollHistoryEvents { data object LoadMore : PollHistoryEvents - data class PollAnswerSelected(val pollStartId: EventId, val answerId: String) : PollHistoryEvents - data class PollEndClicked(val pollStartId: EventId) : PollHistoryEvents - data class OnFilterSelected(val filter: PollHistoryFilter) : PollHistoryEvents + data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvents + data class EndPoll(val pollStartId: EventId) : PollHistoryEvents + data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 981fe00c32..b1579d6cdb 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -73,13 +73,13 @@ class PollHistoryPresenter @Inject constructor( is PollHistoryEvents.LoadMore -> { coroutineScope.loadMore(timeline) } - is PollHistoryEvents.PollAnswerSelected -> appCoroutineScope.launch { + is PollHistoryEvents.SelectPollAnswer -> appCoroutineScope.launch { sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId) } - is PollHistoryEvents.PollEndClicked -> appCoroutineScope.launch { + is PollHistoryEvents.EndPoll -> appCoroutineScope.launch { endPollAction.execute(pollStartId = event.pollStartId) } - is PollHistoryEvents.OnFilterSelected -> { + is PollHistoryEvents.SelectFilter -> { activeFilter = event.filter } } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 466ddc54f9..d9bb9e5e10 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -74,12 +74,12 @@ fun PollHistoryView( state.eventSink(PollHistoryEvents.LoadMore) } - fun onAnswerSelected(pollStartId: EventId, answerId: String) { - state.eventSink(PollHistoryEvents.PollAnswerSelected(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId)) } - fun onPollEnd(pollStartId: EventId) { - state.eventSink(PollHistoryEvents.PollEndClicked(pollStartId)) + fun onEndPoll(pollStartId: EventId) { + state.eventSink(PollHistoryEvents.EndPoll(pollStartId)) } Scaffold( @@ -111,7 +111,7 @@ fun PollHistoryView( } PollHistoryFilterButtons( activeFilter = state.activeFilter, - onFilterSelected = { state.eventSink(PollHistoryEvents.OnFilterSelected(it)) }, + onSelectFilter = { state.eventSink(PollHistoryEvents.SelectFilter(it)) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), @@ -128,9 +128,9 @@ fun PollHistoryView( pollHistoryItems = pollHistoryItems, hasMoreToLoad = state.hasMoreToLoad, isLoading = state.isLoading, - onAnswerSelected = ::onAnswerSelected, - onPollEdit = onEditPoll, - onPollEnd = ::onPollEnd, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = ::onEndPoll, onLoadMore = ::onLoadMore, modifier = Modifier.fillMaxSize(), ) @@ -143,7 +143,7 @@ fun PollHistoryView( @Composable private fun PollHistoryFilterButtons( activeFilter: PollHistoryFilter, - onFilterSelected: (PollHistoryFilter) -> Unit, + onSelectFilter: (PollHistoryFilter) -> Unit, modifier: Modifier = Modifier, ) { SingleChoiceSegmentedButtonRow(modifier = modifier) { @@ -152,7 +152,7 @@ private fun PollHistoryFilterButtons( index = filter.ordinal, count = PollHistoryFilter.entries.size, selected = activeFilter == filter, - onClick = { onFilterSelected(filter) }, + onClick = { onSelectFilter(filter) }, text = stringResource(filter.stringResource), ) } @@ -165,9 +165,9 @@ private fun PollHistoryList( pollHistoryItems: ImmutableList, hasMoreToLoad: Boolean, isLoading: Boolean, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, onLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -180,9 +180,9 @@ private fun PollHistoryList( items(pollHistoryItems) { pollHistoryItem -> PollHistoryItemRow( pollHistoryItem = pollHistoryItem, - onAnswerSelected = onAnswerSelected, - onPollEdit = onPollEdit, - onPollEnd = onPollEnd, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) ) } @@ -232,9 +232,9 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { @Composable private fun PollHistoryItemRow( pollHistoryItem: PollHistoryItem, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { Surface( @@ -251,9 +251,9 @@ private fun PollHistoryItemRow( Spacer(modifier = Modifier.height(4.dp)) PollContentView( state = pollHistoryItem.state, - onAnswerSelected = onAnswerSelected, - onPollEdit = onPollEdit, - onPollEnd = onPollEnd, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, ) } } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index 638f9e6ff0..652aafef4f 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -99,12 +99,12 @@ class PollHistoryPresenterTest { }.test { awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) - state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.PAST)) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.PAST)) } skipItems(1) awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.PAST) - state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.ONGOING)) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.ONGOING)) } awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) @@ -125,10 +125,10 @@ class PollHistoryPresenterTest { presenter.present() }.test { val state = awaitItem() - state.eventSink(PollHistoryEvents.PollEndClicked(AN_EVENT_ID)) + state.eventSink(PollHistoryEvents.EndPoll(AN_EVENT_ID)) runCurrent() endPollAction.verifyExecutionCount(1) - state.eventSink(PollHistoryEvents.PollAnswerSelected(AN_EVENT_ID, "answer")) + state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer")) runCurrent() sendPollResponseAction.verifyExecutionCount(1) cancelAndConsumeRemainingEvents() diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt index 0ef2e42b7d..946796ab61 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -114,7 +114,7 @@ class PollHistoryViewTest { eventsRecorder.assertEmpty() rule.clickOn(CommonStrings.action_ok) eventsRecorder.assertSingle( - PollHistoryEvents.PollEndClicked(eventId) + PollHistoryEvents.EndPoll(eventId) ) } @@ -142,7 +142,7 @@ class PollHistoryViewTest { ) rule.onNodeWithText(answer.text).performClick() eventsRecorder.assertSingle( - PollHistoryEvents.PollAnswerSelected(eventId, answer.id) + PollHistoryEvents.SelectPollAnswer(eventId, answer.id) ) } @@ -156,7 +156,7 @@ class PollHistoryViewTest { ) rule.clickOn(R.string.screen_polls_history_filter_past) eventsRecorder.assertSingle( - PollHistoryEvents.OnFilterSelected(filter = PollHistoryFilter.PAST) + PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST) ) } diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index e488d911ed..0453fa0ce2 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -45,7 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenBugReport() - fun onSecureBackupClicked() + fun onSecureBackupClick() fun onOpenRoomNotificationSettings(roomId: RoomId) } } diff --git a/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsNode.kt b/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsNode.kt index 1d5ba68059..2795fee0a4 100644 --- a/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsNode.kt @@ -65,7 +65,7 @@ class ScTweaksSettingsNode @AssistedInject constructor( ScTweaksSettingsView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, onOpenPrefScreen = this::onOpenScTweaks, handleScPrefAction = this::handleScPrefAction, ) diff --git a/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsView.kt b/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsView.kt index 60fb172eca..6928ae225c 100644 --- a/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/chat/schildi/preferences/tweaks/ScTweaksSettingsView.kt @@ -47,7 +47,7 @@ import timber.log.Timber @Composable fun ScTweaksSettingsView( state: ScTweaksSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, onOpenPrefScreen: (ScPrefScreen) -> Unit, handleScPrefAction: (String) -> Unit, modifier: Modifier = Modifier, @@ -56,7 +56,7 @@ fun ScTweaksSettingsView( PushInfoDialog(state.pushInfo, showPushInfoDialog) PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = state.titleRes) ) { RecursiveScPrefsView( @@ -139,5 +139,5 @@ private fun PushInfoDialog(pushInfo: String, show: MutableState) { @Composable internal fun ScTweaksSettingsViewPreview(@PreviewParameter(ScTweaksSettingsStateProvider::class) state: ScTweaksSettingsState) = ElementPreview { - ScTweaksSettingsView(state = state, onBackPressed = {}, onOpenPrefScreen = {}, handleScPrefAction = {}) + ScTweaksSettingsView(state = state, onBackClick = {}, onOpenPrefScreen = {}, handleScPrefAction = {}) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 898a86cf73..1a4daef339 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -120,8 +120,8 @@ class PreferencesFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenBugReport() } } - override fun onSecureBackupClicked() { - plugins().forEach { it.onSecureBackupClicked() } + override fun onSecureBackupClick() { + plugins().forEach { it.onSecureBackupClick() } } override fun onOpenAnalytics() { @@ -160,7 +160,7 @@ class PreferencesFlowNode @AssistedInject constructor( backstack.push(NavTarget.BlockedUsers) } - override fun onSignOutClicked() { + override fun onSignOutClick() { backstack.push(NavTarget.SignOut) } } @@ -189,7 +189,7 @@ class PreferencesFlowNode @AssistedInject constructor( backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne)) } - override fun onTroubleshootNotificationsClicked() { + override fun onTroubleshootNotificationsClick() { backstack.push(NavTarget.TroubleshootNotifications) } } @@ -237,8 +237,8 @@ class PreferencesFlowNode @AssistedInject constructor( } NavTarget.SignOut -> { val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback { - override fun onChangeRecoveryKeyClicked() { - plugins().forEach { it.onSecureBackupClicked() } + override fun onChangeRecoveryKeyClick() { + plugins().forEach { it.onSecureBackupClick() } } } logoutEntryPoint.nodeBuilder(this, buildContext) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt index 5936b26b1a..bc2ff7894b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt @@ -36,7 +36,7 @@ class AboutNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: AboutPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onElementLegalClicked( + private fun onElementLegalClick( activity: Activity, darkTheme: Boolean, elementLegal: ElementLegal, @@ -51,9 +51,9 @@ class AboutNode @AssistedInject constructor( val state = presenter.present() AboutView( state = state, - onBackPressed = ::navigateUp, - onElementLegalClicked = { elementLegal -> - onElementLegalClicked(activity, isDark, elementLegal) + onBackClick = ::navigateUp, + onElementLegalClick = { elementLegal -> + onElementLegalClick(activity, isDark, elementLegal) }, modifier = modifier ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt index c581811b6b..4a55217275 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -29,19 +29,19 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AboutView( state: AboutState, - onElementLegalClicked: (ElementLegal) -> Unit, - onBackPressed: () -> Unit, + onElementLegalClick: (ElementLegal) -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_about) ) { state.elementLegals.forEach { elementLegal -> PreferenceText( title = stringResource(id = elementLegal.titleRes), - onClick = { onElementLegalClicked(elementLegal) } + onClick = { onElementLegalClick(elementLegal) } ) } } @@ -52,7 +52,7 @@ fun AboutView( internal fun AboutViewPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = ElementPreview { AboutView( state = state, - onElementLegalClicked = {}, - onBackPressed = {}, + onElementLegalClick = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 4ed4277599..ab4987f9d9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -24,7 +24,4 @@ sealed interface AdvancedSettingsEvents { data object ChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents - data object ChangePushProvider : AdvancedSettingsEvents - data object CancelChangePushProvider : AdvancedSettingsEvents - data class SetPushProvider(val index: Int) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt index 45d2a07a71..8989fba04a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt @@ -38,7 +38,7 @@ class AdvancedSettingsNode @AssistedInject constructor( AdvancedSettingsView( state = state, modifier = modifier, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 21d84567e1..d6fbfb4c24 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -17,10 +17,8 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -29,22 +27,13 @@ import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.pushproviders.api.Distributor -import io.element.android.libraries.pushproviders.api.PushProvider -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class AdvancedSettingsPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, - private val matrixClient: MatrixClient, - private val pushService: PushService, ) : Presenter { @Composable override fun present(): AdvancedSettingsState { @@ -61,61 +50,6 @@ class AdvancedSettingsPresenter @Inject constructor( .collectAsState(initial = Theme.System) var showChangeThemeDialog by remember { mutableStateOf(false) } - // List of PushProvider -> Distributor - val distributors = remember { - pushService.getAvailablePushProviders() - .flatMap { pushProvider -> - pushProvider.getDistributors().map { distributor -> - pushProvider to distributor - } - } - } - // List of Distributor names - val distributorNames = remember { - distributors.map { it.second.name } - } - - var currentDistributorName by remember { mutableStateOf>(AsyncAction.Uninitialized) } - var refreshPushProvider by remember { mutableIntStateOf(0) } - - LaunchedEffect(refreshPushProvider) { - val p = pushService.getCurrentPushProvider() - val name = p?.getCurrentDistributor(matrixClient)?.name - currentDistributorName = if (name != null) { - AsyncAction.Success(name) - } else { - AsyncAction.Failure(Exception("Failed to get current push provider")) - } - } - - var showChangePushProviderDialog by remember { mutableStateOf(false) } - - fun CoroutineScope.changePushProvider( - data: Pair? - ) = launch { - showChangePushProviderDialog = false - data ?: return@launch - // No op if the value is the same. - if (data.second.name == currentDistributorName.dataOrNull()) return@launch - currentDistributorName = AsyncAction.Loading - data.let { (pushProvider, distributor) -> - pushService.registerWith( - matrixClient = matrixClient, - pushProvider = pushProvider, - distributor = distributor - ) - .fold( - { - currentDistributorName = AsyncAction.Success(distributor.name) - refreshPushProvider++ - }, - { - currentDistributorName = AsyncAction.Failure(it) - } - ) - } - } - fun handleEvents(event: AdvancedSettingsEvents) { when (event) { is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { @@ -130,9 +64,6 @@ class AdvancedSettingsPresenter @Inject constructor( appPreferencesStore.setTheme(event.theme.name) showChangeThemeDialog = false } - AdvancedSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true - AdvancedSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false - is AdvancedSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) } } @@ -141,9 +72,6 @@ class AdvancedSettingsPresenter @Inject constructor( isSharePresenceEnabled = isSharePresenceEnabled, theme = theme, showChangeThemeDialog = showChangeThemeDialog, - currentPushDistributor = currentDistributorName, - availablePushDistributors = distributorNames.toImmutableList(), - showChangePushProviderDialog = showChangePushProviderDialog, eventSink = { handleEvents(it) } ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 23de2fda13..527515d867 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -17,16 +17,11 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme -import io.element.android.libraries.architecture.AsyncAction -import kotlinx.collections.immutable.ImmutableList data class AdvancedSettingsState( val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, val theme: Theme, val showChangeThemeDialog: Boolean, - val currentPushDistributor: AsyncAction, - val availablePushDistributors: ImmutableList, - val showChangePushProviderDialog: Boolean, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5e6af364f2..21ccec52e4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -18,8 +18,6 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.theme.Theme -import io.element.android.libraries.architecture.AsyncAction -import kotlinx.collections.immutable.toImmutableList open class AdvancedSettingsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -28,9 +26,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider = AsyncAction.Success("Firebase"), - availablePushDistributors: List = listOf("Firebase", "ntfy"), - showChangePushProviderDialog: Boolean = false, eventSink: (AdvancedSettingsEvents) -> Unit = {}, ) = AdvancedSettingsState( isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, - currentPushDistributor = currentPushDistributor, - availablePushDistributors = availablePushDistributors.toImmutableList(), - showChangePushProviderDialog = showChangePushProviderDialog, eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 9b82a87bbc..38311ee6ef 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -16,24 +16,19 @@ package io.element.android.features.preferences.impl.advanced -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.Theme import io.element.android.compound.theme.themes import io.element.android.features.preferences.impl.R -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.dialogs.ListOption import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings @@ -43,12 +38,12 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun AdvancedSettingsView( state: AdvancedSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_advanced_settings) ) { ListItem( @@ -86,41 +81,13 @@ fun AdvancedSettingsView( ), onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } ) - ListItem( - headlineContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) - }, - trailingContent = when (state.currentPushDistributor) { - AsyncAction.Uninitialized, - AsyncAction.Confirming, - AsyncAction.Loading -> ListItemContent.Custom { - CircularProgressIndicator( - modifier = Modifier - .progressSemantics() - .size(20.dp), - strokeWidth = 2.dp - ) - } - is AsyncAction.Failure -> ListItemContent.Text( - stringResource(id = CommonStrings.common_error) - ) - is AsyncAction.Success -> ListItemContent.Text( - state.currentPushDistributor.dataOrNull() ?: "" - ) - }, - onClick = { - if (state.currentPushDistributor.isReady()) { - state.eventSink(AdvancedSettingsEvents.ChangePushProvider) - } - } - ) } if (state.showChangeThemeDialog) { SingleSelectionDialog( options = getOptions(), initialSelection = themes.indexOf(state.theme), - onOptionSelected = { + onSelectOption = { state.eventSink( AdvancedSettingsEvents.SetTheme( themes[it] @@ -130,22 +97,6 @@ fun AdvancedSettingsView( onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangeTheme) }, ) } - - if (state.showChangePushProviderDialog) { - SingleSelectionDialog( - title = stringResource(id = R.string.screen_advanced_settings_choose_distributor_dialog_title_android), - options = state.availablePushDistributors.map { - ListOption(title = it) - }.toImmutableList(), - initialSelection = state.availablePushDistributors.indexOf(state.currentPushDistributor.dataOrNull()), - onOptionSelected = { index -> - state.eventSink( - AdvancedSettingsEvents.SetPushProvider(index) - ) - }, - onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) }, - ) - } } @Composable @@ -170,5 +121,5 @@ private fun Theme.toHumanReadable(): String { @Composable internal fun AdvancedSettingsViewPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = ElementPreview { - AdvancedSettingsView(state = state, onBackPressed = { }) + AdvancedSettingsView(state = state, onBackClick = { }) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt index 1ef0ec10e6..49d65819b9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt @@ -37,7 +37,7 @@ class AnalyticsSettingsNode @AssistedInject constructor( val state = presenter.present() AnalyticsSettingsView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt index c41249b1e2..67c12f31a1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt @@ -29,12 +29,12 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AnalyticsSettingsView( state: AnalyticsSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_analytics) ) { AnalyticsPreferencesView( @@ -48,6 +48,6 @@ fun AnalyticsSettingsView( internal fun AnalyticsSettingsViewPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = ElementPreview { AnalyticsSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt index e277ecc9b2..ba253bf549 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt @@ -37,7 +37,7 @@ class BlockedUsersNode @AssistedInject constructor( val state = presenter.present() BlockedUsersView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index 5424fd737d..5a8d9200e5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -21,20 +21,26 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class BlockedUsersPresenter @Inject constructor( private val matrixClient: MatrixClient, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): BlockedUsersState { @@ -47,7 +53,24 @@ class BlockedUsersPresenter @Inject constructor( mutableStateOf(AsyncAction.Uninitialized) } + val renderBlockedUsersDetail = featureFlagService + .isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails) + .collectAsState(initial = false) val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState() + val ignoredMatrixUser by produceState( + initialValue = ignoredUserIds.map { MatrixUser(userId = it) }, + key1 = renderBlockedUsersDetail.value, + key2 = ignoredUserIds + ) { + value = ignoredUserIds.map { + if (renderBlockedUsersDetail.value) { + matrixClient.getProfile(it).getOrNull() + } else { + null + } + ?: MatrixUser(userId = it) + } + } fun handleEvents(event: BlockedUsersEvents) { when (event) { @@ -68,7 +91,7 @@ class BlockedUsersPresenter @Inject constructor( } } return BlockedUsersState( - blockedUsers = ignoredUserIds, + blockedUsers = ignoredMatrixUser.toPersistentList(), unblockUserAction = unblockUserAction.value, eventSink = ::handleEvents ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt index 8b5209a0cd..42ba3d5a11 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt @@ -17,11 +17,11 @@ package io.element.android.features.preferences.impl.blockedusers import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class BlockedUsersState( - val blockedUsers: ImmutableList, + val blockedUsers: ImmutableList, val unblockUserAction: AsyncAction, val eventSink: (BlockedUsersEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt index 0b5466ed04..a09213a333 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt @@ -18,7 +18,7 @@ package io.element.android.features.preferences.impl.blockedusers import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import kotlinx.collections.immutable.toPersistentList @@ -26,10 +26,9 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider get() = sequenceOf( aBlockedUsersState(), + aBlockedUsersState(blockedUsers = aMatrixUserList().map { it.copy(displayName = null, avatarUrl = null) }), aBlockedUsersState(blockedUsers = emptyList()), aBlockedUsersState(unblockUserAction = AsyncAction.Confirming), - // Sadly there's no good way to preview Loading or Failure states since they're presented with an animation - // All these 3 screen states will be displayed as the Uninitialized one aBlockedUsersState(unblockUserAction = AsyncAction.Loading), aBlockedUsersState(unblockUserAction = AsyncAction.Failure(Throwable("Failed to unblock user"))), aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)), @@ -37,12 +36,13 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider = aMatrixUserList().map { it.userId }, + blockedUsers: List = aMatrixUserList(), unblockUserAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (BlockedUsersEvents) -> Unit = {}, ): BlockedUsersState { return BlockedUsersState( blockedUsers = blockedUsers.toPersistentList(), unblockUserAction = unblockUserAction, - eventSink = {}, + eventSink = eventSink, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt index 190950e645..680f22cc45 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -51,7 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun BlockedUsersView( state: BlockedUsersState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -65,7 +65,7 @@ fun BlockedUsersView( ) }, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) } ) } @@ -73,9 +73,9 @@ fun BlockedUsersView( LazyColumn( modifier = Modifier.padding(padding) ) { - items(state.blockedUsers) { userId -> + items(state.blockedUsers) { matrixUser -> BlockedUserItem( - userId = userId, + matrixUser = matrixUser, onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } ) } @@ -110,7 +110,7 @@ fun BlockedUsersView( title = stringResource(R.string.screen_blocked_users_unblock_alert_title), content = stringResource(R.string.screen_blocked_users_unblock_alert_description), submitText = stringResource(R.string.screen_blocked_users_unblock_alert_action), - onSubmitClicked = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) }, + onSubmitClick = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) }, onDismiss = { state.eventSink(BlockedUsersEvents.Cancel) } ) } @@ -121,12 +121,12 @@ fun BlockedUsersView( @Composable private fun BlockedUserItem( - userId: UserId, + matrixUser: MatrixUser, onClick: (UserId) -> Unit, ) { MatrixUserRow( - modifier = Modifier.clickable { onClick(userId) }, - matrixUser = MatrixUser(userId), + modifier = Modifier.clickable { onClick(matrixUser.userId) }, + matrixUser = matrixUser, ) } @@ -136,7 +136,7 @@ internal fun BlockedUsersViewPreview(@PreviewParameter(BlockedUsersStatePreviewP ElementPreview { BlockedUsersView( state = state, - onBackPressed = {} + onBackClick = {} ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt index 8cec8fa85f..412f89e14f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -59,7 +59,7 @@ class DeveloperSettingsNode @AssistedInject constructor( modifier = modifier, onOpenShowkase = ::openShowkase, onOpenConfigureTracing = ::onOpenConfigureTracing, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 5a40534b87..dd684fcaaa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -39,16 +39,19 @@ fun DeveloperSettingsView( state: DeveloperSettingsState, onOpenShowkase: () -> Unit, onOpenConfigureTracing: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. - PreferenceCategory(title = "Feature flags") { + PreferenceCategory( + title = "Feature flags", + showTopDivider = false, + ) { FeatureListContent(state) } ElementCallCategory(state = state) @@ -67,14 +70,14 @@ fun DeveloperSettingsView( RageshakePreferencesView( state = state.rageshakeState, ) - PreferenceCategory(title = "Crash", showDivider = false) { + PreferenceCategory(title = "Crash", showTopDivider = false) { PreferenceText( title = "Crash the app 💥", onClick = { error("This crash is a test.") } ) } val cache = state.cacheSize - PreferenceCategory(title = "Cache", showDivider = false) { + PreferenceCategory(title = "Cache", showTopDivider = false) { PreferenceText( title = "Clear cache", currentValue = cache.dataOrNull(), @@ -93,11 +96,12 @@ fun DeveloperSettingsView( private fun ElementCallCategory( state: DeveloperSettingsState, ) { - PreferenceCategory(title = "Element Call", showDivider = true) { + PreferenceCategory(title = "Element Call", showTopDivider = true) { val callUrlState = state.customElementCallBaseUrlState fun isUsingDefaultUrl(value: String?): Boolean { return value.isNullOrEmpty() || value == callUrlState.defaultUrl } + val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) { stringResource(R.string.screen_advanced_settings_element_call_base_url_description) } else { @@ -137,6 +141,6 @@ internal fun DeveloperSettingsViewPreview(@PreviewParameter(DeveloperSettingsSta state = state, onOpenShowkase = {}, onOpenConfigureTracing = {}, - onBackPressed = {} + onBackClick = {} ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt index b95f85dbeb..a624f38d08 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt @@ -37,7 +37,7 @@ class ConfigureTracingNode @AssistedInject constructor( val state = presenter.present() ConfigureTracingView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt index bf77762a64..c44b9e4ad5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt @@ -62,7 +62,7 @@ import kotlinx.collections.immutable.ImmutableMap @Composable fun ConfigureTracingView( state: ConfigureTracingState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { var showMenu by remember { mutableStateOf(false) } @@ -75,7 +75,7 @@ fun ConfigureTracingView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -234,6 +234,6 @@ internal fun ConfigureTracingViewPreview( ) = ElementPreview { ConfigureTracingView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TracingConfigurationStore.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TracingConfigurationStore.kt index 582231eb50..c77a3b2d3d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TracingConfigurationStore.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/TracingConfigurationStore.kt @@ -20,7 +20,6 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.Target import javax.inject.Inject @@ -32,8 +31,8 @@ interface TracingConfigurationStore { } @ContributesBinding(AppScope::class) -class SharedPrefTracingConfigurationStore @Inject constructor( - @DefaultPreferences private val sharedPreferences: SharedPreferences +class SharedPreferencesTracingConfigurationStore @Inject constructor( + private val sharedPreferences: SharedPreferences ) : TracingConfigurationStore { override fun getLogLevel(target: Target): LogLevel? { return sharedPreferences.getString("$KEY_PREFIX${target.name}", null) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt index f38c211b67..72896fd613 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -25,4 +25,7 @@ sealed interface NotificationSettingsEvents { data object FixConfigurationMismatch : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents data object ClearNotificationChangeError : NotificationSettingsEvents + data object ChangePushProvider : NotificationSettingsEvents + data object CancelChangePushProvider : NotificationSettingsEvents + data class SetPushProvider(val index: Int) : NotificationSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt index 621b0ed8b1..0c6e7660ef 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt @@ -35,7 +35,7 @@ class NotificationSettingsNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun editDefaultNotificationMode(isOneToOne: Boolean) - fun onTroubleshootNotificationsClicked() + fun onTroubleshootNotificationsClick() } private val callbacks = plugins() @@ -44,8 +44,8 @@ class NotificationSettingsNode @AssistedInject constructor( callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) } } - private fun onTroubleshootNotificationsClicked() { - callbacks.forEach { it.onTroubleshootNotificationsClicked() } + private fun onTroubleshootNotificationsClick() { + callbacks.forEach { it.onTroubleshootNotificationsClick() } } @Composable @@ -54,8 +54,8 @@ class NotificationSettingsNode @AssistedInject constructor( NotificationSettingsView( state = state, onOpenEditDefault = { openEditDefault(isOneToOne = it) }, - onBackPressed = ::navigateUp, - onTroubleshootNotificationsClicked = ::onTroubleshootNotificationsClicked, + onBackClick = ::navigateUp, + onTroubleshootNotificationsClick = ::onTroubleshootNotificationsClick, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 9aa9cafb81..5cfc14545e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -20,17 +20,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.RoomNotificationMode +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 io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce @@ -44,7 +52,8 @@ class NotificationSettingsPresenter @Inject constructor( private val notificationSettingsService: NotificationSettingsService, private val userPushStoreFactory: UserPushStoreFactory, private val matrixClient: MatrixClient, - private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider + private val pushService: PushService, + private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, ) : Presenter { @Composable override fun present(): NotificationSettingsState { @@ -68,6 +77,60 @@ class NotificationSettingsPresenter @Inject constructor( observeNotificationSettings(matrixSettings) } + // 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 }.toImmutableList() + } + + var currentDistributorName by remember { mutableStateOf>(AsyncData.Uninitialized) } + var refreshPushProvider by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshPushProvider) { + val p = pushService.getCurrentPushProvider() + val name = p?.getCurrentDistributor(matrixClient)?.name + currentDistributorName = if (name != null) { + AsyncData.Success(name) + } else { + AsyncData.Failure(Exception("Failed to get current push provider")) + } + } + + var showChangePushProviderDialog by remember { mutableStateOf(false) } + + fun CoroutineScope.changePushProvider( + data: Pair? + ) = launch { + showChangePushProviderDialog = false + data ?: return@launch + // No op if the value is the same. + if (data.second.name == currentDistributorName.dataOrNull()) return@launch + currentDistributorName = AsyncData.Loading(currentDistributorName.dataOrNull()) + data.let { (pushProvider, distributor) -> + pushService.registerWith( + matrixClient = matrixClient, + pushProvider = pushProvider, + distributor = distributor + ) + .fold( + { + refreshPushProvider++ + }, + { + currentDistributorName = AsyncData.Failure(it) + } + ) + } + } + fun handleEvents(event: NotificationSettingsEvents) { when (event) { is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> { @@ -88,6 +151,9 @@ class NotificationSettingsPresenter @Inject constructor( systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() } NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = AsyncAction.Uninitialized + NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true + NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false + is NotificationSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) } } @@ -98,6 +164,9 @@ class NotificationSettingsPresenter @Inject constructor( appNotificationsEnabled = appNotificationsEnabled.value ), changeNotificationSettingAction = changeNotificationSettingAction.value, + currentPushDistributor = currentDistributorName, + availablePushDistributors = distributorNames, + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = ::handleEvents ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index 5c23e74cd5..b545213c7a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -18,13 +18,18 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.runtime.Immutable import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.ImmutableList @Immutable data class NotificationSettingsState( val matrixSettings: MatrixSettings, val appSettings: AppSettings, val changeNotificationSettingAction: AsyncAction, + val currentPushDistributor: AsyncData, + val availablePushDistributors: ImmutableList, + val showChangePushProviderDialog: Boolean, val eventSink: (NotificationSettingsEvents) -> Unit, ) { sealed interface MatrixSettings { @@ -46,4 +51,10 @@ data class NotificationSettingsState( val systemNotificationsEnabled: Boolean, val appNotificationsEnabled: Boolean, ) + + /** + * Whether the advanced settings should be shown. + * This is true if the current push distributor is in a failure state or if there are multiple push distributors available. + */ + val showAdvancedSettings: Boolean = currentPushDistributor.isFailure() || availablePushDistributors.size > 1 } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt index dc1e972aa6..5685804e06 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -18,14 +18,26 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList open class NotificationSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( + aValidNotificationSettingsState(systemNotificationsEnabled = false), aValidNotificationSettingsState(), aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading), aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(Throwable("error"))), + aValidNotificationSettingsState( + availablePushDistributors = listOf("Firebase"), + changeNotificationSettingAction = AsyncAction.Failure(Throwable("error")), + ), + aValidNotificationSettingsState(availablePushDistributors = listOf("Firebase")), + aValidNotificationSettingsState(showChangePushProviderDialog = true), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Loading()), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Failure(Exception("Failed to change distributor"))), aInvalidNotificationSettingsState(), aInvalidNotificationSettingsState(fixFailed = true), ) @@ -36,7 +48,11 @@ fun aValidNotificationSettingsState( atRoomNotificationsEnabled: Boolean = true, callNotificationsEnabled: Boolean = true, inviteForMeNotificationsEnabled: Boolean = true, + systemNotificationsEnabled: Boolean = true, appNotificationEnabled: Boolean = true, + currentPushDistributor: AsyncData = AsyncData.Success("Firebase"), + availablePushDistributors: List = listOf("Firebase", "ntfy"), + showChangePushProviderDialog: Boolean = false, eventSink: (NotificationSettingsEvents) -> Unit = {}, ) = NotificationSettingsState( matrixSettings = NotificationSettingsState.MatrixSettings.Valid( @@ -47,10 +63,13 @@ fun aValidNotificationSettingsState( defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES, ), appSettings = NotificationSettingsState.AppSettings( - systemNotificationsEnabled = false, + systemNotificationsEnabled = systemNotificationsEnabled, appNotificationsEnabled = appNotificationEnabled, ), changeNotificationSettingAction = changeNotificationSettingAction, + currentPushDistributor = currentPushDistributor, + availablePushDistributors = availablePushDistributors.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = eventSink, ) @@ -66,5 +85,8 @@ fun aInvalidNotificationSettingsState( appNotificationsEnabled = true, ), changeNotificationSettingAction = AsyncAction.Uninitialized, + currentPushDistributor = AsyncData.Uninitialized, + availablePushDistributors = persistentListOf(), + showChangePushProviderDialog = false, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index d62f972d71..cf5127cee0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -16,28 +16,38 @@ package io.element.android.features.preferences.impl.notifications +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ListOption +import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList /** * A view that allows a user edit their global notification settings. @@ -46,8 +56,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun NotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit, - onTroubleshootNotificationsClicked: () -> Unit, - onBackPressed: () -> Unit, + onTroubleshootNotificationsClick: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -58,27 +68,27 @@ fun NotificationSettingsView( } PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_notification_settings_title) ) { when (state.matrixSettings) { is NotificationSettingsState.MatrixSettings.Invalid -> InvalidNotificationSettingsView( showError = state.matrixSettings.fixFailed, - onContinueClicked = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) }, + onContinueClick = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) }, onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) }, ) NotificationSettingsState.MatrixSettings.Uninitialized -> return@PreferencePage is NotificationSettingsState.MatrixSettings.Valid -> NotificationSettingsContentView( matrixSettings = state.matrixSettings, - systemSettings = state.appSettings, - onNotificationsEnabledChanged = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it)) }, - onGroupChatsClicked = { onOpenEditDefault(false) }, - onDirectChatsClicked = { onOpenEditDefault(true) }, - onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, + state = state, + onNotificationsEnabledChange = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it)) }, + onGroupChatsClick = { onOpenEditDefault(false) }, + onDirectChatsClick = { onOpenEditDefault(true) }, + onMentionNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, - onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, - onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked, + onInviteForMeNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, ) } AsyncActionView( @@ -93,17 +103,18 @@ fun NotificationSettingsView( @Composable private fun NotificationSettingsContentView( matrixSettings: NotificationSettingsState.MatrixSettings.Valid, - systemSettings: NotificationSettingsState.AppSettings, - onNotificationsEnabledChanged: (Boolean) -> Unit, - onGroupChatsClicked: () -> Unit, - onDirectChatsClicked: () -> Unit, - onMentionNotificationsChanged: (Boolean) -> Unit, + state: NotificationSettingsState, + onNotificationsEnabledChange: (Boolean) -> Unit, + onGroupChatsClick: () -> Unit, + onDirectChatsClick: () -> Unit, + onMentionNotificationsChange: (Boolean) -> Unit, // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged: (Boolean) -> Unit, - onInviteForMeNotificationsChanged: (Boolean) -> Unit, - onTroubleshootNotificationsClicked: () -> Unit, + onInviteForMeNotificationsChange: (Boolean) -> Unit, + onTroubleshootNotificationsClick: () -> Unit, ) { val context = LocalContext.current + val systemSettings: NotificationSettingsState.AppSettings = state.appSettings if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { PreferenceText( icon = CompoundIcons.NotificationsOffSolid(), @@ -121,8 +132,7 @@ private fun NotificationSettingsContentView( PreferenceSwitch( title = stringResource(id = R.string.screen_notification_settings_enable_notifications), isChecked = systemSettings.appNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onNotificationsEnabledChanged + onCheckedChange = onNotificationsEnabledChange ) if (systemSettings.appNotificationsEnabled) { @@ -130,13 +140,13 @@ private fun NotificationSettingsContentView( PreferenceText( title = stringResource(id = R.string.screen_notification_settings_group_chats), subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode), - onClick = onGroupChatsClicked + onClick = onGroupChatsClick ) PreferenceText( title = stringResource(id = R.string.screen_notification_settings_direct_chats), subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode), - onClick = onDirectChatsClicked + onClick = onDirectChatsClick ) } @@ -145,8 +155,7 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_room_mention_label), isChecked = matrixSettings.atRoomNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onMentionNotificationsChanged + onCheckedChange = onMentionNotificationsChange ) } PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { @@ -162,17 +171,62 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_invite_for_me_label), isChecked = matrixSettings.inviteForMeNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onInviteForMeNotificationsChanged + onCheckedChange = onInviteForMeNotificationsChange ) } PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) { PreferenceText( modifier = Modifier, title = stringResource(id = R.string.troubleshoot_notifications_entry_point_title), - onClick = onTroubleshootNotificationsClicked + onClick = onTroubleshootNotificationsClick ) } + if (state.showAdvancedSettings) { + PreferenceCategory(title = stringResource(id = CommonStrings.common_advanced_settings)) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) + }, + trailingContent = when (state.currentPushDistributor) { + AsyncData.Uninitialized, + is AsyncData.Loading -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + is AsyncData.Failure -> ListItemContent.Text( + stringResource(id = CommonStrings.common_error) + ) + is AsyncData.Success -> ListItemContent.Text( + state.currentPushDistributor.dataOrNull() ?: "" + ) + }, + onClick = { + if (state.currentPushDistributor.isReady()) { + state.eventSink(NotificationSettingsEvents.ChangePushProvider) + } + } + ) + } + 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()), + onSelectOption = { index -> + state.eventSink( + NotificationSettingsEvents.SetPushProvider(index) + ) + }, + onDismissRequest = { state.eventSink(NotificationSettingsEvents.CancelChangePushProvider) }, + ) + } + } } } @@ -188,14 +242,14 @@ private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) = @Composable private fun InvalidNotificationSettingsView( showError: Boolean, - onContinueClicked: () -> Unit, + onContinueClick: () -> Unit, onDismissError: () -> Unit, ) { DialogLikeBannerMolecule( title = stringResource(R.string.screen_notification_settings_configuration_mismatch), content = stringResource(R.string.screen_notification_settings_configuration_mismatch_description), - onSubmitClicked = onContinueClicked, - onDismissClicked = null, + onSubmitClick = onContinueClick, + onDismissClick = null, ) if (showError) { @@ -212,8 +266,8 @@ private fun InvalidNotificationSettingsView( internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) = ElementPreview { NotificationSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, onOpenEditDefault = {}, - onTroubleshootNotificationsClicked = {}, + onTroubleshootNotificationsClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt index 9aaec23e94..3adbd7528e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt @@ -16,11 +16,9 @@ package io.element.android.features.preferences.impl.notifications -import android.content.Context import androidx.core.app.NotificationManagerCompat import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import javax.inject.Inject @@ -29,11 +27,11 @@ interface SystemNotificationsEnabledProvider { } @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class, boundType = SystemNotificationsEnabledProvider::class) +@ContributesBinding(AppScope::class) class DefaultSystemNotificationsEnabledProvider @Inject constructor( - @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, ) : SystemNotificationsEnabledProvider { override fun notificationsEnabled(): Boolean { - return NotificationManagerCompat.from(context).areNotificationsEnabled() + return notificationManager.areNotificationsEnabled() } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt index b5a79947af..e6d1234d04 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Composable fun DefaultNotificationSettingOption( mode: RoomNotificationMode, - onOptionSelected: (RoomNotificationMode) -> Unit, + onSelectOption: (RoomNotificationMode) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, isSelected: Boolean = false, @@ -51,7 +51,7 @@ fun DefaultNotificationSettingOption( headlineContent = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), - onClick = { onOptionSelected(mode) }, + onClick = { onSelectOption(mode) }, ) } @@ -63,19 +63,19 @@ internal fun DefaultNotificationSettingOptionPreview() = ElementPreview { mode = RoomNotificationMode.ALL_MESSAGES, isSelected = true, displayMentionsOnlyDisclaimer = false, - onOptionSelected = {}, + onSelectOption = {}, ) DefaultNotificationSettingOption( mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, isSelected = false, displayMentionsOnlyDisclaimer = false, - onOptionSelected = {}, + onSelectOption = {}, ) DefaultNotificationSettingOption( mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, isSelected = false, displayMentionsOnlyDisclaimer = true, - onOptionSelected = {}, + onSelectOption = {}, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt index de365878ad..92dc1f94f6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt @@ -58,7 +58,7 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor( EditDefaultNotificationSettingView( state = state, openRoomNotificationSettings = { openRoomNotificationSettings(it) }, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 1938a4b4f0..f13aa90c4d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun EditDefaultNotificationSettingView( state: EditDefaultNotificationSettingState, openRoomNotificationSettings: (roomId: RoomId) -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val title = if (state.isOneToOne) { @@ -57,7 +57,7 @@ fun EditDefaultNotificationSettingView( } PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = title) ) { // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. @@ -68,7 +68,10 @@ fun EditDefaultNotificationSettingView( } else { R.string.screen_notification_settings_edit_screen_group_section_header } - PreferenceCategory(title = stringResource(id = categoryTitle)) { + PreferenceCategory( + title = stringResource(id = categoryTitle), + showTopDivider = false, + ) { if (state.mode != null) { Column(modifier = Modifier.selectableGroup()) { validModes.forEach { item -> @@ -76,7 +79,7 @@ fun EditDefaultNotificationSettingView( mode = item, isSelected = state.mode == item, displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) } + onSelectOption = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) } ) } } @@ -137,6 +140,6 @@ internal fun EditDefaultNotificationSettingViewPreview( EditDefaultNotificationSettingView( state = state, openRoomNotificationSettings = {}, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 034da13512..b5e1e52e11 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -45,7 +45,7 @@ class PreferencesRootNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onOpenBugReport() - fun onSecureBackupClicked() + fun onSecureBackupClick() fun onOpenAnalytics() fun onOpenAbout() fun onOpenDeveloperSettings() @@ -55,15 +55,15 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenScTweaks(scPrefScreen: ScPrefScreen?) fun onOpenUserProfile(matrixUser: MatrixUser) fun onOpenBlockedUsers() - fun onSignOutClicked() + fun onSignOutClick() } private fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } - private fun onSecureBackupClicked() { - plugins().forEach { it.onSecureBackupClicked() } + private fun onSecureBackupClick() { + plugins().forEach { it.onSecureBackupClick() } } private fun onOpenDeveloperSettings() { @@ -86,7 +86,7 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenAbout() } } - private fun onManageAccountClicked( + private fun onManageAccountClick( activity: Activity, url: String?, isDark: Boolean, @@ -123,8 +123,8 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenBlockedUsers() } } - private fun onSignOutClicked() { - plugins().forEach { it.onSignOutClicked() } + private fun onSignOutClick() { + plugins().forEach { it.onSignOutClick() } } @Composable @@ -135,24 +135,24 @@ class PreferencesRootNode @AssistedInject constructor( PreferencesRootView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, onOpenRageShake = this::onOpenBugReport, onOpenAnalytics = this::onOpenAnalytics, onOpenAbout = this::onOpenAbout, - onSecureBackupClicked = this::onSecureBackupClicked, + onSecureBackupClick = this::onSecureBackupClick, onOpenDeveloperSettings = this::onOpenDeveloperSettings, onOpenAdvancedSettings = this::onOpenAdvancedSettings, onOpenScTweaks = this::onOpenScTweaks, - onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) }, + onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = this::onOpenNotificationSettings, onOpenLockScreenSettings = this::onOpenLockScreenSettings, onOpenUserProfile = this::onOpenUserProfile, onOpenBlockedUsers = this::onOpenBlockedUsers, - onSignOutClicked = { + onSignOutClick = { if (state.directLogoutState.canDoDirectSignOut) { state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) } else { - onSignOutClicked() + onSignOutClick() } }, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 3a04ca1acb..37ec3412c4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -57,9 +57,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PreferencesRootView( state: PreferencesRootState, - onBackPressed: () -> Unit, - onSecureBackupClicked: () -> Unit, - onManageAccountClicked: (url: String) -> Unit, + onBackClick: () -> Unit, + onSecureBackupClick: () -> Unit, + onManageAccountClick: (url: String) -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, @@ -70,7 +70,7 @@ fun PreferencesRootView( onOpenNotificationSettings: () -> Unit, onOpenUserProfile: (MatrixUser) -> Unit, onOpenBlockedUsers: () -> Unit, - onSignOutClicked: () -> Unit, + onSignOutClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -78,7 +78,7 @@ fun PreferencesRootView( // Include pref from other modules PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_settings), snackbarHost = { SnackbarHost(snackbarHostState) } ) { @@ -101,13 +101,13 @@ fun PreferencesRootView( state = state, onOpenNotificationSettings = onOpenNotificationSettings, onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClicked = onSecureBackupClicked, + onSecureBackupClick = onSecureBackupClick, ) // 'Account' section ManageAccountSection( state = state, - onManageAccountClicked = onManageAccountClicked, + onManageAccountClick = onManageAccountClick, onOpenBlockedUsers = onOpenBlockedUsers ) @@ -119,7 +119,7 @@ fun PreferencesRootView( onOpenRageShake = onOpenRageShake, onOpenAdvancedSettings = onOpenAdvancedSettings, onOpenDeveloperSettings = onOpenDeveloperSettings, - onSignOutClicked = onSignOutClicked, + onSignOutClick = onSignOutClick, ) Footer( @@ -134,7 +134,7 @@ private fun ColumnScope.ManageAppSection( state: PreferencesRootState, onOpenNotificationSettings: () -> Unit, onOpenLockScreenSettings: () -> Unit, - onSecureBackupClicked: () -> Unit, + onSecureBackupClick: () -> Unit, ) { if (state.showNotificationSettings) { ListItem( @@ -155,7 +155,7 @@ private fun ColumnScope.ManageAppSection( headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())), trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, - onClick = onSecureBackupClicked, + onClick = onSecureBackupClick, ) } if (state.showNotificationSettings || state.showLockScreenSettings || state.showSecureBackup) { @@ -166,7 +166,7 @@ private fun ColumnScope.ManageAppSection( @Composable private fun ColumnScope.ManageAccountSection( state: PreferencesRootState, - onManageAccountClicked: (url: String) -> Unit, + onManageAccountClick: (url: String) -> Unit, onOpenBlockedUsers: () -> Unit, ) { state.accountManagementUrl?.let { url -> @@ -174,7 +174,7 @@ private fun ColumnScope.ManageAccountSection( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClicked(url) }, + onClick = { onManageAccountClick(url) }, ) } @@ -183,7 +183,7 @@ private fun ColumnScope.ManageAccountSection( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClicked(url) }, + onClick = { onManageAccountClick(url) }, ) } @@ -208,7 +208,7 @@ private fun ColumnScope.GeneralSection( onOpenRageShake: () -> Unit, onOpenAdvancedSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onSignOutClicked: () -> Unit, + onSignOutClick: () -> Unit, ) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, @@ -239,7 +239,7 @@ private fun ColumnScope.GeneralSection( headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())), style = ListItemStyle.Destructive, - onClick = onSignOutClicked, + onClick = onSignOutClick, ) } @@ -292,19 +292,19 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider private fun ContentToPreview(matrixUser: MatrixUser) { PreferencesRootView( state = aPreferencesRootState(myUser = matrixUser), - onBackPressed = {}, + onBackClick = {}, onOpenAnalytics = {}, onOpenRageShake = {}, onOpenDeveloperSettings = {}, onOpenAdvancedSettings = {}, onOpenScTweaks = {}, onOpenAbout = {}, - onSecureBackupClicked = {}, - onManageAccountClicked = {}, + onSecureBackupClick = {}, + onManageAccountClick = {}, onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, onOpenUserProfile = {}, onOpenBlockedUsers = {}, - onSignOutClicked = {}, + onSignOutClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt index 4fcbb94cf1..46b2f8f9d3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt @@ -47,8 +47,8 @@ class EditUserProfileNode @AssistedInject constructor( val state = presenter.present() EditUserProfileView( state = state, - onBackPressed = ::navigateUp, - onProfileEdited = ::navigateUp, + onBackClick = ::navigateUp, + onEditProfileSuccess = ::navigateUp, modifier = modifier ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 389d7c5b08..167c301a35 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -60,14 +60,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun EditUserProfileView( state: EditUserProfileState, - onBackPressed: () -> Unit, - onProfileEdited: () -> Unit, + onBackClick: () -> Unit, + onEditProfileSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() isAvatarActionsSheetVisible.value = true } @@ -82,7 +82,7 @@ fun EditUserProfileView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -110,7 +110,7 @@ fun EditUserProfileView( displayName = state.displayName, avatarUrl = state.userAvatarUrl, avatarSize = AvatarSize.RoomHeader, - onAvatarClicked = { onAvatarClicked() }, + onAvatarClick = { onAvatarClick() }, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(modifier = Modifier.height(16.dp)) @@ -134,7 +134,7 @@ fun EditUserProfileView( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, - onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } + onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -144,7 +144,7 @@ fun EditUserProfileView( progressText = stringResource(R.string.screen_edit_profile_updating_details), ) }, - onSuccess = { onProfileEdited() }, + onSuccess = { onEditProfileSuccess() }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) }, onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, @@ -160,8 +160,8 @@ fun EditUserProfileView( internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = ElementPreview { EditUserProfileView( - onBackPressed = {}, - onProfileEdited = {}, + onBackClick = {}, + onEditProfileSuccess = {}, state = state, ) } diff --git a/features/preferences/impl/src/main/res/values-be/translations.xml b/features/preferences/impl/src/main/res/values-be/translations.xml index 33d1b2706e..d064ee1f40 100644 --- a/features/preferences/impl/src/main/res/values-be/translations.xml +++ b/features/preferences/impl/src/main/res/values-be/translations.xml @@ -3,8 +3,8 @@ "Выберыце спосаб атрымання апавяшчэнняў" "Рэжым распрацоўшчыка" "Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям." - "Базавы URL сервера званкоў Element" - "Задайце свой сервер Element Call." + "Карыстальніцкі URL сервера Element Call" + "Усталюйце карыстальніцкі асноўны URL для Element Call." "Адрас пазначаны няправільна, пераканайцеся, што вы ўказалі пратакол (http/https) і правільны адрас." "Пастаўшчык push-апавяшчэнняў" "Адключыць рэдактар фарматаванага тэксту і ўключыць Markdown." @@ -25,7 +25,7 @@ "Рэдагаваць профіль" "Абнаўленне профілю…" "Дадатковыя налады" - "Аўдыё і відэа званкі" + "Аўдыя і відэа званкі" "Неадпаведнасць канфігурацыі" "Мы спрасцілі налады апавяшчэнняў, каб спрасціць пошук опцый. Некаторыя карыстальніцкія наладкі, абраныя вамі раней, не адлюстроўваюцца ў дадзеным меню, але яны ўсё яшчэ актыўныя. diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml index ba19d3541f..587623d4e5 100644 --- a/features/preferences/impl/src/main/res/values-cs/translations.xml +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -6,6 +6,7 @@ "Vlastní URL pro Element Call" "Nastavte vlastní URL pro Element Call." "Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu." + "Poskytovatel push oznámení" "Vypněte editor formátovaného textu pro ruční zadání Markdown." "Potvrzení o přečtení" "Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů." diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index 14d30f16b7..ac12390e76 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -6,6 +6,7 @@ "URL de base pour Element Call personnalisée" "Configurer une URL de base pour Element Call." "URL invalide, assurez-vous d’inclure le protocol (http/https) et l’adresse correcte." + "Fournisseur de Push" "Désactivez l’éditeur de texte enrichi pour saisir manuellement du Markdown." "Accusés de lecture" "En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres." diff --git a/features/preferences/impl/src/main/res/values-pt/translations.xml b/features/preferences/impl/src/main/res/values-pt/translations.xml index 8ebe95071a..5986f8e93a 100644 --- a/features/preferences/impl/src/main/res/values-pt/translations.xml +++ b/features/preferences/impl/src/main/res/values-pt/translations.xml @@ -6,6 +6,7 @@ "URL base para Element Call personalizado" "Define um URL base para a Element Call." "URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto." + "Fornecedor de envio" "Desativa o editor de texto rico para poderes escrever Markdown manualmente." "Recibos de leitura" "Se desativada, os teus recibos de leitura não serão enviados a ninguém. Continuas a receber recibos de leitura de outros utilizadores." diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index dccf557cbf..3c4a49dd6d 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -6,6 +6,7 @@ "Базовый URL сервера звонков Element" "Задайте свой сервер Element Call." "Адрес указан неверно, удостоверьтесь, что вы указали протокол (http/https) и правильный адрес." + "Поставщик push-уведомлений" "Отключить редактор форматированного текста и включить Markdown." "Уведомления о прочтении" "Если этот параметр выключен, ваш статус о прочтении не будет отображаться. Вы по-прежнему будете видеть статус о прочтении от других пользователей." diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml index 02ee01532e..57233bb027 100644 --- a/features/preferences/impl/src/main/res/values-sv/translations.xml +++ b/features/preferences/impl/src/main/res/values-sv/translations.xml @@ -12,9 +12,11 @@ "Dela närvaro" "Om det är avstängt kan du inte skicka eller ta emot läskvitton eller skrivnotiser" "Aktivera alternativet för att visa meddelandekälla i tidslinjen." + "Du har inga blockerade användare" "Avblockera" "Du kommer att kunna se alla meddelanden från dem igen." "Avblockera användare" + "Avblockerar …" "Visningsnamn" "Ditt visningsnamn" "Ett okänt fel påträffades och informationen kunde inte ändras." diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 7e4175d3bd..e3ca6bf8b4 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -21,16 +21,8 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.push.test.FakePushService -import io.element.android.libraries.pushproviders.api.Distributor -import io.element.android.libraries.pushproviders.api.PushProvider -import io.element.android.libraries.pushproviders.test.FakePushProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem import kotlinx.coroutines.test.runTest @@ -108,93 +100,11 @@ class AdvancedSettingsPresenterTest { } } - @Test - fun `present - change push provider`() = runTest { - val presenter = createAdvancedSettingsPresenter( - pushService = createFakePushService(), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitLastSequentialItem() - assertThat(initialState.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName0")) - assertThat(initialState.availablePushDistributors).containsExactly("aDistributorName0", "aDistributorName1") - initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) - val withDialog = awaitItem() - assertThat(withDialog.showChangePushProviderDialog).isTrue() - // Cancel - withDialog.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) - val withoutDialog = awaitItem() - assertThat(withoutDialog.showChangePushProviderDialog).isFalse() - withDialog.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) - assertThat(awaitItem().showChangePushProviderDialog).isTrue() - withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) - val withNewProvider = awaitItem() - assertThat(withNewProvider.showChangePushProviderDialog).isFalse() - assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) - val lastItem = awaitItem() - assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName1")) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change push provider error`() = runTest { - val presenter = createAdvancedSettingsPresenter( - pushService = createFakePushService( - registerWithLambda = { _, _, _ -> - Result.failure(Exception("An error")) - }, - ), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitLastSequentialItem() - initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) - val withDialog = awaitItem() - assertThat(withDialog.showChangePushProviderDialog).isTrue() - withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) - val withNewProvider = awaitItem() - assertThat(withNewProvider.showChangePushProviderDialog).isFalse() - assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) - val lastItem = awaitItem() - assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncAction.Failure::class.java) - } - } - - private fun createFakePushService( - registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> - Result.success(Unit) - } - ): PushService { - val pushProvider1 = FakePushProvider( - index = 0, - name = "aFakePushProvider0", - isAvailable = true, - distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), - ) - val pushProvider2 = FakePushProvider( - index = 1, - name = "aFakePushProvider1", - isAvailable = true, - distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), - ) - return FakePushService( - availablePushProviders = listOf(pushProvider1, pushProvider2), - registerWithLambda = registerWithLambda, - ) - } - private fun createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), - matrixClient: MatrixClient = FakeMatrixClient(), - pushService: PushService = FakePushService(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, - matrixClient = matrixClient, - pushService = pushService, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt index ee8c900579..9d581f2e56 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -19,8 +19,6 @@ 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 @@ -34,7 +32,6 @@ 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 { @@ -49,7 +46,7 @@ class AdvancedSettingsViewTest { state = aAdvancedSettingsState( eventSink = eventsRecorder ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -103,43 +100,16 @@ class AdvancedSettingsViewTest { rule.clickOn(R.string.screen_advanced_settings_share_presence) eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) } - - @Config(qualifiers = "h1024dp") - @Test - fun `clicking on Push notification provider emits the expected event`() { - val eventsRecorder = EventsRecorder() - rule.setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder - ), - ) - rule.clickOn(R.string.screen_advanced_settings_push_provider_android) - eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangePushProvider) - } - - @Test - fun `clicking on a push provider emits the expected event`() { - val eventsRecorder = EventsRecorder() - rule.setAdvancedSettingsView( - state = aAdvancedSettingsState( - eventSink = eventsRecorder, - showChangePushProviderDialog = true, - availablePushDistributors = listOf("P1", "P2") - ), - ) - rule.onNodeWithText("P2").performClick() - eventsRecorder.assertSingle(AdvancedSettingsEvents.SetPushProvider(1)) - } } private fun AndroidComposeTestRule.setAdvancedSettingsView( state: AdvancedSettingsState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { AdvancedSettingsView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt new file mode 100644 index 0000000000..353d505e50 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -0,0 +1,109 @@ +/* + * 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.blockedusers + +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.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +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 + +@RunWith(AndroidJUnit4::class) +class BlockedUserViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLogoutView( + aBlockedUsersState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on a user emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val userList = aMatrixUserList() + rule.setLogoutView( + aBlockedUsersState( + blockedUsers = userList, + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText(userList.first().displayName.orEmpty()).performClick() + eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) + } + + @Test + fun `clicking on cancel sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aBlockedUsersState( + unblockUserAction = AsyncAction.Confirming, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(BlockedUsersEvents.Cancel) + } + + @Test + fun `clicking on confirm sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aBlockedUsersState( + unblockUserAction = AsyncAction.Confirming, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_blocked_users_unblock_alert_action) + eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock) + } +} + +private fun AndroidComposeTestRule.setLogoutView( + state: BlockedUsersState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + BlockedUsersView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt similarity index 75% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt index 3bcd730fed..5d55d1c1d5 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTest.kt @@ -21,6 +21,11 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -28,7 +33,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.test.runTest import org.junit.Test -class BlockedUsersPresenterTests { +class BlockedUsersPresenterTest { @Test fun `present - initial state with no blocked users`() = runTest { val presenter = aBlockedUsersPresenter() @@ -52,7 +57,7 @@ class BlockedUsersPresenterTests { presenter.present() }.test { with(awaitItem()) { - assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID)) + assertThat(blockedUsers).isEqualTo(persistentListOf(MatrixUser(A_USER_ID))) assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -68,14 +73,39 @@ class BlockedUsersPresenterTests { presenter.present() }.test { with(awaitItem()) { - assertThat(blockedUsers).containsAtLeastElementsIn(persistentListOf(A_USER_ID)) - assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID))) } - matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) + skipItems(1) with(awaitItem()) { - assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID, A_USER_ID_2)) - assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + } + } + + @Test + fun `present - blocked users list with data`() = runTest { + val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar") + val matrixClient = FakeMatrixClient().apply { + ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) + givenGetProfileResult(A_USER_ID, Result.success(alice)) + givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION)) + } + val presenter = aBlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.ShowBlockedUsersDetails, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + // Alice is resolved + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(alice, MatrixUser(A_USER_ID_2))) } } } @@ -157,5 +187,9 @@ class BlockedUsersPresenterTests { private fun aBlockedUsersPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), - ) = BlockedUsersPresenter(matrixClient) + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + ) = BlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = featureFlagService, + ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index ea120a27c1..7288b76d3c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -48,7 +48,7 @@ class DeveloperSettingsViewTest { state = aDeveloperSettingsState( eventSink = eventsRecorder ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -82,6 +82,7 @@ class DeveloperSettingsViewTest { } } + @Config(qualifiers = "h1024dp") @Test fun `clicking on configure tracing invokes the expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -96,7 +97,7 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1024dp") + @Config(qualifiers = "h1500dp") @Test fun `clicking on clear cache emits the expected event`() { val eventsRecorder = EventsRecorder() @@ -114,14 +115,14 @@ private fun AndroidComposeTestRule.setDevel state: DeveloperSettingsState, onOpenShowkase: () -> Unit = EnsureNeverCalled(), onOpenConfigureTracing: () -> Unit = EnsureNeverCalled(), - onBackPressed: () -> Unit = EnsureNeverCalled() + onBackClick: () -> Unit = EnsureNeverCalled() ) { setContent { DeveloperSettingsView( state = state, onOpenShowkase = onOpenShowkase, onOpenConfigureTracing = onOpenConfigureTracing, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt similarity index 99% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt index c8655d6a26..0d5921047c 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/EditDefaultNotificationSettingsPresenterTest.kt @@ -36,7 +36,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test -class EditDefaultNotificationSettingsPresenterTests { +class EditDefaultNotificationSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { val notificationSettingsService = FakeNotificationSettingsService() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt similarity index 74% rename from features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt rename to features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index e058d4691b..3530095ed0 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -19,18 +19,26 @@ package io.element.android.features.preferences.impl.notifications import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +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.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.time.Duration.Companion.milliseconds -class NotificationSettingsPresenterTests { +class NotificationSettingsPresenterTest { @Test fun `present - ensures initial state is correct`() = runTest { val presenter = createNotificationSettingsPresenter() @@ -230,14 +238,95 @@ class NotificationSettingsPresenterTests { } } + @Test + fun `present - change push provider`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success("aDistributorName0")) + assertThat(initialState.availablePushDistributors).containsExactly("aDistributorName0", "aDistributorName1") + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + // Cancel + withDialog.eventSink(NotificationSettingsEvents.CancelChangePushProvider) + val withoutDialog = awaitItem() + assertThat(withoutDialog.showChangePushProviderDialog).isFalse() + withDialog.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + skipItems(1) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncData.Success("aDistributorName1")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider error`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService( + registerWithLambda = { _, _, _ -> + Result.failure(Exception("An error")) + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncData.Failure::class.java) + } + } + + private fun createFakePushService( + registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + } + ): PushService { + val pushProvider1 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + ) + val pushProvider2 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + ) + return FakePushService( + availablePushProviders = listOf(pushProvider1, pushProvider2), + registerWithLambda = registerWithLambda, + ) + } + private fun createNotificationSettingsPresenter( - notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + pushService: PushService = FakePushService(), ): NotificationSettingsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) return NotificationSettingsPresenter( notificationSettingsService = notificationSettingsService, userPushStoreFactory = FakeUserPushStoreFactory(), matrixClient = matrixClient, + pushService = pushService, systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index 8397d5aef6..93d8422bd2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -19,6 +19,8 @@ package io.element.android.features.preferences.impl.notifications 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.features.preferences.impl.R import io.element.android.libraries.architecture.AsyncAction @@ -50,7 +52,7 @@ class NotificationSettingsViewTest { state = aValidNotificationSettingsState( eventSink = eventsRecorder ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -66,7 +68,7 @@ class NotificationSettingsViewTest { state = aValidNotificationSettingsState( eventSink = eventsRecorder ), - onTroubleshootNotificationsClicked = it + onTroubleshootNotificationsClick = it ) rule.clickOn(R.string.troubleshoot_notifications_entry_point_title) } @@ -248,20 +250,57 @@ class NotificationSettingsViewTest { ) ) } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Push notification provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_push_provider_android) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.ChangePushProvider, + ) + ) + } + + @Test + fun `clicking on a push provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder, + showChangePushProviderDialog = true, + availablePushDistributors = listOf("P1", "P2") + ), + ) + rule.onNodeWithText("P2").performClick() + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetPushProvider(1), + ) + ) + } } private fun AndroidComposeTestRule.setNotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(), - onTroubleshootNotificationsClicked: () -> Unit = EnsureNeverCalled(), - onBackPressed: () -> Unit = EnsureNeverCalled(), + onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { NotificationSettingsView( state = state, onOpenEditDefault = onOpenEditDefault, - onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked, - onBackPressed = onBackPressed, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + onBackClick = onBackClick, ) } } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt index 5af36b5ad8..39bec346b3 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt @@ -36,8 +36,8 @@ fun CrashDetectionView( if (state.crashDetected) { CrashDetectionContent( appName = state.appName, - onYesClicked = onOpenBugReport, - onNoClicked = ::onPopupDismissed, + onYesClick = onOpenBugReport, + onNoClick = ::onPopupDismissed, onDismiss = ::onPopupDismissed, ) } @@ -46,8 +46,8 @@ fun CrashDetectionView( @Composable private fun CrashDetectionContent( appName: String, - onNoClicked: () -> Unit = { }, - onYesClicked: () -> Unit = { }, + onNoClick: () -> Unit = { }, + onYesClick: () -> Unit = { }, onDismiss: () -> Unit = { }, ) { ConfirmationDialog( @@ -55,8 +55,8 @@ private fun CrashDetectionContent( content = stringResource(id = R.string.crash_detection_dialog_content, appName), submitText = stringResource(id = CommonStrings.action_yes), cancelText = stringResource(id = CommonStrings.action_no), - onCancelClicked = onNoClicked, - onSubmitClicked = onYesClicked, + onCancelClick = onNoClick, + onSubmitClick = onYesClick, onDismiss = onDismiss, ) } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt index 6121c684cd..76fcf845be 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt @@ -50,16 +50,16 @@ fun RageshakeDetectionView( } when { state.takeScreenshot -> TakeScreenshot( - onScreenshotTaken = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } + onScreenshot = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } ) state.showDialog -> { LaunchedEffect(Unit) { context.vibrate() } RageshakeDialogContent( - onNoClicked = { eventSink(RageshakeDetectionEvents.Dismiss) }, - onDisableClicked = { eventSink(RageshakeDetectionEvents.Disable) }, - onYesClicked = onOpenBugReport + onNoClick = { eventSink(RageshakeDetectionEvents.Dismiss) }, + onDisableClick = { eventSink(RageshakeDetectionEvents.Disable) }, + onYesClick = onOpenBugReport ) } } @@ -67,22 +67,22 @@ fun RageshakeDetectionView( @Composable private fun TakeScreenshot( - onScreenshotTaken: (ImageResult) -> Unit + onScreenshot: (ImageResult) -> Unit ) { val view = LocalView.current - val latestOnScreenshotTaken by rememberUpdatedState(onScreenshotTaken) + val latestOnScreenshot by rememberUpdatedState(onScreenshot) LaunchedEffect(Unit) { view.screenshot { - latestOnScreenshotTaken(it) + latestOnScreenshot(it) } } } @Composable private fun RageshakeDialogContent( - onNoClicked: () -> Unit = { }, - onDisableClicked: () -> Unit = { }, - onYesClicked: () -> Unit = { }, + onNoClick: () -> Unit = { }, + onDisableClick: () -> Unit = { }, + onYesClick: () -> Unit = { }, ) { ConfirmationDialog( title = stringResource(id = CommonStrings.action_report_bug), @@ -90,10 +90,10 @@ private fun RageshakeDialogContent( thirdButtonText = stringResource(id = CommonStrings.action_disable), submitText = stringResource(id = CommonStrings.action_yes), cancelText = stringResource(id = CommonStrings.action_no), - onCancelClicked = onNoClicked, - onThirdButtonClicked = onDisableClicked, - onSubmitClicked = onYesClicked, - onDismiss = onNoClicked, + onCancelClick = onNoClick, + onThirdButtonClick = onDisableClick, + onSubmitClick = onYesClick, + onDismiss = onNoClick, ) } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt index 4f48871666..ec739f423b 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/reporter/BugReporter.kt @@ -25,7 +25,7 @@ interface BugReporter { * @param withDevicesLogs true to include the device log * @param withCrashLogs true to include the crash logs * @param withScreenshot true to include the screenshot - * @param theBugDescription the bug description + * @param problemDescription the bug description * @param canContact true if the user opt in to be contacted directly * @param listener the listener */ @@ -33,9 +33,9 @@ interface BugReporter { withDevicesLogs: Boolean, withCrashLogs: Boolean, withScreenshot: Boolean, - theBugDescription: String, + problemDescription: String, canContact: Boolean = false, - listener: BugReporterListener? + listener: BugReporterListener ) /** diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt index caed077228..d3505aeefb 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt @@ -51,8 +51,8 @@ class BugReportNode @AssistedInject constructor( BugReportView( state = state, modifier = modifier, - onBackPressed = { navigateUp() }, - onDone = { + onBackClick = { navigateUp() }, + onSuccess = { activity?.toast(CommonStrings.common_report_submitted) onDone() }, diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt index 053acf1929..d7c725bfc0 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenter.kt @@ -146,7 +146,7 @@ class BugReportPresenter @Inject constructor( withDevicesLogs = formState.sendLogs, withCrashLogs = hasCrashLogs && formState.sendLogs, withScreenshot = formState.sendScreenshot, - theBugDescription = formState.description, + problemDescription = formState.description, canContact = formState.canContact, listener = listener ) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index f5ffd62fd6..599a48025e 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -57,8 +57,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun BugReportView( state: BugReportState, onViewLogs: () -> Unit, - onDone: () -> Unit, - onBackPressed: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -66,7 +66,7 @@ fun BugReportView( Box(modifier = modifier) { PreferencePage( title = stringResource(id = CommonStrings.common_report_a_problem), - onBackPressed = onBackPressed + onBackClick = onBackClick ) { val isFormEnabled = state.sending !is AsyncAction.Loading var descriptionFieldState by textFieldState( @@ -163,7 +163,7 @@ fun BugReportView( progressDialog = { }, onSuccess = { eventSink(BugReportEvents.ResetAll) - onDone() + onSuccess() }, errorMessage = { error -> when (error) { @@ -181,8 +181,8 @@ fun BugReportView( internal fun BugReportViewPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview { BugReportView( state = state, - onDone = {}, - onBackPressed = {}, + onSuccess = {}, + onBackClick = {}, onViewLogs = {}, ) } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index fbd0f082a0..0a2c4b3cbe 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -40,13 +40,14 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import okhttp3.Call import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -88,6 +89,7 @@ class DefaultBugReporter @Inject constructor( private val scAppStateStore: ScAppStateStore, private val scPreferencesStore: ScPreferencesStore, private val sdkMetadata: SdkMetadata, + private val matrixClientProvider: MatrixClientProvider, ) : BugReporter { companion object { // filenames @@ -95,11 +97,6 @@ class DefaultBugReporter @Inject constructor( private const val LOG_DIRECTORY_NAME = "logs" } - // the pending bug report call - private var bugReportCall: Call? = null - - // boolean to cancel the bug report - private val isCancelled = false private val logcatCommandDebug = arrayOf("logcat", "-d", "-v", "threadtime", "*:*") private var currentTracingFilter: String? = null @@ -109,284 +106,241 @@ class DefaultBugReporter @Inject constructor( withDevicesLogs: Boolean, withCrashLogs: Boolean, withScreenshot: Boolean, - theBugDescription: String, + problemDescription: String, canContact: Boolean, - listener: BugReporterListener? + listener: BugReporterListener, ) { // enumerate files to delete val bugReportFiles: MutableList = ArrayList() + var response: Response? = null try { var serverError: String? = null withContext(coroutineDispatchers.io) { - var bugDescription = theBugDescription val crashCallStack = crashDataStore.crashInfo().first() - - if (crashCallStack.isNotEmpty() && withCrashLogs) { - bugDescription += "\n\n\n\n--------------------------------- crash call stack ---------------------------------\n" - bugDescription += crashCallStack + val bugDescription = buildString { + append(problemDescription) + if (crashCallStack.isNotEmpty() && withCrashLogs) { + append("\n\n\n\n--------------------------------- crash call stack ---------------------------------\n") + append(crashCallStack) + } } - - val gzippedFiles = ArrayList() - + val gzippedFiles = mutableListOf() if (withDevicesLogs) { val files = getLogFiles().sortedByDescending { it.lastModified() } - files.mapNotNullTo(gzippedFiles) { f -> + files.mapNotNullTo(gzippedFiles) { file -> when { - isCancelled -> null - f.extension == "gz" -> f - else -> compressFile(f) + file.extension == "gz" -> file + else -> compressFile(file) } } files.deleteAllExceptMostRecent() } - - if (!isCancelled && (withCrashLogs || withDevicesLogs)) { + if (withCrashLogs || withDevicesLogs) { saveLogCat() val gzippedLogcat = compressFile(logCatErrFile) - if (null != gzippedLogcat) { - if (gzippedFiles.isEmpty()) { - gzippedFiles.add(gzippedLogcat) - } else { - gzippedFiles.add(0, gzippedLogcat) - } + if (gzippedLogcat != null) { + gzippedFiles.add(0, gzippedLogcat) } } - val sessionData = sessionStore.getLatestSession() val deviceId = sessionData?.deviceId ?: "undefined" - val userId = sessionData?.userId ?: "undefined" - - if (!isCancelled) { - // build the multi part request - val builder = BugReporterMultipartBody.Builder() - .addFormDataPart("text", bugDescription) - .addFormDataPart("app", context.getString(R.string.bug_report_app_name)) - .addFormDataPart("user_agent", userAgentProvider.provide()) - .addFormDataPart("user_id", userId) - .addFormDataPart("can_contact", canContact.toString()) - .addFormDataPart("device_id", deviceId) - .addFormDataPart("device", Build.MODEL.trim()) - .addFormDataPart("locale", Locale.getDefault().toString()) - .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) - .addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) - .addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) - .addFormDataPart("app_id", buildMeta.applicationId) - // Nightly versions have a custom version name suffix that we should remove for the bug report - .addFormDataPart("Version", buildMeta.versionName.replace("-nightly", "")) - currentTracingFilter?.let { - builder.addFormDataPart("tracing_filter", it) - } - - // SC additions - val reportTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ", Locale.US).format(Date()) - /* - val enabledDebugSettings = DbgUtil.ALL_PREFS.filter { DbgUtil.isDbgEnabled(it) } - builder.addFormDataPart("enabledDebugSettings", enabledDebugSettings.joinToString().ensureNonEmpty()) - .addFormDataPart("experimentalSettingsEnabled", vectorPreferences.getEnabledExperimentalSettings().joinToString().ensureNonEmpty()) - .addFormDataPart("experimentalSettingsDisabled", vectorPreferences.getDisabledExperimentalSettings().joinToString().ensureNonEmpty()) - */ - builder - .addFormDataPart("reportTime", reportTime) - .addFormDataPart("packageName", buildMeta.applicationId) - .addFormDataPart("is_debug_build", buildMeta.isDebuggable.toString()) - if (buildMeta.isDebuggable) { - builder.addFormDataPart("label", "debug_build") - } - if (canContact) { - builder.addFormDataPart("label", "can contact") - } - builder.addFormDataPart("label", "hs:${userId.substringAfter(":")}") - builder.addFormDataPart("label", "mxid:$userId") - builder.addFormDataPart("up_last_push", scAppStateStore.formatLastPushTs()) - builder.addFormDataPart("up_provider", scAppStateStore.lastPushProvider()) - builder.addFormDataPart("up_gateway", scAppStateStore.lastPushGateway()) - builder.addFormDataPart("up_distributor", scAppStateStore.lastPushDistributor().toString()) - val gatewayHost = tryOrNull { URL(scAppStateStore.lastPushGateway()).host } - builder.addFormDataPart("label", "up:gateway:$gatewayHost") - builder.addFormDataPart("label", "up:distributor:${scAppStateStore.lastPushDistributorName()}") - // Device characteristics - context.resources.displayMetrics.apply { - builder.addFormDataPart("device_density", density.toString()) - .addFormDataPart("device_width_px", widthPixels.toString()) - .addFormDataPart("device_height_px", heightPixels.toString()) - .addFormDataPart("device_width_dp", (widthPixels/density).toString()) - .addFormDataPart("device_height_dp", (heightPixels/density).toString()) - } - // Non-default SC settings - val changedScSettings = ScPrefs.scTweaks.prefs.collectScPrefs { - scPreferencesStore.getCachedOrDefaultValue(it) != it.defaultValue - } - val scPrefsString = changedScSettings.joinToString(separator = ",") { "${it.sKey}=${scPreferencesStore.getCachedOrDefaultValue(it)}" } - builder.addFormDataPart("sc_preferences", scPrefsString) - - - // add the gzipped files, don't cancel the whole upload if only some file failed to upload - var totalUploadedSize = 0L - var uploadedSomeLogs = false - for (file in gzippedFiles) { - try { - val requestBody = file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) - totalUploadedSize += requestBody.contentLength() - // If we are about to upload more than the max request size, stop here - if (totalUploadedSize > ApplicationConfig.MAX_LOG_UPLOAD_SIZE) { - Timber.e("Could not upload file ${file.name} because it would exceed the max request size") - break - } - builder.addFormDataPart("compressed-log", file.name, requestBody) - uploadedSomeLogs = true - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "## sendBugReport() : fail to attach file ${file.name}") + val userId = sessionData?.userId?.let { UserId(it) } + // build the multi part request + val builder = BugReporterMultipartBody.Builder() + .addFormDataPart("text", bugDescription) + .addFormDataPart("app", context.getString(R.string.bug_report_app_name)) + .addFormDataPart("user_agent", userAgentProvider.provide()) + .addFormDataPart("user_id", userId?.toString() ?: "undefined") + .addFormDataPart("can_contact", canContact.toString()) + .addFormDataPart("device_id", deviceId) + .addFormDataPart("device", Build.MODEL.trim()) + .addFormDataPart("locale", Locale.getDefault().toString()) + .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) + .addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) + .addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME)) + .addFormDataPart("app_id", buildMeta.applicationId) + // Nightly versions have a custom version name suffix that we should remove for the bug report + .addFormDataPart("Version", buildMeta.versionName.replace("-nightly", "")) + .addFormDataPart("label", buildMeta.versionName) + //.addFormDataPart("label", buildMeta.flavorDescription) + .addFormDataPart("branch_name", buildMeta.gitBranchName) + userId?.let { + matrixClientProvider.getOrNull(it)?.let { client -> + val curveKey = client.encryptionService().deviceCurve25519() + val edKey = client.encryptionService().deviceEd25519() + if (curveKey != null && edKey != null) { + builder.addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey") } } + } - bugReportFiles.addAll(gzippedFiles) - - if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) { - serverError = "Couldn't upload any logs, please retry." - return@withContext - } - - if (withScreenshot) { - screenshotHolder.getFileUri() - ?.toUri() - ?.toFile() - ?.let { screenshotFile -> - try { - builder.addFormDataPart( - "file", - screenshotFile.name, - screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) - ) - } catch (e: Exception) { - Timber.e(e, "## sendBugReport() : fail to write screenshot") - } - } - } - - // add some github labels - builder.addFormDataPart("label", buildMeta.versionName) - //builder.addFormDataPart("label", buildMeta.flavorDescription) - builder.addFormDataPart("branch_name", buildMeta.gitBranchName) - - if (crashCallStack.isNotEmpty() && withCrashLogs) { - builder.addFormDataPart("label", "crash") - } - - val requestBody = builder.build() - - // add a progress listener - requestBody.setWriteListener { totalWritten, contentLength -> - val percentage = if (-1L != contentLength) { - if (totalWritten > contentLength) { - 100 - } else { - (totalWritten * 100 / contentLength).toInt() - } - } else { - 0 - } - - if (isCancelled && null != bugReportCall) { - bugReportCall!!.cancel() - } - - Timber.v("## onWrite() : $percentage%") - try { - listener?.onProgress(percentage) - } catch (e: Exception) { - Timber.e(e, "## onProgress() : failed") - } - } - - // build the request - val request = Request.Builder() - .url(bugReporterUrlProvider.provide()) - .post(requestBody) - .build() + // SC additions + val reportTime = SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ", Locale.US).format(Date()) + /* + val enabledDebugSettings = DbgUtil.ALL_PREFS.filter { DbgUtil.isDbgEnabled(it) } + builder.addFormDataPart("enabledDebugSettings", enabledDebugSettings.joinToString().ensureNonEmpty()) + .addFormDataPart("experimentalSettingsEnabled", vectorPreferences.getEnabledExperimentalSettings().joinToString().ensureNonEmpty()) + .addFormDataPart("experimentalSettingsDisabled", vectorPreferences.getDisabledExperimentalSettings().joinToString().ensureNonEmpty()) + */ + builder + .addFormDataPart("reportTime", reportTime) + .addFormDataPart("packageName", buildMeta.applicationId) + .addFormDataPart("is_debug_build", buildMeta.isDebuggable.toString()) + if (buildMeta.isDebuggable) { + builder.addFormDataPart("label", "debug_build") + } + if (canContact) { + builder.addFormDataPart("label", "can contact") + } + builder.addFormDataPart("label", "hs:${userId?.value?.substringAfter(":")}") + builder.addFormDataPart("label", "mxid:${userId?.value}") + builder.addFormDataPart("up_last_push", scAppStateStore.formatLastPushTs()) + builder.addFormDataPart("up_provider", scAppStateStore.lastPushProvider()) + builder.addFormDataPart("up_gateway", scAppStateStore.lastPushGateway()) + builder.addFormDataPart("up_distributor", scAppStateStore.lastPushDistributor().toString()) + val gatewayHost = tryOrNull { URL(scAppStateStore.lastPushGateway()).host } + builder.addFormDataPart("label", "up:gateway:$gatewayHost") + builder.addFormDataPart("label", "up:distributor:${scAppStateStore.lastPushDistributorName()}") + // Device characteristics + context.resources.displayMetrics.apply { + builder.addFormDataPart("device_density", density.toString()) + .addFormDataPart("device_width_px", widthPixels.toString()) + .addFormDataPart("device_height_px", heightPixels.toString()) + .addFormDataPart("device_width_dp", (widthPixels/density).toString()) + .addFormDataPart("device_height_dp", (heightPixels/density).toString()) + } + // Non-default SC settings + val changedScSettings = ScPrefs.scTweaks.prefs.collectScPrefs { + scPreferencesStore.getCachedOrDefaultValue(it) != it.defaultValue + } + val scPrefsString = changedScSettings.joinToString(separator = ",") { "${it.sKey}=${scPreferencesStore.getCachedOrDefaultValue(it)}" } + builder.addFormDataPart("sc_preferences", scPrefsString) - var responseCode = HttpURLConnection.HTTP_INTERNAL_ERROR - var response: Response? = null - var errorMessage: String? = null - // trigger the request + if (crashCallStack.isNotEmpty() && withCrashLogs) { + builder.addFormDataPart("label", "crash") + } + currentTracingFilter?.let { + builder.addFormDataPart("tracing_filter", it) + } + // add the gzipped files, don't cancel the whole upload if only some file failed to upload + var totalUploadedSize = 0L + var uploadedSomeLogs = false + for (file in gzippedFiles) { try { - bugReportCall = okHttpClient.get().newCall(request) - response = bugReportCall!!.execute() - responseCode = response.code + val requestBody = file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) + totalUploadedSize += requestBody.contentLength() + // If we are about to upload more than the max request size, stop here + if (totalUploadedSize > ApplicationConfig.MAX_LOG_UPLOAD_SIZE) { + Timber.e("Could not upload file ${file.name} because it would exceed the max request size") + break + } + builder.addFormDataPart("compressed-log", file.name, requestBody) + uploadedSomeLogs = true } catch (e: CancellationException) { throw e } catch (e: Exception) { - Timber.e(e, "response") - errorMessage = e.localizedMessage + Timber.e(e, "## sendBugReport() : fail to attach file ${file.name}") } - - // if the upload failed, try to retrieve the reason - if (responseCode != HttpURLConnection.HTTP_OK) { - if (null != errorMessage) { - serverError = "Failed with error $errorMessage" - } else if (response?.body == null) { - serverError = "Failed with error $responseCode" + } + bugReportFiles.addAll(gzippedFiles) + if (gzippedFiles.isNotEmpty() && !uploadedSomeLogs) { + serverError = "Couldn't upload any logs, please retry." + return@withContext + } + if (withScreenshot) { + screenshotHolder.getFileUri() + ?.toUri() + ?.toFile() + ?.let { screenshotFile -> + try { + builder.addFormDataPart( + "file", + screenshotFile.name, + screenshotFile.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull()) + ) + } catch (e: Exception) { + Timber.e(e, "## sendBugReport() : fail to write screenshot") + } + } + } + val requestBody = builder.build() + // add a progress listener + requestBody.setWriteListener { totalWritten, contentLength -> + val percentage = if (-1L != contentLength) { + if (totalWritten > contentLength) { + 100 + } else { + (totalWritten * 100 / contentLength).toInt() + } + } else { + 0 + } + Timber.v("## onWrite() : $percentage%") + listener.onProgress(percentage) + } + // build the request + val request = Request.Builder() + .url(bugReporterUrlProvider.provide()) + .post(requestBody) + .build() + var errorMessage: String? = null + // trigger the request + try { + response = okHttpClient.get() + .newCall(request) + .execute() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Error executing the request") + errorMessage = e.localizedMessage + } + val responseCode = response?.code + // if the upload failed, try to retrieve the reason + if (responseCode != HttpURLConnection.HTTP_OK) { + serverError = if (errorMessage != null) { + "Failed with error $errorMessage" + } else { + val responseBody = response?.body + if (responseBody == null) { + "Failed with error $responseCode" } else { try { - val inputStream = response.body!!.byteStream() - serverError = inputStream.use { - buildString { - var ch = it.read() - while (ch != -1) { - append(ch.toChar()) - ch = it.read() - } - } - } - // check if the error message - serverError?.let { - try { - val responseJSON = JSONObject(it) - serverError = responseJSON.getString("error") - } catch (e: CancellationException) { - throw e - } catch (e: JSONException) { - Timber.e(e, "doInBackground ; Json conversion failed") - } + val inputStream = responseBody.byteStream() + val serverErrorJson = inputStream.use { + it.readBytes().toString(Charsets.UTF_8) } - // should never happen - if (null == serverError) { - serverError = "Failed with error $responseCode" + try { + val responseJSON = JSONObject(serverErrorJson) + responseJSON.getString("error") + } catch (e: CancellationException) { + throw e + } catch (e: JSONException) { + Timber.e(e, "Json conversion failed") + "Failed with error $responseCode" } } catch (e: CancellationException) { throw e } catch (e: Exception) { Timber.e(e, "## sendBugReport() : failed to parse error") + "Failed with error $responseCode" } } } } } - withContext(coroutineDispatchers.main) { - bugReportCall = null - if (null != listener) { - try { - if (isCancelled) { - listener.onUploadCancelled() - } else if (null == serverError) { - listener.onUploadSucceed() - } else { - listener.onUploadFailed(serverError) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.e(e, "## onPostExecute() : failed") - } - } + if (serverError == null) { + listener.onUploadSucceed() + } else { + listener.onUploadFailed(serverError) } } finally { // delete the generated files when the bug report process has finished for (file in bugReportFiles) { file.safeDelete() } + response?.close() } } @@ -462,17 +416,17 @@ class DefaultBugReporter @Inject constructor( * @param streamWriter the stream writer */ private fun getLogCatError(streamWriter: OutputStreamWriter) { - val logcatProc: Process + val logcatProcess: Process try { - logcatProc = Runtime.getRuntime().exec(logcatCommandDebug) + logcatProcess = Runtime.getRuntime().exec(logcatCommandDebug) } catch (e1: IOException) { return } try { - val separator = System.getProperty("line.separator") - logcatProc.inputStream + val separator = System.lineSeparator() + logcatProcess.inputStream .reader() .buffered(ApplicationConfig.MAX_LOG_UPLOAD_SIZE.toInt()) .forEachLine { line -> diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt index e0c033afc7..9b07b1c942 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt @@ -136,7 +136,7 @@ class BugReportPresenterTest { initialState.eventSink.invoke(BugReportEvents.ResetAll) val resetState = awaitItem() assertThat(resetState.hasCrashLogs).isFalse() - logFilesRemoverLambda.assertions().isCalledExactly(1) + logFilesRemoverLambda.assertions().isCalledOnce() // TODO Make it live assertThat(resetState.screenshotUri).isNull() } } @@ -144,7 +144,7 @@ class BugReportPresenterTest { @Test fun `present - send success`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Success), + FakeBugReporter(mode = FakeBugReporter.Mode.Success), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -170,7 +170,7 @@ class BugReportPresenterTest { @Test fun `present - send failure`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Failure), + FakeBugReporter(mode = FakeBugReporter.Mode.Failure), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -219,7 +219,7 @@ class BugReportPresenterTest { @Test fun `present - send cancel`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Cancel), + FakeBugReporter(mode = FakeBugReporter.Mode.Cancel), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt index cce2d5d144..922481cedf 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -22,34 +22,40 @@ import io.element.android.libraries.matrix.test.A_FAILURE_REASON import kotlinx.coroutines.delay import java.io.File -class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { +class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter { + enum class Mode { + Success, + Failure, + Cancel + } + override suspend fun sendBugReport( withDevicesLogs: Boolean, withCrashLogs: Boolean, withScreenshot: Boolean, - theBugDescription: String, + problemDescription: String, canContact: Boolean, - listener: BugReporterListener?, + listener: BugReporterListener, ) { delay(100) - listener?.onProgress(0) + listener.onProgress(0) delay(100) - listener?.onProgress(50) + listener.onProgress(50) delay(100) when (mode) { - FakeBugReporterMode.Success -> Unit - FakeBugReporterMode.Failure -> { - listener?.onUploadFailed(A_FAILURE_REASON) + Mode.Success -> Unit + Mode.Failure -> { + listener.onUploadFailed(A_FAILURE_REASON) return } - FakeBugReporterMode.Cancel -> { - listener?.onUploadCancelled() + Mode.Cancel -> { + listener.onUploadCancelled() return } } - listener?.onProgress(100) + listener.onProgress(100) delay(100) - listener?.onUploadSucceed() + listener.onUploadSucceed() } override fun logDirectory(): File { @@ -64,9 +70,3 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes // No op } } - -enum class FakeBugReporterMode { - Success, - Failure, - Cancel -} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index f2c8d0d0a8..ad7f6b76e7 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -20,16 +20,25 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.network.useragent.DefaultUserAgentProvider +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import okhttp3.MultipartReader import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.buffer +import okio.source import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -54,7 +63,7 @@ class DefaultBugReporterTest { withDevicesLogs = true, withCrashLogs = true, withScreenshot = true, - theBugDescription = "a bug occurred", + problemDescription = "a bug occurred", canContact = true, listener = object : BugReporterListener { override fun onUploadCancelled() { @@ -84,6 +93,202 @@ class DefaultBugReporterTest { assertThat(onUploadSucceedCalled).isTrue() } + @Test + fun `test sendBugReport form data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + val progressValues = mutableListOf() + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() {} + + override fun onUploadFailed(reason: String?) {} + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() {} + }, + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + + assertThat(foundValues["app"]).isEqualTo("element-x-android") + assertThat(foundValues["can_contact"]).isEqualTo("true") + assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") + assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") + assertThat(foundValues["user_id"]).isEqualTo("@foo:eample.com") + assertThat(foundValues["text"]).isEqualTo("a bug occurred") + assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY") + + // device_key now added given they are not null + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 1) + + server.shutdown() + } + + @Test + fun `test sendBugReport should not report device_keys if not known`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + assertThat(foundValues["device_keys"]).isNull() + server.shutdown() + } + + @Test + fun `test sendBugReport no client provider no session data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore("I did crash", true), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = InMemorySessionStore(), + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + problemDescription = "a bug occurred", + canContact = true, + listener = NoopBugReporterListener(), + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + println("## FOUND VALUES $foundValues") + assertThat(foundValues["device_keys"]).isNull() + assertThat(foundValues["device_id"]).isEqualTo("undefined") + assertThat(foundValues["user_id"]).isEqualTo("undefined") + assertThat(foundValues["label"]).isEqualTo("crash") + } + + private fun collectValuesFromFormData(request: RecordedRequest): HashMap { + val boundary = request.headers["Content-Type"]!!.split("=").last() + val foundValues = HashMap() + request.body.inputStream().source().buffer().use { + val multipartReader = MultipartReader(it, boundary) + // Just use simple parsing to detect basic properties + val regex = "form-data; name=\"(\\w*)\".*".toRegex() + multipartReader.use { + var part = multipartReader.nextPart() + while (part != null) { + part.headers["Content-Disposition"]?.let { contentDisposition -> + regex.find(contentDisposition)?.groupValues?.get(1)?.let { name -> + foundValues.put(name, part!!.body.readUtf8()) + } + } + part = multipartReader.nextPart() + } + } + } + return foundValues + } + + private fun mockSessionData(userId: String, deviceId: String) = SessionData( + userId = userId, + deviceId = deviceId, + homeserverUrl = "example.com", + accessToken = "AA", + isTokenValid = true, + loginType = LoginType.DIRECT, + loginTimestamp = null, + oidcData = null, + refreshToken = null, + slidingSyncProxy = null, + passphrase = null + ) @Test fun `test sendBugReport error`() = runTest { val server = MockWebServer() @@ -103,7 +308,7 @@ class DefaultBugReporterTest { withDevicesLogs = true, withCrashLogs = true, withScreenshot = true, - theBugDescription = "a bug occurred", + problemDescription = "a bug occurred", canContact = true, listener = object : BugReporterListener { override fun onUploadCancelled() { @@ -150,6 +355,7 @@ class DefaultBugReporterTest { buildMeta = buildMeta, bugReporterUrlProvider = { server.url("/") }, sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider() ) } diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt new file mode 100644 index 0000000000..7a0ec207dd --- /dev/null +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/NoopBugReporterListener.kt @@ -0,0 +1,26 @@ +/* + * 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.rageshake.impl.reporter + +import io.element.android.features.rageshake.api.reporter.BugReporterListener + +class NoopBugReporterListener : BugReporterListener { + override fun onUploadCancelled() = Unit + override fun onUploadFailed(reason: String?) = Unit + override fun onProgress(progress: Int) = Unit + override fun onUploadSucceed() = Unit +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt index d6d497e62a..38f7a01190 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -51,8 +51,8 @@ class RoomAliasResolverNode @AssistedInject constructor( val state = presenter.present() RoomAliasResolverView( state = state, - onAliasResolved = ::onAliasResolved, - onBackPressed = ::navigateUp, + onSuccess = ::onAliasResolved, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt index 722eb86650..933817a37a 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -55,14 +55,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomAliasResolverView( state: RoomAliasResolverState, - onBackPressed: () -> Unit, - onAliasResolved: (ResolvedRoomAlias) -> Unit, + onBackClick: () -> Unit, + onSuccess: (ResolvedRoomAlias) -> Unit, modifier: Modifier = Modifier, ) { - val latestOnAliasResolved by rememberUpdatedState(onAliasResolved) + val latestOnSuccess by rememberUpdatedState(onSuccess) LaunchedEffect(state.resolveState) { if (state.resolveState is AsyncData.Success) { - latestOnAliasResolved(state.resolveState.data) + latestOnSuccess(state.resolveState.data) } } Box( @@ -73,7 +73,7 @@ fun RoomAliasResolverView( containerColor = Color.Transparent, paddingValues = PaddingValues(16.dp), topBar = { - RoomAliasResolverTopBar(onBackClicked = onBackPressed) + RoomAliasResolverTopBar(onBackClick = onBackClick) }, content = { RoomAliasResolverContent(state = state) @@ -148,11 +148,11 @@ private fun RoomAliasResolverContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomAliasResolverTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {}, ) @@ -163,7 +163,7 @@ private fun RoomAliasResolverTopBar( internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview { RoomAliasResolverView( state = state, - onAliasResolved = { }, - onBackPressed = { } + onSuccess = { }, + onBackClick = { } ) } diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt index 990bbfe46a..3dc5052011 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt @@ -47,7 +47,7 @@ class RoomAliasResolverViewTest { aRoomAliasResolverState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -84,14 +84,14 @@ class RoomAliasResolverViewTest { private fun AndroidComposeTestRule.setRoomAliasResolverView( state: RoomAliasResolverState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onAliasResolved: (ResolvedRoomAlias) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { RoomAliasResolverView( state = state, - onBackPressed = onBackPressed, - onAliasResolved = onAliasResolved, + onBackClick = onBackClick, + onSuccess = onAliasResolved, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index 798a903274..9b3cb0f8bc 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -28,6 +28,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.Interaction import io.element.android.anvilannotations.ContributesNode import io.element.android.features.call.CallType import io.element.android.features.call.ui.ElementCallActivity @@ -53,6 +54,8 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -62,6 +65,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( @ApplicationContext private val context: Context, private val pollHistoryEntryPoint: PollHistoryEntryPoint, private val room: MatrixRoom, + private val analyticsService: AnalyticsService, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -142,6 +146,7 @@ class RoomDetailsFlowNode @AssistedInject constructor( sessionId = room.sessionId, roomId = room.roomId, ) + analyticsService.captureInteraction(Interaction.Name.MobileRoomCallButton) ElementCallActivity.start(context, inputs) } } @@ -189,8 +194,8 @@ class RoomDetailsFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenRoom(roomId) } } - override fun onStartCall(roomId: RoomId) { - ElementCallActivity.start(context, CallType.RoomCall(roomId = roomId, sessionId = room.sessionId)) + override fun onStartCall(dmRoomId: RoomId) { + ElementCallActivity.start(context, CallType.RoomCall(sessionId = room.sessionId, roomId = dmRoomId)) } } val plugins = listOf(RoomMemberDetailsNode.RoomMemberDetailsInput(navTarget.roomMemberId), callback) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 83eb19dc37..d55966315e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -124,7 +124,7 @@ class RoomDetailsNode @AssistedInject constructor( lifecycleScope.onShareRoom(context) } - fun onActionClicked(action: RoomDetailsAction) { + fun onActionClick(action: RoomDetailsAction) { when (action) { RoomDetailsAction.Edit -> onEditRoomDetails() RoomDetailsAction.AddTopic -> onEditRoomDetails() @@ -135,7 +135,7 @@ class RoomDetailsNode @AssistedInject constructor( state = state, modifier = modifier, goBack = this::navigateUp, - onActionClicked = ::onActionClicked, + onActionClick = ::onActionClick, onShareRoom = ::onShareRoom, openRoomMemberList = ::openRoomMemberList, openRoomNotificationSettings = ::openRoomNotificationSettings, @@ -143,7 +143,7 @@ class RoomDetailsNode @AssistedInject constructor( openAvatarPreview = ::openAvatarPreview, openPollHistory = ::openPollHistory, openAdminSettings = this::openAdminSettings, - onJoinCallClicked = ::onJoinCall, + onJoinCallClick = ::onJoinCall, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt index 43605eb7b6..aee1694e93 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsState.kt @@ -16,6 +16,7 @@ package io.element.android.features.roomdetails.impl +import androidx.compose.runtime.Immutable import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.userprofile.shared.UserProfileState import io.element.android.libraries.matrix.api.core.RoomAlias @@ -45,11 +46,13 @@ data class RoomDetailsState( val eventSink: (RoomDetailsEvent) -> Unit ) +@Immutable sealed interface RoomDetailsType { data object Room : RoomDetailsType data class Dm(val roomMember: RoomMember) : RoomDetailsType } +@Immutable sealed interface RoomTopicState { data object Hidden : RoomTopicState data object CanAddTopic : RoomTopicState diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 76807c07e6..078e1f6def 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -89,7 +89,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomDetailsView( state: RoomDetailsState, goBack: () -> Unit, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, onShareRoom: () -> Unit, openRoomMemberList: () -> Unit, openRoomNotificationSettings: () -> Unit, @@ -97,7 +97,7 @@ fun RoomDetailsView( openAvatarPreview: (name: String, url: String) -> Unit, openPollHistory: () -> Unit, openAdminSettings: () -> Unit, - onJoinCallClicked: () -> Unit, + onJoinCallClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -106,7 +106,7 @@ fun RoomDetailsView( RoomDetailsTopBar( goBack = goBack, showEdit = state.canEdit, - onActionClicked = onActionClicked + onActionClick = onActionClick ) }, ) { padding -> @@ -135,7 +135,7 @@ fun RoomDetailsView( state = state, onShareRoom = onShareRoom, onInvitePeople = invitePeople, - onCall = onJoinCallClicked, + onCall = onJoinCallClick, ) } @@ -153,7 +153,7 @@ fun RoomDetailsView( state = state, onShareRoom = onShareRoom, onInvitePeople = invitePeople, - onCall = onJoinCallClicked, + onCall = onJoinCallClick, ) } } @@ -162,7 +162,7 @@ fun RoomDetailsView( if (state.roomTopic !is RoomTopicState.Hidden) { TopicSection( roomTopic = state.roomTopic, - onActionClicked = onActionClicked, + onActionClick = onActionClick, ) } @@ -226,7 +226,7 @@ fun RoomDetailsView( @Composable private fun RoomDetailsTopBar( goBack: () -> Unit, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, showEdit: Boolean, ) { var showMenu by remember { mutableStateOf(false) } @@ -249,7 +249,7 @@ private fun RoomDetailsTopBar( // Explicitly close the menu before handling the action, as otherwise it stays open during the // transition and renders really badly. showMenu = false - onActionClicked(RoomDetailsAction.Edit) + onActionClick(RoomDetailsAction.Edit) }, ) } @@ -397,14 +397,17 @@ private fun BadgeList( @Composable private fun TopicSection( roomTopic: RoomTopicState, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, ) { - PreferenceCategory(title = stringResource(CommonStrings.common_topic)) { + PreferenceCategory( + title = stringResource(CommonStrings.common_topic), + showTopDivider = false, + ) { if (roomTopic is RoomTopicState.CanAddTopic) { PreferenceText( title = stringResource(R.string.screen_room_details_add_topic_title), icon = Icons.Outlined.Add, - onClick = { onActionClicked(RoomDetailsAction.AddTopic) }, + onClick = { onActionClick(RoomDetailsAction.AddTopic) }, ) } else if (roomTopic is RoomTopicState.ExistingTopic) { ClickableLinkText( @@ -489,7 +492,7 @@ private fun SecuritySection() { @Composable private fun OtherActionsSection(isDm: Boolean, onLeaveRoom: () -> Unit) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = true) { ListItem( headlineContent = { val leaveText = stringResource( @@ -524,7 +527,7 @@ private fun ContentToPreview(state: RoomDetailsState) { RoomDetailsView( state = state, goBack = {}, - onActionClicked = {}, + onActionClick = {}, onShareRoom = {}, openRoomMemberList = {}, openRoomNotificationSettings = {}, @@ -532,6 +535,6 @@ private fun ContentToPreview(state: RoomDetailsState) { openAvatarPreview = { _, _ -> }, openPollHistory = {}, openAdminSettings = {}, - onJoinCallClicked = {}, + onJoinCallClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt index 34f6be1b7b..95f10e1859 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt @@ -49,8 +49,8 @@ class RoomDetailsEditNode @AssistedInject constructor( val state = presenter.present() RoomDetailsEditView( state = state, - onBackPressed = ::navigateUp, - onRoomEdited = ::navigateUp, + onBackClick = ::navigateUp, + onRoomEditSuccess = ::navigateUp, modifier = modifier, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index e0da0d2f19..a074d1a7b2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -63,14 +63,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomDetailsEditView( state: RoomDetailsEditState, - onBackPressed: () -> Unit, - onRoomEdited: () -> Unit, + onBackClick: () -> Unit, + onRoomEditSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() isAvatarActionsSheetVisible.value = true } @@ -85,7 +85,7 @@ fun RoomDetailsEditView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -114,7 +114,7 @@ fun RoomDetailsEditView( displayName = state.roomRawName, avatarUrl = state.roomAvatarUrl, avatarSize = AvatarSize.EditRoomDetails, - onAvatarClicked = ::onAvatarClicked, + onAvatarClick = ::onAvatarClick, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(60.dp)) @@ -160,7 +160,7 @@ fun RoomDetailsEditView( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, - onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } + onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -170,7 +170,7 @@ fun RoomDetailsEditView( progressText = stringResource(R.string.screen_room_details_updating_room), ) }, - onSuccess = { onRoomEdited() }, + onSuccess = { onRoomEditSuccess() }, errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) } ) @@ -209,7 +209,7 @@ private fun LabelledReadOnlyField( internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview { RoomDetailsEditView( state = state, - onBackPressed = {}, - onRoomEdited = {}, + onBackClick = {}, + onRoomEditSuccess = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt index 34fe6273e7..5de3f9b4a7 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt @@ -65,8 +65,8 @@ class RoomInviteMembersNode @AssistedInject constructor( RoomInviteMembersView( state = state, modifier = modifier, - onBackPressed = { navigateUp() }, - onSubmitPressed = { users -> + onBackClick = { navigateUp() }, + onSubmitClick = { users -> navigateUp() coroutineScope.launch { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index f6e8f07cce..51ab1886f9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -57,22 +57,22 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun RoomInviteMembersView( state: RoomInviteMembersState, - onBackPressed: () -> Unit, - onSubmitPressed: (List) -> Unit, + onBackClick: () -> Unit, + onSubmitClick: (List) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { RoomInviteMembersTopBar( - onBackPressed = { + onBackClick = { if (state.isSearchActive) { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false)) } else { - onBackPressed() + onBackClick() } }, - onSubmitPressed = { onSubmitPressed(state.selectedUsers) }, + onSubmitClick = { onSubmitClick(state.selectedUsers) }, canSend = state.canInvite, ) } @@ -91,9 +91,9 @@ fun RoomInviteMembersView( selectedUsers = state.selectedUsers, state = state.searchResults, active = state.isSearchActive, - onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) }, - onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + onActiveChange = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) }, + onToggleUser = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, ) if (!state.isSearchActive) { @@ -101,7 +101,7 @@ fun RoomInviteMembersView( modifier = Modifier.fillMaxWidth(), selectedUsers = state.selectedUsers, autoScroll = true, - onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + onUserRemove = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, contentPadding = PaddingValues(16.dp), ) } @@ -113,8 +113,8 @@ fun RoomInviteMembersView( @Composable private fun RoomInviteMembersTopBar( canSend: Boolean, - onBackPressed: () -> Unit, - onSubmitPressed: () -> Unit, + onBackClick: () -> Unit, + onSubmitClick: () -> Unit, ) { TopAppBar( title = { @@ -123,11 +123,11 @@ private fun RoomInviteMembersTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_invite), - onClick = onSubmitPressed, + onClick = onSubmitClick, enabled = canSend, ) } @@ -142,17 +142,17 @@ private fun RoomInviteMembersSearchBar( showLoader: Boolean, selectedUsers: ImmutableList, active: Boolean, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserToggled: (MatrixUser) -> Unit, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onToggleUser: (MatrixUser) -> Unit, modifier: Modifier = Modifier, placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), ) { SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, contentPrefix = { @@ -161,7 +161,7 @@ private fun RoomInviteMembersSearchBar( modifier = Modifier.fillMaxWidth(), selectedUsers = selectedUsers, autoScroll = true, - onUserRemoved = onUserToggled, + onUserRemove = onToggleUser, contentPadding = PaddingValues(16.dp), ) } @@ -210,7 +210,7 @@ private fun RoomInviteMembersSearchBar( checked = invitableUser.isSelected, enabled = enabled, data = data, - onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, + onCheckedChange = { onToggleUser(invitableUser.matrixUser) }, modifier = Modifier.fillMaxWidth() ) @@ -228,7 +228,7 @@ private fun RoomInviteMembersSearchBar( internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview { RoomInviteMembersView( state = state, - onBackPressed = {}, - onSubmitPressed = {}, + onBackClick = {}, + onSubmitClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index c111245ee0..488286ce00 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -85,7 +85,7 @@ fun RoomMemberListView( modifier: Modifier = Modifier, initialSelectedSectionIndex: Int = 0, ) { - fun onUserSelected(roomMember: RoomMember) { + fun onSelectUser(roomMember: RoomMember) { state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember)) } @@ -95,8 +95,8 @@ fun RoomMemberListView( if (!state.isSearchActive) { RoomMemberListTopBar( canInvite = state.canInvite, - onBackPressed = navigator::exitRoomMemberList, - onInvitePressed = navigator::openInviteMembers, + onBackClick = navigator::exitRoomMemberList, + onInviteClick = navigator::openInviteMembers, ) } } @@ -119,9 +119,9 @@ fun RoomMemberListView( state = state.searchResults, active = state.isSearchActive, placeHolderTitle = stringResource(CommonStrings.common_search_for_someone), - onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, - onUserSelected = ::onUserSelected, + onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, + onSelectUser = ::onSelectUser, selectedSection = selectedSection, modifier = Modifier.fillMaxWidth(), ) @@ -133,8 +133,8 @@ fun RoomMemberListView( showMembersCount = true, canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers, selectedSection = selectedSection, - onSelectedSectionChanged = { selectedSection = it }, - onUserSelected = ::onUserSelected, + onSelectedSectionChange = { selectedSection = it }, + onSelectUser = ::onSelectUser, ) } } @@ -153,9 +153,9 @@ private fun RoomMemberList( roomMembers: RoomMembers, showMembersCount: Boolean, selectedSection: SelectedSection, - onSelectedSectionChanged: (SelectedSection) -> Unit, + onSelectedSectionChange: (SelectedSection) -> Unit, canDisplayBannedUsersControls: Boolean, - onUserSelected: (RoomMember) -> Unit, + onSelectUser: (RoomMember) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { stickyHeader { @@ -176,7 +176,7 @@ private fun RoomMemberList( index = index, count = segmentedButtonTitles.size, selected = selectedSection.ordinal == index, - onClick = { onSelectedSectionChanged(SelectedSection.entries[index]) }, + onClick = { onSelectedSectionChange(SelectedSection.entries[index]) }, text = title, ) } @@ -197,7 +197,7 @@ private fun RoomMemberList( roomMemberListSection( headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) }, members = roomMembers.invited, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } if (roomMembers.joined.isNotEmpty()) { @@ -211,7 +211,7 @@ private fun RoomMemberList( } }, members = roomMembers.joined, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } } @@ -220,7 +220,7 @@ private fun RoomMemberList( roomMemberListSection( headerText = null, members = roomMembers.banned, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } else { item { @@ -298,8 +298,8 @@ private fun RoomMemberListItem( @Composable private fun RoomMemberListTopBar( canInvite: Boolean, - onBackPressed: () -> Unit, - onInvitePressed: () -> Unit, + onBackClick: () -> Unit, + onInviteClick: () -> Unit, ) { TopAppBar( title = { @@ -308,12 +308,12 @@ private fun RoomMemberListTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { if (canInvite) { TextButton( text = stringResource(CommonStrings.action_invite), - onClick = onInvitePressed, + onClick = onInviteClick, ) } } @@ -327,17 +327,17 @@ private fun RoomMemberSearchBar( state: SearchBarResultState, active: Boolean, placeHolderTitle: String, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserSelected: (RoomMember) -> Unit, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onSelectUser: (RoomMember) -> Unit, selectedSection: SelectedSection, modifier: Modifier = Modifier, ) { SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, resultState = state, @@ -346,10 +346,10 @@ private fun RoomMemberSearchBar( isLoading = false, roomMembers = results, showMembersCount = false, - onUserSelected = { onUserSelected(it) }, + onSelectUser = { onSelectUser(it) }, canDisplayBannedUsersControls = false, selectedSection = selectedSection, - onSelectedSectionChanged = {}, + onSelectedSectionChange = {}, ) }, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index caccbc97be..eebfec967f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -93,7 +93,7 @@ class RoomMemberDetailsNode @AssistedInject constructor( modifier = modifier, goBack = this::navigateUp, onShareUser = ::onShareUser, - onDmStarted = ::onStartDM, + onOpenDm = ::onStartDM, onStartCall = ::onStartCall, openAvatarPreview = callback::openAvatarPreview, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt index 54060b959d..03c602fdd4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt @@ -75,7 +75,7 @@ fun RoomMembersModerationView( RoomMemberActionsBottomSheet( roomMember = state.selectedRoomMember, actions = state.actions, - onActionSelected = { action -> + onSelectAction = { action -> when (action) { is ModerationAction.DisplayProfile -> { onDisplayMemberProfile(action.userId) @@ -126,7 +126,7 @@ fun RoomMembersModerationView( title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title), content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description), submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action), - onSubmitClicked = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } }, + onSubmitClick = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } }, onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) } ) } @@ -161,7 +161,7 @@ fun RoomMembersModerationView( title = stringResource(R.string.screen_room_member_list_manage_member_unban_title), content = stringResource(R.string.screen_room_member_list_manage_member_unban_message), submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action), - onSubmitClicked = { state.eventSink(RoomMembersModerationEvents.UnbanUser) }, + onSubmitClick = { state.eventSink(RoomMembersModerationEvents.UnbanUser) }, onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }, ) } @@ -197,7 +197,7 @@ fun RoomMembersModerationView( private fun RoomMemberActionsBottomSheet( roomMember: RoomMember?, actions: ImmutableList, - onActionSelected: (ModerationAction) -> Unit, + onSelectAction: (ModerationAction) -> Unit, onDismiss: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -260,7 +260,7 @@ private fun RoomMemberActionsBottomSheet( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), onClick = { coroutineScope.launch { - onActionSelected(action) + onSelectAction(action) bottomSheetState.hide() } } @@ -273,7 +273,7 @@ private fun RoomMemberActionsBottomSheet( onClick = { coroutineScope.launch { bottomSheetState.hide() - onActionSelected(action) + onSelectAction(action) } } ) @@ -286,7 +286,7 @@ private fun RoomMemberActionsBottomSheet( onClick = { coroutineScope.launch { bottomSheetState.hide() - onActionSelected(action) + onSelectAction(action) } } ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index 3b65bdf2fd..1b76daf431 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -68,7 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor( state = state, modifier = modifier, onShowGlobalNotifications = this::openGlobalNotificationSettings, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt index 15e450502a..25d9fd929c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Composable fun RoomNotificationSettingsOption( roomNotificationSettingsItem: RoomNotificationSettingsItem, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -51,7 +51,7 @@ fun RoomNotificationSettingsOption( headlineContent = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), - onClick = { onOptionSelected(roomNotificationSettingsItem) }, + onClick = { onSelectOption(roomNotificationSettingsItem) }, ) } @@ -62,7 +62,7 @@ internal fun RoomNotificationSettingsOptionPreview() = ElementPreview { for ((index, item) in roomNotificationSettingsItems().withIndex()) { RoomNotificationSettingsOption( roomNotificationSettingsItem = item, - onOptionSelected = {}, + onSelectOption = {}, isSelected = index == 0, enabled = index != 2, displayMentionsOnlyDisclaimer = index == 1, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt index 37bff2ab88..f84e19b2e9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode fun RoomNotificationSettingsOptions( selected: RoomNotificationMode?, enabled: Boolean, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, ) { @@ -36,7 +36,7 @@ fun RoomNotificationSettingsOptions( RoomNotificationSettingsOption( roomNotificationSettingsItem = item, isSelected = selected == item.mode, - onOptionSelected = onOptionSelected, + onSelectOption = onSelectOption, displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, enabled = enabled ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index ec3d436752..2022c87de0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -51,21 +51,21 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { if (state.showUserDefinedSettingStyle) { UserDefinedRoomNotificationSettingsView( state = state, modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } else { RoomSpecificNotificationSettingsView( state = state, modifier = modifier, onShowGlobalNotifications = onShowGlobalNotifications, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } @@ -74,14 +74,14 @@ fun RoomNotificationSettingsView( private fun RoomSpecificNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { RoomNotificationSettingsTopBar( - onBackPressed = { onBackPressed() } + onBackClick = { onBackClick() } ) } ) { padding -> @@ -136,7 +136,7 @@ private fun RoomSpecificNotificationSettingsView( RoomNotificationSettingsOption( roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle), isSelected = true, - onOptionSelected = { }, + onSelectOption = { }, displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, enabled = true ) @@ -148,7 +148,7 @@ private fun RoomSpecificNotificationSettingsView( selected = state.displayNotificationMode, enabled = !state.displayIsDefault.orTrue(), displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { + onSelectOption = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, ) @@ -175,7 +175,7 @@ private fun RoomSpecificNotificationSettingsView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomNotificationSettingsTopBar( - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( title = { @@ -184,7 +184,7 @@ private fun RoomNotificationSettingsTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } @@ -196,6 +196,6 @@ internal fun RoomNotificationSettingsViewPreview( RoomNotificationSettingsView( state = state, onShowGlobalNotifications = {}, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 925ea23401..81f0c5f9d9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -42,7 +42,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun UserDefinedRoomNotificationSettingsView( state: RoomNotificationSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -50,7 +50,7 @@ fun UserDefinedRoomNotificationSettingsView( topBar = { UserDefinedRoomNotificationSettingsTopBar( roomName = state.roomName, - onBackPressed = { onBackPressed() } + onBackClick = { onBackClick() } ) } ) { padding -> @@ -67,7 +67,7 @@ fun UserDefinedRoomNotificationSettingsView( selected = state.displayNotificationMode, enabled = !state.displayIsDefault.orTrue(), displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { + onSelectOption = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, ) @@ -90,7 +90,7 @@ fun UserDefinedRoomNotificationSettingsView( AsyncActionView( async = state.restoreDefaultAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) }, ) @@ -102,7 +102,7 @@ fun UserDefinedRoomNotificationSettingsView( @Composable private fun UserDefinedRoomNotificationSettingsTopBar( roomName: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( title = { @@ -110,7 +110,7 @@ private fun UserDefinedRoomNotificationSettingsTopBar( text = roomName, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } @@ -121,6 +121,6 @@ internal fun UserDefinedRoomNotificationSettingsViewPreview( ) = ElementPreview { UserDefinedRoomNotificationSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt index af04465c9f..52b77f6ac6 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt @@ -49,14 +49,14 @@ class RolesAndPermissionsNode @AssistedInject constructor( override fun openEditRoomDetailsPermissions() override fun openMessagesAndContentPermissions() override fun openModerationPermissions() - override fun onBackPressed() {} + override fun onBackClick() {} } private val callback = plugins().first() @Stable private val navigator = object : RolesAndPermissionsNavigator by callback { - override fun onBackPressed() { + override fun onBackClick() { navigateUp() } } @@ -88,7 +88,7 @@ class RolesAndPermissionsNode @AssistedInject constructor( } interface RolesAndPermissionsNavigator { - fun onBackPressed() {} + fun onBackClick() {} fun openAdminList() {} fun openModeratorList() {} fun openEditRoomDetailsPermissions() {} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt index 268e7ca478..0732c5c845 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt @@ -61,7 +61,7 @@ fun RolesAndPermissionsView( PreferencePage( modifier = modifier, title = stringResource(R.string.screen_room_roles_and_permissions_title), - onBackPressed = rolesAndPermissionsNavigator::onBackPressed, + onBackClick = rolesAndPermissionsNavigator::onBackClick, ) { ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false) ListItem( @@ -113,7 +113,7 @@ fun RolesAndPermissionsView( content = stringResource(R.string.screen_room_roles_and_permissions_reset_confirm_description), submitText = stringResource(CommonStrings.action_reset), destructiveSubmit = true, - onSubmitClicked = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, + onSubmitClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }, ) }, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt index 450005ae4a..f5b1873717 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt @@ -91,7 +91,7 @@ fun ChangeRolesView( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp) + val latestNavigateUp by rememberUpdatedState(newValue = navigateUp) BackHandler(enabled = !state.isSearchActive) { state.eventSink(ChangeRolesEvent.Exit) } @@ -150,7 +150,7 @@ fun ChangeRolesView( searchResults = members, selectedUsers = state.selectedUsers, canRemoveMember = state.canChangeMemberRole, - onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, selectedUsersList = {}, ) } @@ -166,12 +166,12 @@ fun ChangeRolesView( searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()), selectedUsers = state.selectedUsers, canRemoveMember = state.canChangeMemberRole, - onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, selectedUsersList = { users -> SelectedUsersRowList( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), selectedUsers = users, - onUserRemoved = { + onUserRemove = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) }, canDeselect = { state.canChangeMemberRole(it.userId) }, @@ -188,12 +188,12 @@ fun ChangeRolesView( AsyncActionView( async = state.exitState, - onSuccess = { updatedNavigateUp() }, + onSuccess = { latestNavigateUp() }, confirmationDialog = { ConfirmationDialog( title = stringResource(CommonStrings.dialog_unsaved_changes_title), content = stringResource(CommonStrings.dialog_unsaved_changes_description_android), - onSubmitClicked = { state.eventSink(ChangeRolesEvent.Exit) }, + onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) } ) }, @@ -207,7 +207,7 @@ fun ChangeRolesView( ConfirmationDialog( title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), - onSubmitClicked = { state.eventSink(ChangeRolesEvent.Save) }, + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) } ) } @@ -240,7 +240,7 @@ private fun SearchResultsList( searchResults: MembersByRole, selectedUsers: ImmutableList, canRemoveMember: (UserId) -> Boolean, - onSelectionToggled: (RoomMember) -> Unit, + onToggleSelection: (RoomMember) -> Unit, lazyListState: LazyListState, selectedUsersList: @Composable (ImmutableList) -> Unit, ) { @@ -268,7 +268,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -279,7 +279,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -290,7 +290,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -314,19 +314,19 @@ private fun ListSectionHeader(text: String) { private fun ListMemberItem( roomMember: RoomMember, canRemoveMember: (UserId) -> Boolean, - onSelectionToggled: (RoomMember) -> Unit, + onToggleSelection: (RoomMember) -> Unit, selectedUsers: ImmutableList, ) { val canToggle = canRemoveMember(roomMember.userId) val trailingContent: @Composable (() -> Unit) = { Checkbox( checked = selectedUsers.any { it.userId == roomMember.userId }, - onCheckedChange = { onSelectionToggled(roomMember) }, + onCheckedChange = { onToggleSelection(roomMember) }, enabled = canToggle, ) } MemberRow( - modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }), + modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }), avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem), name = roomMember.getBestName(), userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true }, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt index 18b369a38b..3ab9ca3e5a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt @@ -53,7 +53,7 @@ class ChangeRoomPermissionsNode @AssistedInject constructor( ChangeRoomPermissionsView( modifier = modifier, state = state, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt index c9e09c7c68..f997561218 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ChangeRoomPermissionsView( state: ChangeRoomPermissionsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler { @@ -117,20 +117,20 @@ fun ChangeRoomPermissionsView( AsyncActionView( async = state.saveAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) } ) AsyncActionView( async = state.confirmExitAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, confirmationDialog = { ConfirmationDialog( title = stringResource(R.string.screen_room_change_role_unsaved_changes_title), content = stringResource(R.string.screen_room_change_role_unsaved_changes_description), submitText = stringResource(CommonStrings.action_save), cancelText = stringResource(CommonStrings.action_discard), - onSubmitClicked = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, + onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) } ) }, @@ -193,7 +193,7 @@ internal fun ChangeRoomPermissionsViewPreview(@PreviewParameter(ChangeRoomPermis ElementPreview { ChangeRoomPermissionsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } } diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml index 46f987b994..90f9a983af 100644 --- a/features/roomdetails/impl/src/main/res/values-be/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -35,8 +35,8 @@ "Дадаць тэму" "Ужо ўдзельнік" "Ужо запрасілі" - "Зашыфравана" - "Не зашыфравана" + "Зашыфраваны" + "Не зашыфраваны" "Публічны пакой" "Рэдагаваць пакой" "Адбылася невядомая памылка, і інфармацыю нельга было змяніць." @@ -46,7 +46,7 @@ "Пры загрузцы налад апавяшчэнняў адбылася памылка." "Не атрымалася адключыць гук у гэтым пакоі, паўтарыце спробу." "Не ўдалося ўключыць гук у гэтым пакоі. Паўтарыце спробу." - "Запрасіць карыстальникаў" + "Запрасіць карыстальнікаў" "Пакінуць размову" "Пакінуць пакой" "Уласныя" @@ -66,7 +66,7 @@ "Блакіроўка %1$s" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Выдаліць і заблакіраваць удзельніка" @@ -77,7 +77,7 @@ "Разблакіраваць" "Яны змогуць зноў далучыцца да гэтага пакоя, калі іх запросяць." "Разблакіраваць удзельніка" - "Інфармацыя пра ўдзельніка" + "Прагляд профілю" "Заблакіраваны" "Удзельнікі" "У чаканні" diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index 144b82fd87..b9f3eed338 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -35,6 +35,9 @@ "Ajouter un sujet" "Déjà membre" "Déjà invité(e)" + "Chiffré" + "Non chiffré" + "Salon public" "Modifier le salon" "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." "Impossible de mettre à jour le salon" diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml index 1b0bc8d193..d439cff033 100644 --- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -35,6 +35,9 @@ "Добавить тему" "Уже зарегистрирован" "Уже приглашены" + "Зашифровано" + "Не зашифровано" + "Общественная комната" "Редактировать комнату" "Произошла неизвестная ошибка и информацию не удалось изменить." "Не удалось обновить комнату" diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml index 73f4f945a8..f49cadc64a 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -3,7 +3,30 @@ "Ett fel uppstod vid uppdatering av aviseringsinställningen." "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum." "Omröstningar" + "Endast administratörer" + "Banna personer" + "Ta bort meddelanden" "Alla" + "Bjuda in personer" + "Medlemsmoderering" + "Meddelanden och innehåll" + "Administratörer och moderatorer" + "Ta bort personer" + "Byt rumsavatar" + "Rumsdetaljer" + "Byt rumsnamn" + "Byt rumsämne" + "Skicka meddelanden" + "Redigera administratörer" + "Du kommer inte att kunna ångra den här åtgärden. Du befordrar användaren till att ha samma behörighetsnivå som du." + "Lägg till Admin?" + "Degradera" + "Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier." + "Degradera dig själv?" + "Redigera moderatorer" + "Administratörer" + "Moderatorer" + "Medlemmar" "Lägg till ämne" "Redan medlem" "Redan inbjuden" @@ -21,19 +44,36 @@ "Anpassad" "Förval" "Aviseringar" + "Roller och behörigheter" "Rumsnamn" "Säkerhet" "Dela rum" "Ämne" "Uppdaterar rummet …" + "Banna" + "Denne kommer inte att kunna gå med i det här rummet igen om denne bjuds in." + "Är du säker på att du vill banna den här medlemmen?" + "Bannar %1$s" "%1$d person" "%1$d personer" + "Ta bort från rummet" + "Ta bort och banna medlem" + "Ta bara bort medlem" + "Ta bort medlem och banna från att gå med i framtiden?" + "Avbanna" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Avbanna användare" + "Visa profil" + "Bannade" + "Medlemmar" "Väntar" + "Tar bort %1$s …" "Admin" "Moderator" "Rumsmedlemmar" + "Avbannar %1$s" "Tillåt anpassad inställning" "Om du aktiverar detta åsidosätts din standardinställning" "Meddela mig i den här chatten för" @@ -48,4 +88,13 @@ "Alla meddelanden" "Endast omnämnanden och nyckelord" "I det här rummet, meddela mig för" + "Administratörer" + "Medlemsmoderering" + "Meddelanden och innehåll" + "Moderatorer" + "Behörigheter" + "Återställ behörigheter" + "Roller" + "Rumsdetaljer" + "Roller och behörigheter" diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml index 11769b3176..e2101473cf 100644 --- a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,10 +2,27 @@ "更新通知設定時發生錯誤。" "所有投票" + "僅限管理員" + "移除訊息" "所有人" + "成員管理" + "訊息與內容" + "管理員和版主" + "聊天室資訊" + "傳送訊息" + "編輯管理員" + "編輯版主" + "管理員" + "版主" + "成員" + "您有尚未儲存的變更" + "是否儲存變更?" "新增主題" "已是成員" "已邀請" + "已加密" + "未加密" + "公開的聊天室" "編輯聊天室" "無法更新聊天室" "訊息已加密" @@ -18,15 +35,22 @@ "自訂" "預設" "通知" + "身份與權限" "聊天室名稱" "安全性" "分享聊天室" "主題" "正在更新聊天室…" + "此聊天室沒有黑名單。" "%1$d 位夥伴" + "查看個人檔案" + "黑名單" + "成員" "待定" + "管理員" + "版主" "聊天室成員" "全域設定" "預設" @@ -34,4 +58,18 @@ "無法設定模式,請再試一次。" "所有訊息" "僅限提及與關鍵字" + "管理員" + "變更我的身份" + "降級為普通成員" + "降級為版主" + "成員管理" + "訊息與內容" + "版主" + "權限" + "重設權限" + "重設之後,您會遺失當前的設定。" + "確定要重設權限嗎?" + "身份" + "聊天室資訊" + "身份與權限" diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt index 857422ca51..e3788e4187 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTest.kt @@ -64,7 +64,7 @@ import org.junit.Test import kotlin.time.Duration.Companion.milliseconds @ExperimentalCoroutinesApi -class RoomDetailsPresenterTests { +class RoomDetailsPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt index 4db2d9bb02..13fefa144d 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt @@ -58,7 +58,7 @@ class RoomDetailsEditViewTest { aRoomDetailsEditState( eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -230,14 +230,14 @@ class RoomDetailsEditViewTest { private fun AndroidComposeTestRule.setRoomDetailsEditView( state: RoomDetailsEditState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onRoomEdited: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsEditView( state = state, - onBackPressed = onBackPressed, - onRoomEdited = onRoomEdited, + onBackClick = onBackClick, + onRoomEditSuccess = onRoomEdited, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index febd213e0c..62dbac449c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -120,7 +120,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), canInvite = true, ), - onJoinCallClicked = callback, + onJoinCallClick = callback, ) rule.clickOn(CommonStrings.action_call) } @@ -134,7 +134,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), roomTopic = RoomTopicState.CanAddTopic, ), - onActionClicked = callback, + onActionClick = callback, ) rule.clickOn(R.string.screen_room_details_add_topic_title) } @@ -148,7 +148,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), canEdit = true, ), - onActionClicked = callback, + onActionClick = callback, ) val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) rule.onNodeWithContentDescription(menuContentDescription).performClick() @@ -248,7 +248,7 @@ private fun AndroidComposeTestRule.setRoomD eventSink = EventsRecorder(expectEvents = false), ), goBack: () -> Unit = EnsureNeverCalled(), - onActionClicked: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(), + onActionClick: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(), onShareRoom: () -> Unit = EnsureNeverCalled(), openRoomMemberList: () -> Unit = EnsureNeverCalled(), openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(), @@ -256,13 +256,13 @@ private fun AndroidComposeTestRule.setRoomD openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(), openPollHistory: () -> Unit = EnsureNeverCalled(), openAdminSettings: () -> Unit = EnsureNeverCalled(), - onJoinCallClicked: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsView( state = state, goBack = goBack, - onActionClicked = onActionClicked, + onActionClick = onActionClick, onShareRoom = onShareRoom, openRoomMemberList = openRoomMemberList, openRoomNotificationSettings = openRoomNotificationSettings, @@ -270,7 +270,7 @@ private fun AndroidComposeTestRule.setRoomD openAvatarPreview = openAvatarPreview, openPollHistory = openPollHistory, openAdminSettings = openAdminSettings, - onJoinCallClicked = onJoinCallClicked, + onJoinCallClick = onJoinCallClick, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt index 243b6c0c4d..c6862f3e2e 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTest.kt @@ -46,7 +46,7 @@ import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class RoomMemberListPresenterTests { +class RoomMemberListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt index 89b7335fef..c9df8d381c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTest.kt @@ -46,7 +46,7 @@ import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class RoomMemberDetailsPresenterTests { +class RoomMemberDetailsPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt index e972c37ae4..8909870669 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/moderation/DefaultRoomMembersModerationPresenterTest.kt @@ -42,7 +42,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test -class DefaultRoomMembersModerationPresenterTests { +class DefaultRoomMembersModerationPresenterTest { @Test fun `canDisplayModerationActions - when room is DM is false`() = runTest { val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTest.kt index 23bd504bf8..54e23a3995 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/notificationsettings/RoomNotificationSettingsPresenterTest.kt @@ -33,7 +33,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test -class RoomNotificationSettingsPresenterTests { +class RoomNotificationSettingsPresenterTest { @Test fun `present - initial state is created from room info`() = runTest { val presenter = createRoomNotificationSettingsPresenter() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt index 615d8e38a9..35353f0d1a 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionPresenterTest.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test -class RolesAndPermissionPresenterTests { +class RolesAndPermissionPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createRolesAndPermissionsPresenter() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTest.kt similarity index 98% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTest.kt index be6411b373..fb5f6f8002 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTest.kt @@ -43,7 +43,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -class RolesAndPermissionsViewTests { +class RolesAndPermissionsViewTest { @get:Rule val rule = createAndroidComposeRule() @Test @@ -184,7 +184,7 @@ private fun AndroidComposeTestRule.setRoles RolesAndPermissionsView( state = state, rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator { - override fun onBackPressed() = goBack() + override fun onBackClick() = goBack() override fun openAdminList() = openAdminList() override fun openModeratorList() = openModeratorList() override fun openEditRoomDetailsPermissions() = openPermissionScreens() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt index e8ff232222..d4155d9924 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesPresenterTest.kt @@ -42,7 +42,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test -class ChangeRolesPresenterTests { +class ChangeRolesPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createChangeRolesPresenter() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt index 93cbad4d58..79c1cdb433 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt @@ -297,12 +297,12 @@ class ChangeRolesViewTest { private fun AndroidComposeTestRule.setChangeRolesContent( state: ChangeRolesState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { ChangeRolesView( state = state, - navigateUp = onBackPressed, + navigateUp = onBackClick, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt similarity index 99% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt index bf885388a0..c37d458d67 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsPresenterTest.kt @@ -39,7 +39,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.runTest import org.junit.Test -class ChangeRoomPermissionsPresenterTests { +class ChangeRoomPermissionsPresenterTest { @Test fun `present - initial state`() = runTest { val section = ChangeRoomPermissionsSection.RoomDetails diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTest.kt similarity index 97% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt rename to features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTest.kt index 942b40ff7a..2435a78720 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTest.kt @@ -46,7 +46,7 @@ import org.junit.rules.TestRule import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class ChangeRoomPermissionsViewTests { +class ChangeRoomPermissionsViewTest { @get:Rule val rule = createAndroidComposeRule() @Test @@ -161,7 +161,7 @@ class ChangeRoomPermissionsViewTests { hasChanges = true, saveAction = AsyncAction.Success(Unit), ), - onBackPressed = callback + onBackClick = callback ) rule.clickOn(CommonStrings.action_save) } @@ -190,12 +190,12 @@ private fun AndroidComposeTestRule.setChang section = ChangeRoomPermissionsSection.RoomDetails, eventSink = eventsRecorder, ), - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { ChangeRoomPermissionsView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt index d72a7cbe17..a811717946 100644 --- a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt @@ -30,6 +30,6 @@ interface RoomDirectoryEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onResultClicked(roomDescription: RoomDescription) + fun onResultClick(roomDescription: RoomDescription) } } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt index 694febe571..3b6b4ddf46 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt @@ -35,9 +35,9 @@ class RoomDirectoryNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: RoomDirectoryPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onResultClicked(roomDescription: RoomDescription) { + private fun onResultClick(roomDescription: RoomDescription) { plugins().forEach { - it.onResultClicked(roomDescription) + it.onResultClick(roomDescription) } } @@ -46,8 +46,8 @@ class RoomDirectoryNode @AssistedInject constructor( val state = presenter.present() RoomDirectoryView( state = state, - onResultClicked = ::onResultClicked, - onBackPressed = ::navigateUp, + onResultClick = ::onResultClick, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt index 6cecb61368..838b83e9f6 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -67,19 +67,19 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun RoomDirectoryView( state: RoomDirectoryState, - onResultClicked: (RoomDescription) -> Unit, - onBackPressed: () -> Unit, + onResultClick: (RoomDescription) -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { - RoomDirectoryTopBar(onBackPressed = onBackPressed) + RoomDirectoryTopBar(onBackClick = onBackClick) }, content = { padding -> RoomDirectoryContent( state = state, - onResultClicked = onResultClicked, + onResultClick = onResultClick, modifier = Modifier .padding(padding) .consumeWindowInsets(padding) @@ -91,13 +91,13 @@ fun RoomDirectoryView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDirectoryTopBar( - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( modifier = modifier, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -111,7 +111,7 @@ private fun RoomDirectoryTopBar( @Composable private fun RoomDirectoryContent( state: RoomDirectoryState, - onResultClicked: (RoomDescription) -> Unit, + onResultClick: (RoomDescription) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -125,7 +125,7 @@ private fun RoomDirectoryContent( roomDescriptions = state.roomDescriptions, displayLoadMoreIndicator = state.displayLoadMoreIndicator, displayEmptyState = state.displayEmptyState, - onResultClicked = onResultClicked, + onResultClick = onResultClick, onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, ) } @@ -136,7 +136,7 @@ private fun RoomDirectoryRoomList( roomDescriptions: ImmutableList, displayLoadMoreIndicator: Boolean, displayEmptyState: Boolean, - onResultClicked: (RoomDescription) -> Unit, + onResultClick: (RoomDescription) -> Unit, onReachedLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -145,7 +145,7 @@ private fun RoomDirectoryRoomList( RoomDirectoryRoomRow( roomDescription = roomDescription, onClick = { - onResultClicked(roomDescription) + onResultClick(roomDescription) }, ) } @@ -287,7 +287,7 @@ private fun RoomDirectoryRoomRow( internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { RoomDirectoryView( state = state, - onResultClicked = {}, - onBackPressed = {}, + onResultClick = {}, + onBackClick = {}, ) } diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt index 7fc9e7bb55..c971e84118 100644 --- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt @@ -54,7 +54,7 @@ class RoomDirectoryViewTest { } @Test - fun `clicking on room item then onResultClicked lambda is called once`() { + fun `clicking on room item then onResultClick lambda is called once`() { val eventsRecorder = EventsRecorder() val state = aRoomDirectoryState( roomDescriptions = aRoomDescriptionList(), @@ -64,7 +64,7 @@ class RoomDirectoryViewTest { ensureCalledOnceWithParam(clickedRoom) { callback -> rule.setRoomDirectoryView( state = state, - onResultClicked = callback, + onResultClick = callback, ) rule.onNodeWithText(clickedRoom.computedName).performClick() } @@ -84,14 +84,14 @@ class RoomDirectoryViewTest { private fun AndroidComposeTestRule.setRoomDirectoryView( state: RoomDirectoryState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onResultClicked: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onResultClick: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { RoomDirectoryView( state = state, - onResultClicked = onResultClicked, - onBackPressed = onBackPressed, + onResultClick = onResultClick, + onBackClick = onBackClick, ) } } diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt index f4e2ca7b01..86d3e7cd1a 100644 --- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -30,12 +30,12 @@ interface RoomListEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onRoomClicked(roomId: RoomId) - fun onCreateRoomClicked() - fun onSettingsClicked() - fun onSessionConfirmRecoveryKeyClicked() - fun onRoomSettingsClicked(roomId: RoomId) - fun onReportBugClicked() - fun onRoomDirectorySearchClicked() + fun onRoomClick(roomId: RoomId) + fun onCreateRoomClick() + fun onSettingsClick() + fun onSessionConfirmRecoveryKeyClick() + fun onRoomSettingsClick(roomId: RoomId) + fun onReportBugClick() + fun onRoomDirectorySearchClick() } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt index cc9bc414f3..72aa6ce5ef 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt @@ -47,30 +47,30 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomListContextMenu( contextMenu: RoomListState.ContextMenu.Shown, eventSink: (RoomListEvents.ContextMenuEvents) -> Unit, - onRoomSettingsClicked: (roomId: RoomId) -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, ) { ModalBottomSheet( onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, ) { RoomListModalBottomSheetContent( contextMenu = contextMenu, - onRoomMarkReadClicked = { + onRoomMarkReadClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId)) }, - onRoomMarkUnreadClicked = { + onRoomMarkUnreadClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId)) }, - onRoomSettingsClicked = { + onRoomSettingsClick = { eventSink(RoomListEvents.HideContextMenu) - onRoomSettingsClicked(contextMenu.roomId) + onRoomSettingsClick(contextMenu.roomId) }, - onLeaveRoomClicked = { + onLeaveRoomClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) }, - onFavoriteChanged = { isFavorite -> + onFavoriteChange = { isFavorite -> eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) }, ) @@ -80,11 +80,11 @@ fun RoomListContextMenu( @Composable private fun RoomListModalBottomSheetContent( contextMenu: RoomListState.ContextMenu.Shown, - onRoomSettingsClicked: () -> Unit, - onLeaveRoomClicked: () -> Unit, - onFavoriteChanged: (isFavorite: Boolean) -> Unit, - onRoomMarkReadClicked: () -> Unit, - onRoomMarkUnreadClicked: () -> Unit, + onRoomSettingsClick: () -> Unit, + onLeaveRoomClick: () -> Unit, + onFavoriteChange: (isFavorite: Boolean) -> Unit, + onRoomMarkReadClick: () -> Unit, + onRoomMarkUnreadClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() @@ -114,9 +114,9 @@ private fun RoomListModalBottomSheetContent( }, modifier = Modifier.clickable { if (contextMenu.hasNewContent) { - onRoomMarkReadClicked() + onRoomMarkReadClick() } else { - onRoomMarkUnreadClicked() + onRoomMarkUnreadClick() } }, leadingContent = ListItemContent.Icon(iconSource = IconSource.Vector(if (contextMenu.hasNewContent) Icons.Default.RemoveRedEye else Icons.Default.NewReleases)), @@ -147,11 +147,11 @@ private fun RoomListModalBottomSheetContent( trailingContent = ListItemContent.Switch( checked = contextMenu.isFavorite, onChange = { isFavorite -> - onFavoriteChanged(isFavorite) + onFavoriteChange(isFavorite) }, ), onClick = { - onFavoriteChanged(!contextMenu.isFavorite) + onFavoriteChange(!contextMenu.isFavorite) }, style = ListItemStyle.Primary, ) @@ -162,7 +162,7 @@ private fun RoomListModalBottomSheetContent( style = MaterialTheme.typography.bodyLarge, ) }, - modifier = Modifier.clickable { onRoomSettingsClicked() }, + modifier = Modifier.clickable { onRoomSettingsClick() }, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector( CompoundIcons.Settings(), @@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent( ) Text(text = leaveText) }, - modifier = Modifier.clickable { onLeaveRoomClicked() }, + modifier = Modifier.clickable { onLeaveRoomClick() }, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector( CompoundIcons.Leave(), @@ -204,10 +204,10 @@ internal fun RoomListModalBottomSheetContentPreview( ) = ElementPreview { RoomListModalBottomSheetContent( contextMenu = contextMenu, - onRoomMarkReadClicked = {}, - onRoomMarkUnreadClicked = {}, - onRoomSettingsClicked = {}, - onLeaveRoomClicked = {}, - onFavoriteChanged = {}, + onRoomMarkReadClick = {}, + onRoomMarkUnreadClick = {}, + onRoomSettingsClick = {}, + onLeaveRoomClick = {}, + onFavoriteChange = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index bfd1cadeb0..5a8ef7c6a5 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -54,27 +54,27 @@ class RoomListNode @AssistedInject constructor( ) } - private fun onRoomClicked(roomId: RoomId) { - plugins().forEach { it.onRoomClicked(roomId) } + private fun onRoomClick(roomId: RoomId) { + plugins().forEach { it.onRoomClick(roomId) } } private fun onOpenSettings() { - plugins().forEach { it.onSettingsClicked() } + plugins().forEach { it.onSettingsClick() } } - private fun onCreateRoomClicked() { - plugins().forEach { it.onCreateRoomClicked() } + private fun onCreateRoomClick() { + plugins().forEach { it.onCreateRoomClick() } } - private fun onSessionConfirmRecoveryKeyClicked() { - plugins().forEach { it.onSessionConfirmRecoveryKeyClicked() } + private fun onSessionConfirmRecoveryKeyClick() { + plugins().forEach { it.onSessionConfirmRecoveryKeyClick() } } - private fun onRoomSettingsClicked(roomId: RoomId) { - plugins().forEach { it.onRoomSettingsClicked(roomId) } + private fun onRoomSettingsClick(roomId: RoomId) { + plugins().forEach { it.onRoomSettingsClick(roomId) } } - private fun onMenuActionClicked(activity: Activity, roomListMenuAction: RoomListMenuAction) { + private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) { when (roomListMenuAction) { RoomListMenuAction.Settings -> { onOpenSettings() @@ -83,13 +83,13 @@ class RoomListNode @AssistedInject constructor( inviteFriendsUseCase.execute(activity) } RoomListMenuAction.ReportBug -> { - plugins().forEach { it.onReportBugClicked() } + plugins().forEach { it.onReportBugClick() } } } } - private fun onRoomDirectorySearchClicked() { - plugins().forEach { it.onRoomDirectorySearchClicked() } + private fun onRoomDirectorySearchClick() { + plugins().forEach { it.onRoomDirectorySearchClick() } } @Composable @@ -98,19 +98,19 @@ class RoomListNode @AssistedInject constructor( val activity = LocalContext.current as Activity RoomListView( state = state, - onRoomClicked = this::onRoomClicked, - onSettingsClicked = this::onOpenSettings, - onCreateRoomClicked = this::onCreateRoomClicked, - onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked, - onRoomSettingsClicked = this::onRoomSettingsClicked, - onMenuActionClicked = { onMenuActionClicked(activity, it) }, - onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked, + onRoomClick = this::onRoomClick, + onSettingsClick = this::onOpenSettings, + onCreateRoomClick = this::onCreateRoomClick, + onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick, + onRoomSettingsClick = this::onRoomSettingsClick, + onMenuActionClick = { onMenuActionClick(activity, it) }, + onRoomDirectorySearchClick = this::onRoomDirectorySearchClick, modifier = modifier, ) { acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, - onInviteAccepted = this::onRoomClicked, - onInviteDeclined = { }, + onAcceptInvite = this::onRoomClick, + onDeclineInvite = { }, modifier = Modifier ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index efdf98a376..e9b0aa642c 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -54,13 +53,13 @@ import io.element.android.libraries.matrix.api.core.RoomId @Composable fun RoomListView( state: RoomListState, - onRoomClicked: (RoomId) -> Unit, - onSettingsClicked: () -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onCreateRoomClicked: () -> Unit, - onRoomSettingsClicked: (roomId: RoomId) -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onSettingsClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onCreateRoomClick: () -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + onRoomDirectorySearchClick: () -> Unit, modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { @@ -73,7 +72,7 @@ fun RoomListView( RoomListContextMenu( contextMenu = state.contextMenu, eventSink = state.eventSink, - onRoomSettingsClicked = onRoomSettingsClicked, + onRoomSettingsClick = onRoomSettingsClick, ) } @@ -81,19 +80,19 @@ fun RoomListView( RoomListScaffold( state = state, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, - onOpenSettings = onSettingsClicked, - onCreateRoomClicked = onCreateRoomClicked, - onMenuActionClicked = onMenuActionClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, + onOpenSettings = onSettingsClick, + onCreateRoomClick = onCreateRoomClick, + onMenuActionClick = onMenuActionClick, modifier = Modifier.padding(top = topPadding), ) // This overlaid view will only be visible when state.displaySearchResults is true RoomListSearchView( state = state.searchState, eventSink = state.eventSink, - onRoomClicked = onRoomClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, modifier = Modifier .statusBarsPadding() .padding(top = topPadding) @@ -109,15 +108,15 @@ fun RoomListView( @Composable private fun RoomListScaffold( state: RoomListState, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomId) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomId) -> Unit, onOpenSettings: () -> Unit, - onCreateRoomClicked: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onCreateRoomClick: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, modifier: Modifier = Modifier, ) { - fun onRoomClicked(room: RoomListRoomSummary) { - onRoomClicked(room.roomId) + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) } val appBarState = rememberTopAppBarState() @@ -136,10 +135,10 @@ private fun RoomListScaffold( areSearchResultsDisplayed = state.searchState.isSearchActive, // SC start selectedSpaceName = state.resolveSpaceName(), - onCreateRoomClicked = onCreateRoomClicked, + onCreateRoomClick = onCreateRoomClick, // SC end onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, - onMenuActionClicked = onMenuActionClicked, + onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, scrollBehavior = scrollBehavior, displayMenuItems = state.displayActions, @@ -152,9 +151,9 @@ private fun RoomListScaffold( contentState = state.contentState, filtersState = state.filtersState, eventSink = state.eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = ::onRoomClicked, - onCreateRoomClicked = onCreateRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = ::onRoomClick, + onCreateRoomClick = onCreateRoomClick, modifier = Modifier // SC: go to edge for migration screen .thenIf(state.contentState !is RoomListContentState.Migration) { this @@ -168,7 +167,7 @@ private fun RoomListScaffold( FloatingActionButton( // FIXME align on Design system theme containerColor = MaterialTheme.colorScheme.primary, - onClick = onCreateRoomClicked + onClick = onCreateRoomClick ) { Icon( // Note cannot use Icons.Outlined.EditSquare, it does not exist :/ @@ -189,13 +188,13 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) = ElementPreview { RoomListView( state = state, - onRoomClicked = {}, - onSettingsClicked = {}, - onConfirmRecoveryKeyClicked = {}, - onCreateRoomClicked = {}, - onRoomSettingsClicked = {}, - onMenuActionClicked = {}, - onRoomDirectorySearchClicked = {}, + onRoomClick = {}, + onSettingsClick = {}, + onConfirmRecoveryKeyClick = {}, + onCreateRoomClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onRoomDirectorySearchClick = {}, acceptDeclineInviteView = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt index 71f9c78530..1e987c2a27 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt @@ -26,16 +26,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable internal fun ConfirmRecoveryKeyBanner( - onContinueClicked: () -> Unit, - onDismissClicked: () -> Unit, + onContinueClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { DialogLikeBannerMolecule( modifier = modifier, title = stringResource(R.string.confirm_recovery_key_banner_title), content = stringResource(R.string.confirm_recovery_key_banner_message), - onSubmitClicked = onContinueClicked, - onDismissClicked = onDismissClicked, + onSubmitClick = onContinueClick, + onDismissClick = onDismissClick, ) } @@ -43,7 +43,7 @@ internal fun ConfirmRecoveryKeyBanner( @Composable internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview { ConfirmRecoveryKeyBanner( - onContinueClicked = {}, - onDismissClicked = {}, + onContinueClick = {}, + onDismissClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt index 2d6bc6a39c..2b2ed026d2 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt @@ -78,9 +78,9 @@ fun RoomListContentView( contentState: RoomListContentState, filtersState: RoomListFiltersState, eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, - onCreateRoomClicked: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, + onCreateRoomClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -95,7 +95,7 @@ fun RoomListContentView( } is RoomListContentState.Empty -> { EmptyView( - onCreateRoomClicked = onCreateRoomClicked, + onCreateRoomClick = onCreateRoomClick, ) } is RoomListContentState.Rooms -> { @@ -103,8 +103,8 @@ fun RoomListContentView( state = contentState, filtersState = filtersState, eventSink = eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, ) } } @@ -127,7 +127,7 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) { @Composable private fun EmptyView( - onCreateRoomClicked: () -> Unit, + onCreateRoomClick: () -> Unit, modifier: Modifier = Modifier ) { EmptyScaffold( @@ -137,7 +137,7 @@ private fun EmptyView( Button( text = stringResource(CommonStrings.action_start_chat), leadingIcon = IconSource.Vector(CompoundIcons.Compose()), - onClick = onCreateRoomClicked, + onClick = onCreateRoomClick, ) }, modifier = modifier.fillMaxSize(), @@ -149,8 +149,8 @@ private fun RoomsView( state: RoomListContentState.Rooms, filtersState: RoomListFiltersState, eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { if (state.summaries.isEmpty() && (filtersState.hasAnyFilterSelected && state.spacesList.isEmpty())) { @@ -163,8 +163,8 @@ private fun RoomsView( state = state, filtersState = filtersState, // SC eventSink = eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, modifier = modifier.fillMaxSize(), ) } @@ -175,8 +175,8 @@ private fun RoomsViewList( state: RoomListContentState.Rooms, filtersState: RoomListFiltersState, // SC eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { val withSpaceFilter = isSpaceFilterActive(state.spaceSelectionHierarchy) @@ -219,8 +219,8 @@ private fun RoomsViewList( SecurityBannerState.RecoveryKeyConfirmation -> { item { ConfirmRecoveryKeyBanner( - onContinueClicked = onConfirmRecoveryKeyClicked, - onDismissClicked = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } ) } } @@ -236,7 +236,7 @@ private fun RoomsViewList( if (ScPrefs.SC_OVERVIEW_LAYOUT.value()) { ScRoomSummaryRow( room = room, - onClick = onRoomClicked, + onClick = onRoomClick, eventSink = eventSink, isLastIndex = index == state.summaries.lastIndex, ) @@ -244,7 +244,7 @@ private fun RoomsViewList( } RoomSummaryRow( room = room, - onClick = onRoomClicked, + onClick = onRoomClick, eventSink = eventSink, ) if (index != state.summaries.lastIndex) { @@ -314,8 +314,8 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) } ), eventSink = {}, - onConfirmRecoveryKeyClicked = {}, - onRoomClicked = {}, - onCreateRoomClicked = {}, + onConfirmRecoveryKeyClick = {}, + onRoomClick = {}, + onCreateRoomClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index 798a1fb4bb..b49efcda9f 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -95,10 +95,10 @@ fun RoomListTopBar( areSearchResultsDisplayed: Boolean, // SC start selectedSpaceName: String?, - onCreateRoomClicked: () -> Unit, + onCreateRoomClick: () -> Unit, // SC end onToggleSearch: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, displayMenuItems: Boolean, @@ -112,11 +112,11 @@ fun RoomListTopBar( areSearchResultsDisplayed = areSearchResultsDisplayed, // SC start selectedSpaceName = selectedSpaceName, - onCreateRoomClicked = onCreateRoomClicked, + onCreateRoomClick = onCreateRoomClick, // SC end onOpenSettings = onOpenSettings, - onSearchClicked = onToggleSearch, - onMenuActionClicked = onMenuActionClicked, + onSearchClick = onToggleSearch, + onMenuActionClick = onMenuActionClick, scrollBehavior = scrollBehavior, displayMenuItems = displayMenuItems, displayFilters = displayFilters, @@ -133,12 +133,12 @@ private fun DefaultRoomListTopBar( areSearchResultsDisplayed: Boolean, // SC start selectedSpaceName: String? = null, - onCreateRoomClicked: () -> Unit = {}, + onCreateRoomClick: () -> Unit = {}, // SC end scrollBehavior: TopAppBarScrollBehavior, onOpenSettings: () -> Unit, - onSearchClicked: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onSearchClick: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, displayMenuItems: Boolean, displayFilters: Boolean, filtersState: RoomListFiltersState, @@ -234,7 +234,7 @@ private fun DefaultRoomListTopBar( actions = { if (displayMenuItems) { IconButton( - onClick = onSearchClicked, + onClick = onSearchClick, ) { Icon( imageVector = CompoundIcons.Search(), @@ -255,12 +255,12 @@ private fun DefaultRoomListTopBar( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - ScRoomListDropdownEntriesTop(onClick = { showMenu = false }, onMenuActionClicked = onMenuActionClicked, onCreateRoomClicked = onCreateRoomClicked) + ScRoomListDropdownEntriesTop(onClick = { showMenu = false }, onMenuActionClick = onMenuActionClick, onCreateRoomClick = onCreateRoomClick) if (RoomListConfig.SHOW_INVITE_MENU_ITEM) { DropdownMenuItem( onClick = { showMenu = false - onMenuActionClicked(RoomListMenuAction.InviteFriends) + onMenuActionClick(RoomListMenuAction.InviteFriends) }, text = { Text(stringResource(id = CommonStrings.action_invite)) }, leadingIcon = { @@ -276,7 +276,7 @@ private fun DefaultRoomListTopBar( DropdownMenuItem( onClick = { showMenu = false - onMenuActionClicked(RoomListMenuAction.ReportBug) + onMenuActionClick(RoomListMenuAction.ReportBug) }, text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, leadingIcon = { @@ -288,7 +288,7 @@ private fun DefaultRoomListTopBar( } ) } - ScRoomListDropdownEntriesBottom(onClick = { showMenu = false }, onMenuActionClicked = onMenuActionClicked) + ScRoomListDropdownEntriesBottom(onClick = { showMenu = false }, onMenuActionClick = onMenuActionClick) } } } @@ -349,11 +349,11 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview { areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, - onSearchClicked = {}, + onSearchClick = {}, displayMenuItems = true, displayFilters = true, filtersState = aRoomListFiltersState(), - onMenuActionClicked = {}, + onMenuActionClick = {}, ) } @@ -367,10 +367,10 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, - onSearchClicked = {}, + onSearchClick = {}, displayMenuItems = true, displayFilters = true, filtersState = aRoomListFiltersState(), - onMenuActionClicked = {}, + onMenuActionClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBarScExtensions.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBarScExtensions.kt index e540df2b7d..2ad655ea16 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBarScExtensions.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBarScExtensions.kt @@ -18,14 +18,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ScRoomListDropdownEntriesTop( onClick: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, - onCreateRoomClicked: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + onCreateRoomClick: () -> Unit, ) { if (ScPrefs.SPACE_NAV.value()) { DropdownMenuItem( onClick = { onClick() - onCreateRoomClicked() + onCreateRoomClick() }, text = { Text(stringResource(id = io.element.android.libraries.ui.strings.R.string.action_start_chat)) }, leadingIcon = { @@ -40,7 +40,7 @@ fun ScRoomListDropdownEntriesTop( DropdownMenuItem( onClick = { onClick() - onMenuActionClicked(RoomListMenuAction.Settings) + onMenuActionClick(RoomListMenuAction.Settings) }, text = { Text(stringResource(id = CommonStrings.common_settings)) }, leadingIcon = { @@ -57,7 +57,7 @@ fun ScRoomListDropdownEntriesTop( @Composable fun ScRoomListDropdownEntriesBottom( onClick: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, ) { if (ScPrefs.SC_DEV_QUICK_OPTIONS.value()) { HorizontalDivider() diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt index a14140297c..d90faef985 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -105,10 +105,10 @@ internal fun RoomSummaryRow( } Spacer(modifier = Modifier.height(12.dp)) InviteButtonsRow( - onAcceptClicked = { + onAcceptClick = { eventSink(RoomListEvents.AcceptInvite(room)) }, - onDeclineClicked = { + onDeclineClick = { eventSink(RoomListEvents.DeclineInvite(room)) } ) @@ -299,8 +299,8 @@ private fun InviteNameAndIndicatorRow( @Composable private fun InviteButtonsRow( - onAcceptClicked: () -> Unit, - onDeclineClicked: () -> Unit, + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, modifier: Modifier = Modifier ) { Row( @@ -309,13 +309,13 @@ private fun InviteButtonsRow( ) { OutlinedButton( text = stringResource(CommonStrings.action_decline), - onClick = onDeclineClicked, + onClick = onDeclineClick, size = ButtonSize.Medium, modifier = Modifier.weight(1f), ) Button( text = stringResource(CommonStrings.action_accept), - onClick = onAcceptClicked, + onClick = onAcceptClick, size = ButtonSize.Medium, modifier = Modifier.weight(1f), ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt index fcdc260c6e..545884c875 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt @@ -16,6 +16,9 @@ package io.element.android.features.roomlist.impl.filters +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,13 +34,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview @@ -53,7 +58,7 @@ fun RoomListFiltersView( state: RoomListFiltersState, modifier: Modifier = Modifier ) { - fun onClearFiltersClicked() { + fun onClearFiltersClick() { state.eventSink(RoomListFiltersEvents.ClearSelectedFilters) } @@ -62,6 +67,7 @@ fun RoomListFiltersView( } val lazyListState = rememberLazyListState() + val previousFilters = remember { mutableStateOf(listOf()) } LazyRow( contentPadding = PaddingValues(start = 8.dp, end = 16.dp), modifier = modifier.fillMaxWidth(), @@ -75,28 +81,30 @@ fun RoomListFiltersView( modifier = Modifier .padding(start = 8.dp) .testTag(TestTags.homeScreenClearFilters), - onClick = ::onClearFiltersClicked + onClick = { + previousFilters.value = state.selectedFilters() + onClearFiltersClick() + } ) } } - for (filterWithSelection in state.filterSelectionStates) { + state.filterSelectionStates.forEachIndexed { i, filterWithSelection -> item(filterWithSelection.filter) { + val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat() RoomListFilterView( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier + .animateItemPlacement() + .zIndex(zIndex), roomListFilter = filterWithSelection.filter, selected = filterWithSelection.isSelected, - onClick = ::onToggleFilter, + onClick = { + previousFilters.value = state.selectedFilters() + onToggleFilter(it) + }, ) } } } - LaunchedEffect(state.filterSelectionStates) { - // Checking for canScrollBackward is necessary for the itemPlacementAnimation to work correctly. - // We don't want the itemPlacementAnimation to be triggered when clearing the filters. - if (!state.hasAnyFilterSelected || lazyListState.canScrollBackward) { - lazyListState.animateScrollToItem(0) - } - } } @Composable @@ -126,16 +134,27 @@ private fun RoomListFilterView( onClick: (RoomListFilter) -> Unit, modifier: Modifier = Modifier ) { + val background = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.bgActionPrimaryRest else ElementTheme.colors.bgCanvasDefault, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip background colour", + ) + val textColour = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.textOnSolidPrimary else ElementTheme.colors.textPrimary, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip text colour", + ) + FilterChip( selected = selected, onClick = { onClick(roomListFilter) }, modifier = modifier.height(36.dp), shape = CircleShape, colors = FilterChipDefaults.filterChipColors( - containerColor = ElementTheme.colors.bgCanvasDefault, - selectedContainerColor = ElementTheme.colors.bgActionPrimaryRest, - labelColor = ElementTheme.colors.textPrimary, - selectedLabelColor = ElementTheme.colors.textOnSolidPrimary, + containerColor = background.value, + selectedContainerColor = background.value, + labelColor = textColour.value, + selectedLabelColor = textColour.value ), label = { Text(text = stringResource(id = roomListFilter.stringResource)) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPrefsMigrationScreenStore.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPreferencesMigrationScreenStore.kt similarity index 91% rename from features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPrefsMigrationScreenStore.kt rename to features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPreferencesMigrationScreenStore.kt index 62121fd282..344c84b6a6 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPrefsMigrationScreenStore.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/migration/SharedPreferencesMigrationScreenStore.kt @@ -22,13 +22,12 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.roomlist.api.migration.MigrationScreenStore import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.matrix.api.core.SessionId import javax.inject.Inject @ContributesBinding(AppScope::class) -class SharedPrefsMigrationScreenStore @Inject constructor( - @DefaultPreferences private val sharedPreferences: SharedPreferences, +class SharedPreferencesMigrationScreenStore @Inject constructor( + private val sharedPreferences: SharedPreferences, ) : MigrationScreenStore { override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean { return sharedPreferences.getBoolean(sessionId.toKey(), false).not() diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt index a18dd6607f..b8af9e16f1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt @@ -72,8 +72,8 @@ import io.element.android.libraries.ui.strings.CommonStrings internal fun RoomListSearchView( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit, - onRoomClicked: (RoomId) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onRoomDirectorySearchClick: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(enabled = state.isSearchActive) { @@ -87,17 +87,20 @@ internal fun RoomListSearchView( ) { Column( modifier = modifier - .applyIf(state.isSearchActive, ifTrue = { - // Disable input interaction to underlying views - pointerInput(Unit) {} - }) + .applyIf( + condition = state.isSearchActive, + ifTrue = { + // Disable input interaction to underlying views + pointerInput(Unit) {} + } + ) ) { if (state.isSearchActive) { RoomListSearchContent( state = state, - onRoomClicked = onRoomClicked, + onRoomClick = onRoomClick, eventSink = eventSink, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, ) } } @@ -109,17 +112,17 @@ internal fun RoomListSearchView( private fun RoomListSearchContent( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit, - onRoomClicked: (RoomId) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onRoomDirectorySearchClick: () -> Unit, ) { val borderColor = MaterialTheme.colorScheme.tertiary val strokeWidth = 1.dp - fun onBackButtonPressed() { + fun onBackButtonClick() { state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) } - fun onRoomClicked(room: RoomListRoomSummary) { - onRoomClicked(room.roomId) + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) } Scaffold( topBar = { @@ -132,7 +135,7 @@ private fun RoomListSearchContent( strokeWidth = strokeWidth.value ) }, - navigationIcon = { BackButton(onClick = ::onBackButtonPressed) }, + navigationIcon = { BackButton(onClick = ::onBackButtonClick) }, title = { val filter = state.query val focusRequester = FocusRequester() @@ -186,7 +189,7 @@ private fun RoomListSearchContent( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp, horizontal = 16.dp), - onClick = onRoomDirectorySearchClicked + onClick = onRoomDirectorySearchClick ) } LazyColumn( @@ -198,7 +201,7 @@ private fun RoomListSearchContent( ) { room -> RoomSummaryRow( room = room, - onClick = ::onRoomClicked, + onClick = ::onRoomClick, eventSink = eventSink, ) } @@ -237,8 +240,8 @@ private fun RoomDirectorySearchButton( internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { RoomListSearchContent( state = state, - onRoomClicked = {}, + onRoomClick = {}, eventSink = {}, - onRoomDirectorySearchClicked = {}, + onRoomDirectorySearchClick = {}, ) } diff --git a/features/roomlist/impl/src/main/res/values-be/translations.xml b/features/roomlist/impl/src/main/res/values-be/translations.xml index ac54eb1d84..603ac6c3f3 100644 --- a/features/roomlist/impl/src/main/res/values-be/translations.xml +++ b/features/roomlist/impl/src/main/res/values-be/translations.xml @@ -7,7 +7,7 @@ "Вы ўпэўненыя, што хочаце адмовіцца ад прыватных зносін з %1$s?" "Адхіліць чат" "Няма запрашэнняў" - "%1$s (%2$s) запрасіў вас" + "%1$s (%2$s) запрасіў(-ла) вас" "Гэта аднаразовы працэс, дзякуем за чаканне." "Налада ўліковага запісу." "Стварыце новую размову або пакой" diff --git a/features/roomlist/impl/src/main/res/values-sv/translations.xml b/features/roomlist/impl/src/main/res/values-sv/translations.xml index cc483e81da..9b6927127f 100644 --- a/features/roomlist/impl/src/main/res/values-sv/translations.xml +++ b/features/roomlist/impl/src/main/res/values-sv/translations.xml @@ -14,10 +14,16 @@ "Kom igång genom att skicka meddelanden till någon." "Inga chattar än." "Favoriter" + "Du har inga favoritchattar än" "Låg prioritet" + "Du har inga chattar för det här valet" "Personer" + "Du har inga DM:er än" "Rum" + "Du är inte i något rum än" "Olästa" + "Grattis! +Du har inga olästa meddelanden!" "Alla chattar" "Markera som läst" "Markera som oläst" diff --git a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml index 8d0ec972f7..6d01b67889 100644 --- a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,10 +7,13 @@ "正在設定您的帳號。" "建立新的對話或聊天室" "我的最愛" + "邀請" "夥伴" "聊天室" "未讀" "所有聊天室" + "標為已讀" + "標為未讀" "您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。" "驗證這是您本人" diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt index a1c98c6874..d9ebec849d 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt @@ -41,7 +41,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(R.string.screen_roomlist_mark_as_read) @@ -61,7 +61,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(R.string.screen_roomlist_mark_as_unread) @@ -81,7 +81,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(CommonStrings.action_leave_conversation) @@ -101,7 +101,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(CommonStrings.action_leave_room) @@ -122,7 +122,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) } rule.clickOn(CommonStrings.common_settings) @@ -139,7 +139,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) } rule.clickOn(CommonStrings.common_favourite) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt similarity index 99% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt rename to features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt index e682e6608d..e72a398016 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTest.kt @@ -92,7 +92,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class RoomListPresenterTests { +class RoomListPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt index 7507ab466b..99045cd783 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt @@ -67,7 +67,7 @@ class RoomListViewTest { contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), eventSink = eventsRecorder, ), - onConfirmRecoveryKeyClicked = callback, + onConfirmRecoveryKeyClick = callback, ) rule.clickOn(CommonStrings.action_continue) } @@ -82,7 +82,7 @@ class RoomListViewTest { eventSink = eventsRecorder, contentState = anEmptyContentState(), ), - onCreateRoomClicked = callback, + onCreateRoomClick = callback, ) rule.clickOn(CommonStrings.action_start_chat) } @@ -100,7 +100,7 @@ class RoomListViewTest { ensureCalledOnceWithParam(room0.roomId) { callback -> rule.setRoomListView( state = state, - onRoomClicked = callback, + onRoomClick = callback, ) rule.onNodeWithText(room0.lastMessage!!.toString()).performClick() } @@ -133,7 +133,7 @@ class RoomListViewTest { ensureCalledOnceWithParam(room0) { callback -> rule.setRoomListView( state = state, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) rule.clickOn(CommonStrings.common_settings) } @@ -160,24 +160,24 @@ class RoomListViewTest { private fun AndroidComposeTestRule.setRoomListView( state: RoomListState, - onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onSettingsClicked: () -> Unit = EnsureNeverCalled(), - onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), - onCreateRoomClicked: () -> Unit = EnsureNeverCalled(), - onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), - onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(), + onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onSettingsClick: () -> Unit = EnsureNeverCalled(), + onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onCreateRoomClick: () -> Unit = EnsureNeverCalled(), + onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), + onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomListView( state = state, - onRoomClicked = onRoomClicked, - onSettingsClicked = onSettingsClicked, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onCreateRoomClicked = onCreateRoomClicked, - onRoomSettingsClicked = onRoomSettingsClicked, - onMenuActionClicked = onMenuActionClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onSettingsClick = onSettingsClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onCreateRoomClick = onCreateRoomClick, + onRoomSettingsClick = onRoomSettingsClick, + onMenuActionClick = onMenuActionClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, acceptDeclineInviteView = { }, ) } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt similarity index 99% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt rename to features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt index 8d1fa69276..dac6433696 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersPresenterTest.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import io.element.android.libraries.matrix.api.roomlist.RoomListFilter as MatrixRoomListFilter -class RoomListFiltersPresenterTests { +class RoomListFiltersPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createRoomListFiltersPresenter() diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt similarity index 98% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTests.kt rename to features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt index 94897532e9..89221b7c95 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersViewTest.kt @@ -30,7 +30,7 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class RoomListFiltersViewTests { +class RoomListFiltersViewTest { @get:Rule val rule = createAndroidComposeRule() @Test diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt similarity index 99% rename from features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt rename to features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt index b3463c549f..89c843e222 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchPresenterTest.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test -class RoomListSearchPresenterTests { +class RoomListSearchPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createRoomListSearchPresenter() diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt index b3f0755f11..a50ba4fc26 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt @@ -47,7 +47,7 @@ class RoomListSearchViewTest { isRoomDirectorySearchEnabled = true, eventSink = eventsRecorder, ), - onRoomDirectorySearchClicked = it, + onRoomDirectorySearchClick = it, ) rule.clickOn(R.string.screen_roomlist_room_directory_button_title) } @@ -57,15 +57,15 @@ class RoomListSearchViewTest { private fun AndroidComposeTestRule.setRoomListSearchView( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit = EventsRecorder(expectEvents = false), - onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(), + onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomListSearchView( state = state, eventSink = eventSink, - onRoomClicked = onRoomClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, ) } } diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts index b1060dcd0e..64c207c798 100644 --- a/features/securebackup/impl/build.gradle.kts +++ b/features/securebackup/impl/build.gradle.kts @@ -40,7 +40,6 @@ dependencies { implementation(projects.anvilannotations) implementation(projects.appconfig) - implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt index f696066181..e03aecad43 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -87,23 +87,23 @@ class SecureBackupFlowNode @AssistedInject constructor( return when (navTarget) { NavTarget.Root -> { val callback = object : SecureBackupRootNode.Callback { - override fun onSetupClicked() { + override fun onSetupClick() { backstack.push(NavTarget.Setup) } - override fun onChangeClicked() { + override fun onChangeClick() { backstack.push(NavTarget.Change) } - override fun onDisableClicked() { + override fun onDisableClick() { backstack.push(NavTarget.Disable) } - override fun onEnableClicked() { + override fun onEnableClick() { backstack.push(NavTarget.Enable) } - override fun onConfirmRecoveryKeyClicked() { + override fun onConfirmRecoveryKeyClick() { backstack.push(NavTarget.EnterRecoveryKey) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt index 33b52a4497..f1fbc6fe7e 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt @@ -38,7 +38,7 @@ class CreateNewRecoveryKeyNode @AssistedInject constructor( CreateNewRecoveryKeyView( desktopApplicationName = buildMeta.desktopApplicationName, modifier = modifier, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt index 8974d1faa2..202766e4ac 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt @@ -16,47 +16,38 @@ package io.element.android.features.securebackup.impl.createkey -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.modifiers.squareSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Scaffold -import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateNewRecoveryKeyView( desktopApplicationName: String, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { - TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClicked) }) + TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClick) }) } ) { padding -> Column( @@ -74,55 +65,19 @@ fun CreateNewRecoveryKeyView( @Composable private fun Content(desktopApplicationName: String) { - Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { - Item(index = 1, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1, desktopApplicationName))) - Item(index = 2, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2))) - Item( - index = 3, - text = buildAnnotatedString { - val resetAllAction = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all) - val text = stringResource(R.string.screen_create_new_recovery_key_list_item_3, resetAllAction) - append(text) - val start = text.indexOf(resetAllAction) - val end = start + resetAllAction.length - if (start in text.indices && end in text.indices) { - addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) - } - } - ) - Item(index = 4, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4))) - Item(index = 5, text = AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5))) - } -} - -@Composable -private fun Item(index: Int, text: AnnotatedString) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - ItemNumber(index = index) - Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary) - } -} - -@Composable -private fun ItemNumber( - index: Int, -) { - val color = ElementTheme.colors.textPlaceholder - Box( - modifier = Modifier - .border(1.dp, color, CircleShape) - .squareSize() - ) { - Text( - modifier = Modifier.padding(1.5.dp), - text = index.toString(), - style = ElementTheme.typography.fontBodySmRegular, - color = color, - textAlign = TextAlign.Center, + val listItems = buildList { + add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_1, desktopApplicationName))) + add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_2))) + add( + annotatedTextWithBold( + text = stringResource(R.string.screen_create_new_recovery_key_list_item_3), + boldText = stringResource(R.string.screen_create_new_recovery_key_list_item_3_reset_all) + ) ) + add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_4))) + add(AnnotatedString(stringResource(R.string.screen_create_new_recovery_key_list_item_5))) } + NumberedListOrganism(modifier = Modifier.padding(horizontal = 16.dp), items = listItems.toImmutableList()) } @PreviewsDayNight @@ -131,7 +86,7 @@ internal fun CreateNewRecoveryKeyViewPreview() { ElementPreview { CreateNewRecoveryKeyView( desktopApplicationName = "Element", - onBackClicked = {}, + onBackClick = {}, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt index 3fd6559858..795f4d313e 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt @@ -38,8 +38,8 @@ class SecureBackupDisableNode @AssistedInject constructor( SecureBackupDisableView( state = state, modifier = modifier, - onDone = ::navigateUp, - onBackClicked = ::navigateUp, + onSuccess = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt index 37fc3eb2be..c1ce30724e 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt @@ -32,6 +32,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreview @@ -43,19 +44,20 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun SecureBackupDisableView( state: SecureBackupDisableState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_key_backup_disable_title), subTitle = stringResource(id = R.string.screen_key_backup_disable_description), - iconVector = CompoundIcons.KeyOffSolid(), - content = { Content(state = state) }, + iconStyle = BigIcon.Style.Default(CompoundIcons.KeyOffSolid()), buttons = { Buttons(state = state) }, - ) + ) { + Content(state = state) + } AsyncActionView( async = state.disableAction, @@ -68,7 +70,7 @@ fun SecureBackupDisableView( progressDialog = {}, errorMessage = { it.message ?: it.toString() }, onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) }, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, ) } @@ -79,7 +81,7 @@ private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismi content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description), submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off), destructiveSubmit = true, - onSubmitClicked = onConfirm, + onSubmitClick = onConfirm, onDismiss = onDismiss, ) } @@ -135,7 +137,7 @@ internal fun SecureBackupDisableViewPreview( ) = ElementPreview { SecureBackupDisableView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt index 804af27493..11e1b7a83a 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt @@ -38,8 +38,8 @@ class SecureBackupEnableNode @AssistedInject constructor( SecureBackupEnableView( state = state, modifier = modifier, - onDone = ::navigateUp, - onBackClicked = ::navigateUp, + onSuccess = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt index 53748d3879..190f423bfe 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -33,21 +34,21 @@ import io.element.android.libraries.designsystem.theme.components.Button @Composable fun SecureBackupEnableView( state: SecureBackupEnableState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), - iconVector = CompoundIcons.KeySolid(), + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), buttons = { Buttons(state = state) } ) AsyncActionView( async = state.enableAction, progressDialog = { }, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, onErrorDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) } ) } @@ -71,7 +72,7 @@ internal fun SecureBackupEnableViewPreview( ) = ElementPreview { SecureBackupEnableView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt index c80becb88a..2fd9067f78 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt @@ -46,8 +46,8 @@ class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor( SecureBackupEnterRecoveryKeyView( state = state, modifier = modifier, - onDone = callback::onEnterRecoveryKeySuccess, - onBackClicked = ::navigateUp, + onSuccess = callback::onEnterRecoveryKeySuccess, + onBackClick = ::navigateUp, onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt index db23018c6f..cbb46849a9 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -28,6 +28,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -38,14 +39,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupEnterRecoveryKeyView( state: SecureBackupEnterRecoveryKeyState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, onCreateNewRecoveryKey: () -> Unit, modifier: Modifier = Modifier, ) { AsyncActionView( async = state.submitAction, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, progressDialog = { }, errorTitle = { stringResource(id = R.string.screen_recovery_key_confirm_error_title) }, errorMessage = { stringResource(id = R.string.screen_recovery_key_confirm_error_content) }, @@ -54,13 +55,14 @@ fun SecureBackupEnterRecoveryKeyView( FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, - iconVector = CompoundIcons.KeySolid(), + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), title = stringResource(id = R.string.screen_recovery_key_confirm_title), subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description), - content = { Content(state = state) }, buttons = { Buttons(state = state, onCreateRecoveryKey = onCreateNewRecoveryKey) } - ) + ) { + Content(state = state) + } } @Composable @@ -109,8 +111,8 @@ internal fun SecureBackupEnterRecoveryKeyViewPreview( ) = ElementPreview { SecureBackupEnterRecoveryKeyView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, onCreateNewRecoveryKey = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt index 2fa037860e..22a78f89b4 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt @@ -40,34 +40,34 @@ class SecureBackupRootNode @AssistedInject constructor( plugins = plugins ) { interface Callback : Plugin { - fun onSetupClicked() - fun onChangeClicked() - fun onDisableClicked() - fun onEnableClicked() - fun onConfirmRecoveryKeyClicked() + fun onSetupClick() + fun onChangeClick() + fun onDisableClick() + fun onEnableClick() + fun onConfirmRecoveryKeyClick() } - private fun onSetupClicked() { - plugins().forEach { it.onSetupClicked() } + private fun onSetupClick() { + plugins().forEach { it.onSetupClick() } } - private fun onChangeClicked() { - plugins().forEach { it.onChangeClicked() } + private fun onChangeClick() { + plugins().forEach { it.onChangeClick() } } - private fun onDisableClicked() { - plugins().forEach { it.onDisableClicked() } + private fun onDisableClick() { + plugins().forEach { it.onDisableClick() } } - private fun onEnableClicked() { - plugins().forEach { it.onEnableClicked() } + private fun onEnableClick() { + plugins().forEach { it.onEnableClick() } } - private fun onConfirmRecoveryKeyClicked() { - plugins().forEach { it.onConfirmRecoveryKeyClicked() } + private fun onConfirmRecoveryKeyClick() { + plugins().forEach { it.onConfirmRecoveryKeyClick() } } - private fun onLearnMoreClicked(uriHandler: UriHandler) { + private fun onLearnMoreClick(uriHandler: UriHandler) { uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL) } @@ -77,13 +77,13 @@ class SecureBackupRootNode @AssistedInject constructor( val uriHandler = LocalUriHandler.current SecureBackupRootView( state = state, - onBackPressed = ::navigateUp, - onSetupClicked = ::onSetupClicked, - onChangeClicked = ::onChangeClicked, - onEnableClicked = ::onEnableClicked, - onDisableClicked = ::onDisableClicked, - onConfirmRecoveryKeyClicked = ::onConfirmRecoveryKeyClicked, - onLearnMoreClicked = { onLearnMoreClicked(uriHandler) }, + onBackClick = ::navigateUp, + onSetupClick = ::onSetupClick, + onChangeClick = ::onChangeClick, + onEnableClick = ::onEnableClick, + onDisableClick = ::onDisableClick, + onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick, + onLearnMoreClick = { onLearnMoreClick(uriHandler) }, modifier = modifier, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt index 754beb6d5f..a566fe15a7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt @@ -47,20 +47,20 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupRootView( state: SecureBackupRootState, - onBackPressed: () -> Unit, - onSetupClicked: () -> Unit, - onChangeClicked: () -> Unit, - onEnableClicked: () -> Unit, - onDisableClicked: () -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onLearnMoreClicked: () -> Unit, + onBackClick: () -> Unit, + onSetupClick: () -> Unit, + onChangeClick: () -> Unit, + onEnableClick: () -> Unit, + onDisableClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onLearnMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_chat_backup), snackbarHost = { SnackbarHost(snackbarHostState) }, ) { @@ -74,7 +74,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_title), subtitleAnnotated = text, - onClick = onLearnMoreClicked, + onClick = onLearnMoreClick, ) // Disable / Enable backup @@ -87,13 +87,13 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), tintColor = ElementTheme.colors.textCriticalPrimary, - onClick = onDisableClicked, + onClick = onDisableClick, ) } false -> { PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), - onClick = onEnableClicked, + onClick = onEnableClick, ) } } @@ -127,7 +127,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), - onClick = onEnableClicked, + onClick = onEnableClick, ) } } @@ -140,7 +140,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), tintColor = ElementTheme.colors.textCriticalPrimary, - onClick = onDisableClicked, + onClick = onDisableClick, ) } BackupState.DISABLING -> { @@ -158,14 +158,14 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName), - onClick = onSetupClicked, + onClick = onSetupClick, showEndBadge = true, ) } RecoveryState.ENABLED -> { PreferenceText( title = stringResource(id = R.string.screen_chat_backup_recovery_action_change), - onClick = onChangeClicked, + onClick = onChangeClick, ) } RecoveryState.INCOMPLETE -> @@ -173,7 +173,7 @@ fun SecureBackupRootView( title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description), showEndBadge = true, - onClick = onConfirmRecoveryKeyClicked, + onClick = onConfirmRecoveryKeyClick, ) } } @@ -186,12 +186,12 @@ internal fun SecureBackupRootViewPreview( ) = ElementPreview { SecureBackupRootView( state = state, - onBackPressed = {}, - onSetupClicked = {}, - onChangeClicked = {}, - onEnableClicked = {}, - onDisableClicked = {}, - onConfirmRecoveryKeyClicked = {}, - onLearnMoreClicked = {}, + onBackClick = {}, + onSetupClick = {}, + onChangeClick = {}, + onEnableClick = {}, + onDisableClick = {}, + onConfirmRecoveryKeyClick = {}, + onLearnMoreClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt index 495b4a38c1..a01d638055 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt @@ -55,11 +55,11 @@ class SecureBackupSetupNode @AssistedInject constructor( val state = presenter.present() SecureBackupSetupView( state = state, - onDone = { + onSuccess = { coroutineScope.postSuccessSnackbar() navigateUp() }, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index 03d4f80b97..6ade84be20 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -31,6 +31,7 @@ import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView import io.element.android.libraries.androidutils.system.copyToClipboard import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -42,26 +43,27 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupSetupView( state: SecureBackupSetupState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked.takeIf { state.canGoBack() }, + onBackClick = onBackClick.takeIf { state.canGoBack() }, title = title(state), subTitle = subtitle(state), - iconVector = CompoundIcons.KeySolid(), - content = { Content(state) }, - buttons = { Buttons(state, onDone = onDone) }, - ) + iconStyle = BigIcon.Style.Default(CompoundIcons.KeySolid()), + buttons = { Buttons(state, onFinish = onSuccess) }, + ) { + Content(state = state) + } if (state.showSaveConfirmationDialog) { ConfirmationDialog( title = stringResource(id = R.string.screen_recovery_key_setup_confirmation_title), content = stringResource(id = R.string.screen_recovery_key_setup_confirmation_description), submitText = stringResource(id = CommonStrings.action_continue), - onSubmitClicked = onDone, + onSubmitClick = onSuccess, onDismiss = { state.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) } @@ -138,7 +140,7 @@ private fun Content( @Composable private fun ColumnScope.Buttons( state: SecureBackupSetupState, - onDone: () -> Unit, + onFinish: () -> Unit, ) { val context = LocalContext.current val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action) @@ -149,7 +151,7 @@ private fun ColumnScope.Buttons( text = stringResource(id = CommonStrings.action_done), enabled = false, modifier = Modifier.fillMaxWidth(), - onClick = onDone + onClick = onFinish ) } is SetupState.Created, @@ -172,7 +174,7 @@ private fun ColumnScope.Buttons( modifier = Modifier.fillMaxWidth(), onClick = { if (state.setupState is SetupState.CreatedAndSaved) { - onDone() + onFinish() } else { state.eventSink.invoke(SecureBackupSetupEvents.Done) } @@ -189,7 +191,7 @@ internal fun SecureBackupSetupViewPreview( ) = ElementPreview { SecureBackupSetupView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt index 31efdfa3a1..cff8cb4347 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt @@ -32,7 +32,7 @@ internal fun SecureBackupSetupViewChangePreview( isChangeRecoveryKeyUserStory = true, recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change), ), - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt index f074116af1..c42847a323 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt @@ -39,22 +39,22 @@ class SecureBackupEnterRecoveryKeyViewTest { @get:Rule val rule = createAndroidComposeRule() @Test - fun `back key pressed - calls onBackClicked`() { + fun `back key pressed - calls onBackClick`() { ensureCalledOnce { callback -> rule.setSecureBackupEnterRecoveryKeyView( aSecureBackupEnterRecoveryKeyState(), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBackKey() } } @Test - fun `back button clicked - calls onBackClicked`() { + fun `back button clicked - calls onBackClick`() { ensureCalledOnce { callback -> rule.setSecureBackupEnterRecoveryKeyView( aSecureBackupEnterRecoveryKeyState(), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBack() } @@ -95,14 +95,14 @@ class SecureBackupEnterRecoveryKeyViewTest { private fun AndroidComposeTestRule.setSecureBackupEnterRecoveryKeyView( state: SecureBackupEnterRecoveryKeyState, onDone: () -> Unit = EnsureNeverCalled(), - onBackClicked: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(), ) { rule.setContent { SecureBackupEnterRecoveryKeyView( state = state, - onDone = onDone, - onBackClicked = onBackClicked, + onSuccess = onDone, + onBackClick = onBackClick, onCreateNewRecoveryKey = onCreateNewRecoveryKey ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt b/features/share/api/build.gradle.kts similarity index 69% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt rename to features/share/api/build.gradle.kts index be8c86448e..14528434a6 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt +++ b/features/share/api/build.gradle.kts @@ -14,10 +14,16 @@ * limitations under the License. */ -package io.element.android.libraries.designsystem.components.preferences +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} -import androidx.compose.ui.unit.dp +android { + namespace = "io.element.android.features.share.api" +} -internal val preferenceMinHeightOnlyTitle = 56.dp -internal val preferenceMinHeight = 56.dp -internal val preferencePaddingHorizontal = 16.dp +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt new file mode 100644 index 0000000000..0861e00ca2 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareEntryPoint.kt @@ -0,0 +1,41 @@ +/* + * 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.share.api + +import android.content.Intent +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.matrix.api.core.RoomId + +interface ShareEntryPoint : FeatureEntryPoint { + data class Params(val intent: Intent) : NodeInputs + + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface Callback : Plugin { + fun onDone(roomIds: List) + } + + interface NodeBuilder { + fun params(params: Params): NodeBuilder + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } +} diff --git a/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt new file mode 100644 index 0000000000..c46f5b3215 --- /dev/null +++ b/features/share/api/src/main/kotlin/io/element/android/features/share/api/ShareService.kt @@ -0,0 +1,23 @@ +/* + * 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.share.api + +import kotlinx.coroutines.CoroutineScope + +interface ShareService { + fun observeFeatureFlag(coroutineScope: CoroutineScope) +} diff --git a/features/share/impl/build.gradle.kts b/features/share/impl/build.gradle.kts new file mode 100644 index 0000000000..c180c5d29a --- /dev/null +++ b/features/share/impl/build.gradle.kts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.share.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.appconfig) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.featureflag.api) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.mediaupload.api) + implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) + api(libs.statemachine) + api(projects.features.share.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaupload.test) + testImplementation(projects.tests.testutils) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) + + ksp(libs.showkase.processor) +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt new file mode 100644 index 0000000000..95859cc8b0 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareEntryPoint.kt @@ -0,0 +1,49 @@ +/* + * 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.share.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultShareEntryPoint @Inject constructor() : ShareEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): ShareEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : ShareEntryPoint.NodeBuilder { + override fun params(params: ShareEntryPoint.Params): ShareEntryPoint.NodeBuilder { + plugins += ShareNode.Inputs(intent = params.intent) + return this + } + + override fun callback(callback: ShareEntryPoint.Callback): ShareEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt new file mode 100644 index 0000000000..2ea15a5d71 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/DefaultShareService.kt @@ -0,0 +1,83 @@ +/* + * 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.share.impl + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.share.api.ShareService +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultShareService @Inject constructor( + private val featureFlagService: FeatureFlagService, + @ApplicationContext private val context: Context, +) : ShareService { + override fun observeFeatureFlag(coroutineScope: CoroutineScope) { + val shareActivityComponent = getShareActivityComponent() + ?: return Unit.also { + Timber.w("ShareActivity not found") + } + featureFlagService.isFeatureEnabledFlow(FeatureFlags.IncomingShare) + .onEach { enabled -> + shareActivityComponent.enableOrDisable(enabled) + } + .launchIn(coroutineScope) + } + + private fun getShareActivityComponent(): ComponentName? { + return context.packageManager + .getPackageInfo( + context.packageName, + PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS + ) + .activities + .firstOrNull { it.name.endsWith(".ShareActivity") } + ?.let { shareActivityInfo -> + ComponentName( + shareActivityInfo.packageName, + shareActivityInfo.name, + ) + } + } + + private fun ComponentName.enableOrDisable(enabled: Boolean) { + val state = if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + try { + context.packageManager.setComponentEnabledSetting( + this, + state, + PackageManager.DONT_KILL_APP, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to enable or disable the component") + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt new file mode 100644 index 0000000000..9fe24660ec --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareEvents.kt @@ -0,0 +1,21 @@ +/* + * 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.share.impl + +sealed interface ShareEvents { + data object ClearError : ShareEvents +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt new file mode 100644 index 0000000000..9eaaf2b8f6 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareIntentHandler.kt @@ -0,0 +1,147 @@ +/* + * 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.share.impl + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Build +import androidx.core.content.IntentCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.compat.queryIntentActivitiesCompat +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAny +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeApplication +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeFile +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeText +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject + +interface ShareIntentHandler { + data class UriToShare( + val uri: Uri, + val mimeType: String, + ) + + /** + * This methods aims to handle incoming share intents. + * + * @return true if it can handle the intent data, false otherwise + */ + suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean +} + +@ContributesBinding(AppScope::class) +class DefaultShareIntentHandler @Inject constructor( + @ApplicationContext private val context: Context, +) : ShareIntentHandler { + override suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + val type = intent.resolveType(context) ?: return false + return when { + type == MimeTypes.PlainText -> handlePlainText(intent, onPlainText) + type.isMimeTypeImage() || + type.isMimeTypeVideo() || + type.isMimeTypeAudio() || + type.isMimeTypeApplication() || + type.isMimeTypeFile() || + type.isMimeTypeText() || + type.isMimeTypeAny() -> { + val uris = getIncomingUris(intent, type) + val result = onUris(uris) + revokeUriPermissions(uris.map { it.uri }) + result + } + else -> false + } + } + + private suspend fun handlePlainText(intent: Intent, onPlainText: suspend (String) -> Boolean): Boolean { + val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + return if (content?.isNotEmpty() == true) { + onPlainText(content) + } else { + false + } + } + + /** + * Use this function to retrieve files which are shared from another application or internally + * by using android.intent.action.SEND or android.intent.action.SEND_MULTIPLE actions. + */ + private fun getIncomingUris(intent: Intent, type: String): List { + val uriList = mutableListOf() + if (intent.action == Intent.ACTION_SEND) { + IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.let { uriList.add(it) } + } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) { + IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) + ?.let { uriList.addAll(it) } + } + val resInfoList: List = context.packageManager.queryIntentActivitiesCompat(intent, PackageManager.MATCH_DEFAULT_ONLY) + uriList.forEach { uri -> + resInfoList.forEach resolve@{ resolveInfo -> + val packageName: String = resolveInfo.activityInfo.packageName + // Replace implicit intent by an explicit to fix crash on some devices like Xiaomi. + // see https://juejin.cn/post/7031736325422186510 + try { + context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: Exception) { + Timber.w(e, "Unable to grant Uri permission") + return@resolve + } + intent.action = null + intent.component = ComponentName(packageName, resolveInfo.activityInfo.name) + } + } + return uriList.map { uri -> + ShareIntentHandler.UriToShare( + uri = uri, + mimeType = type + ) + } + } + + private fun revokeUriPermissions(uris: List) { + uris.forEach { uri -> + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.revokeUriPermission(context.packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + context.revokeUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } catch (e: Exception) { + Timber.w(e, "Unable to revoke Uri permission") + } + } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt new file mode 100644 index 0000000000..ad360b09ea --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareNode.kt @@ -0,0 +1,101 @@ +/* + * 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.share.impl + +import android.content.Intent +import android.os.Parcelable +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.plugin.Plugin +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.share.api.ShareEntryPoint +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint +import io.element.android.libraries.roomselect.api.RoomSelectMode +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class ShareNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: SharePresenter.Factory, + private val roomSelectEntryPoint: RoomSelectEntryPoint, +) : ParentNode( + navModel = PermanentNavModel( + navTargets = setOf(NavTarget), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + @Parcelize + object NavTarget : Parcelable + + data class Inputs(val intent: Intent) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.intent) + private val callbacks = plugins.filterIsInstance() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + val callback = object : RoomSelectEntryPoint.Callback { + override fun onRoomSelected(roomIds: List) { + presenter.onRoomSelected(roomIds) + } + + override fun onCancel() { + navigateUp() + } + } + + return roomSelectEntryPoint.nodeBuilder(this, buildContext) + .callback(callback) + .params(RoomSelectEntryPoint.Params(mode = RoomSelectMode.Share)) + .build() + } + + @Composable + override fun View(modifier: Modifier) { + Box(modifier = modifier) { + // Will render to room select screen + Children( + navModel = navModel, + ) + + val state = presenter.present() + ShareView( + state = state, + onShareSuccess = ::onShareSuccess, + ) + } + } + + private fun onShareSuccess(roomIds: List) { + callbacks.forEach { it.onDone(roomIds) } + } +} diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt new file mode 100644 index 0000000000..c0ebb4ee16 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/SharePresenter.kt @@ -0,0 +1,115 @@ +/* + * 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.share.impl + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaSender +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class SharePresenter @AssistedInject constructor( + @Assisted private val intent: Intent, + private val appCoroutineScope: CoroutineScope, + private val shareIntentHandler: ShareIntentHandler, + private val matrixClient: MatrixClient, + private val mediaPreProcessor: MediaPreProcessor, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(intent: Intent): SharePresenter + } + + private val shareActionState: MutableState>> = mutableStateOf(AsyncAction.Uninitialized) + + fun onRoomSelected(roomIds: List) { + appCoroutineScope.share(intent, roomIds) + } + + @Composable + override fun present(): ShareState { + fun handleEvents(event: ShareEvents) { + when (event) { + ShareEvents.ClearError -> shareActionState.value = AsyncAction.Uninitialized + } + } + + return ShareState( + shareAction = shareActionState.value, + eventSink = { handleEvents(it) } + ) + } + + private fun CoroutineScope.share( + intent: Intent, + roomIds: List, + ) = launch { + suspend { + val result = shareIntentHandler.handleIncomingShareIntent( + intent, + onUris = { filesToShare -> + if (filesToShare.isEmpty()) { + false + } else { + roomIds + .map { roomId -> + val room = matrixClient.getRoom(roomId) ?: return@map false + val mediaSender = MediaSender(preProcessor = mediaPreProcessor, room = room) + filesToShare + .map { fileToShare -> + mediaSender.sendMedia( + uri = fileToShare.uri, + mimeType = fileToShare.mimeType, + compressIfPossible = true, + ).isSuccess + } + .all { it } + } + .all { it } + } + }, + onPlainText = { text -> + roomIds + .map { roomId -> + matrixClient.getRoom(roomId)?.sendMessage( + body = text, + htmlBody = null, + mentions = emptyList(), + )?.isSuccess.orFalse() + } + .all { it } + } + ) + if (!result) { + error("Failed to handle incoming share intent") + } + roomIds + }.runCatchingUpdatingState(shareActionState) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt similarity index 64% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt rename to features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt index 8f99651c89..b7e3510033 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * 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. @@ -14,11 +14,12 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications.fake +package io.element.android.features.share.impl -import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator -import io.mockk.mockk +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId -class MockkSummaryGroupMessageCreator { - val instance = mockk() -} +data class ShareState( + val shareAction: AsyncAction>, + val eventSink: (ShareEvents) -> Unit +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt new file mode 100644 index 0000000000..a8b766f238 --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareStateProvider.kt @@ -0,0 +1,47 @@ +/* + * 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.share.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId + +open class ShareStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShareState(), + aShareState( + shareAction = AsyncAction.Loading, + ), + aShareState( + shareAction = AsyncAction.Success( + listOf(RoomId("!room2:domain")), + ) + ), + aShareState( + shareAction = AsyncAction.Failure(Throwable("error")), + ), + ) +} + +fun aShareState( + shareAction: AsyncAction> = AsyncAction.Uninitialized, + eventSink: (ShareEvents) -> Unit = {} +) = ShareState( + shareAction = shareAction, + eventSink = eventSink +) diff --git a/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt new file mode 100644 index 0000000000..1bc3e2325b --- /dev/null +++ b/features/share/impl/src/main/kotlin/io/element/android/features/share/impl/ShareView.kt @@ -0,0 +1,49 @@ +/* + * 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.share.impl + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.matrix.api.core.RoomId + +@Composable +fun ShareView( + state: ShareState, + onShareSuccess: (List) -> Unit, +) { + AsyncActionView( + async = state.shareAction, + onSuccess = { + onShareSuccess(it) + }, + onErrorDismiss = { + state.eventSink(ShareEvents.ClearError) + }, + ) +} + +@PreviewsDayNight +@Composable +internal fun ShareViewPreview(@PreviewParameter(ShareStateProvider::class) state: ShareState) = ElementPreview { + ShareView( + state = state, + onShareSuccess = {} + ) +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt new file mode 100644 index 0000000000..682bb177cc --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/FakeShareIntentHandler.kt @@ -0,0 +1,35 @@ +/* + * 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.share.impl + +import android.content.Intent + +class FakeShareIntentHandler( + private val onIncomingShareIntent: suspend ( + Intent, + suspend (List) -> Boolean, + suspend (String) -> Boolean, + ) -> Boolean = { _, _, _ -> false }, +) : ShareIntentHandler { + override suspend fun handleIncomingShareIntent( + intent: Intent, + onUris: suspend (List) -> Boolean, + onPlainText: suspend (String) -> Boolean, + ): Boolean { + return onIncomingShareIntent(intent, onUris, onPlainText) + } +} diff --git a/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt new file mode 100644 index 0000000000..42c053bafb --- /dev/null +++ b/features/share/impl/src/test/kotlin/io/element/android/features/share/impl/SharePresenterTest.kt @@ -0,0 +1,164 @@ +/* + * 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.share.impl + +import android.content.Intent +import android.net.Uri +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SharePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createSharePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - on room selected error then clear error`() = runTest { + val presenter = createSharePresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val failure = awaitItem() + assertThat(failure.shareAction.isFailure()).isTrue() + failure.eventSink.invoke(ShareEvents.ClearError) + assertThat(awaitItem().shareAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - on room selected ok`() = runTest { + val presenter = createSharePresenter( + shareIntentHandler = FakeShareIntentHandler { _, _, _ -> true } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + } + } + + @Test + fun `present - send text ok`() = runTest { + val matrixRoom = FakeMatrixRoom() + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, matrixRoom) + } + val presenter = createSharePresenter( + matrixClient = matrixClient, + shareIntentHandler = FakeShareIntentHandler { _, _, onText -> + onText(A_MESSAGE) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + } + } + + @Test + fun `present - send media ok`() = runTest { + val matrixRoom = FakeMatrixRoom() + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, matrixRoom) + } + val presenter = createSharePresenter( + matrixClient = matrixClient, + shareIntentHandler = FakeShareIntentHandler { _, onFile, _ -> + onFile( + listOf( + ShareIntentHandler.UriToShare( + uri = Uri.parse("content://image.jpg"), + mimeType = MimeTypes.Jpeg, + ) + ) + ) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.shareAction.isUninitialized()).isTrue() + presenter.onRoomSelected(listOf(A_ROOM_ID)) + assertThat(awaitItem().shareAction.isLoading()).isTrue() + val success = awaitItem() + assertThat(success.shareAction.isSuccess()).isTrue() + assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID))) + } + } + + private fun TestScope.createSharePresenter( + intent: Intent = Intent(), + shareIntentHandler: ShareIntentHandler = FakeShareIntentHandler(), + matrixClient: MatrixClient = FakeMatrixClient(), + mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor() + ): SharePresenter { + return SharePresenter( + intent = intent, + appCoroutineScope = this, + shareIntentHandler = shareIntentHandler, + matrixClient = matrixClient, + mediaPreProcessor = mediaPreProcessor + ) + } +} diff --git a/features/share/test/build.gradle.kts b/features/share/test/build.gradle.kts new file mode 100644 index 0000000000..0eaa0bedd2 --- /dev/null +++ b/features/share/test/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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. + */ +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.share.test" +} + +dependencies { + implementation(projects.features.share.api) + implementation(libs.coroutines.core) + implementation(projects.tests.testutils) +} diff --git a/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt new file mode 100644 index 0000000000..302d8e2a54 --- /dev/null +++ b/features/share/test/src/main/kotlin/io/element/android/features/share/test/FakeShareService.kt @@ -0,0 +1,29 @@ +/* + * 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.share.test + +import io.element.android.features.share.api.ShareService +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.CoroutineScope + +class FakeShareService( + private val observeFeatureFlagLambda: (CoroutineScope) -> Unit = { lambdaError() } +) : ShareService { + override fun observeFeatureFlag(coroutineScope: CoroutineScope) { + observeFeatureFlagLambda(coroutineScope) + } +} diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt index 402e07dbba..9101254126 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/UserProfileFlowNode.kt @@ -83,8 +83,8 @@ class UserProfileFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenRoom(roomId) } } - override fun onStartCall(roomId: RoomId) { - ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = roomId)) + override fun onStartCall(dmRoomId: RoomId) { + ElementCallActivity.start(context, CallType.RoomCall(sessionId = sessionIdHolder.current, roomId = dmRoomId)) } } val params = UserProfileNode.UserProfileInputs(userId = inputs().userId) diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt index 4d4ea993c4..d2fa42183b 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt @@ -89,7 +89,7 @@ class UserProfileNode @AssistedInject constructor( modifier = modifier, goBack = this::navigateUp, onShareUser = ::onShareUser, - onDmStarted = ::onStartDM, + onOpenDm = ::onStartDM, onStartCall = callback::onStartCall, openAvatarPreview = callback::openAvatarPreview, ) diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt similarity index 99% rename from features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt rename to features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 20b63ef702..6adf4b9d38 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTests.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -43,7 +43,7 @@ import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class UserProfilePresenterTests { +class UserProfilePresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt index 7a669772f7..027f789515 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileNodeHelper.kt @@ -32,7 +32,7 @@ class UserProfileNodeHelper( interface Callback : NodeInputs { fun openAvatarPreview(username: String, avatarUrl: String) fun onStartDM(roomId: RoomId) - fun onStartCall(roomId: RoomId) + fun onStartCall(dmRoomId: RoomId) } fun onShareUser( diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 3d5ae4a66c..f147798b19 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun UserProfileView( state: UserProfileState, onShareUser: () -> Unit, - onDmStarted: (RoomId) -> Unit, + onOpenDm: (RoomId) -> Unit, onStartCall: (RoomId) -> Unit, goBack: () -> Unit, openAvatarPreview: (username: String, url: String) -> Unit, @@ -96,7 +96,7 @@ fun UserProfileView( progressText = stringResource(CommonStrings.common_starting_chat), ) }, - onSuccess = onDmStarted, + onSuccess = onOpenDm, errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) }, onRetry = { state.eventSink(UserProfileEvents.StartDM) }, onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) }, @@ -114,7 +114,7 @@ internal fun UserProfileViewPreview( state = state, onShareUser = {}, goBack = {}, - onDmStarted = {}, + onOpenDm = {}, onStartCall = {}, openAvatarPreview = { _, _ -> } ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt index 3e7aeff512..7f671c397d 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt @@ -63,7 +63,7 @@ private fun BlockConfirmationDialog( title = stringResource(R.string.screen_dm_details_block_user), content = stringResource(R.string.screen_dm_details_block_alert_description), submitText = stringResource(R.string.screen_dm_details_block_alert_action), - onSubmitClicked = onBlockAction, + onSubmitClick = onBlockAction, onDismiss = onDismiss ) } @@ -77,7 +77,7 @@ private fun UnblockConfirmationDialog( title = stringResource(R.string.screen_dm_details_unblock_user), content = stringResource(R.string.screen_dm_details_unblock_alert_description), submitText = stringResource(R.string.screen_dm_details_unblock_alert_action), - onSubmitClicked = onUnblockAction, + onSubmitClick = onUnblockAction, onDismiss = onDismiss ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt index 6ad1bf8484..424219158a 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt @@ -45,7 +45,7 @@ fun BlockUserSection( ) { PreferenceCategory( modifier = modifier, - showDivider = false, + showTopDivider = false, ) { when (state.isBlocked) { is AsyncData.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink) diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt index 6cc5e229e5..04fc36da48 100644 --- a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt @@ -226,7 +226,7 @@ private fun AndroidComposeTestRule.setUserP UserProfileView( state = state, onShareUser = onShareUser, - onDmStarted = onDmStarted, + onOpenDm = onDmStarted, onStartCall = onStartCall, goBack = goBack, openAvatarPreview = openAvatarPreview, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt index 6fbde66faf..9ce1358683 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt @@ -43,7 +43,7 @@ class VerifySelfSessionNode @AssistedInject constructor( state = state, modifier = modifier, onEnterRecoveryKey = callback::onEnterRecoveryKey, - onFinished = callback::onDone, + onFinish = callback::onDone, ) } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt index 42d752d8f5..6b908e3ebd 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -66,16 +66,16 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver fun VerifySelfSessionView( state: VerifySelfSessionState, onEnterRecoveryKey: () -> Unit, - onFinished: () -> Unit, + onFinish: () -> Unit, modifier: Modifier = Modifier, ) { fun resetFlow() { state.eventSink(VerifySelfSessionViewEvents.Reset) } - val updatedOnFinished by rememberUpdatedState(newValue = onFinished) - LaunchedEffect(state.verificationFlowStep, updatedOnFinished) { + val latestOnFinish by rememberUpdatedState(newValue = onFinish) + LaunchedEffect(state.verificationFlowStep, latestOnFinish) { if (state.verificationFlowStep is FlowStep.Skipped) { - updatedOnFinished() + latestOnFinish() } } BackHandler { @@ -114,7 +114,7 @@ fun VerifySelfSessionView( screenState = state, goBack = ::resetFlow, onEnterRecoveryKey = onEnterRecoveryKey, - onFinished = onFinished, + onFinish = onFinish, ) } ) { @@ -227,7 +227,7 @@ private fun BottomMenu( screenState: VerifySelfSessionState, onEnterRecoveryKey: () -> Unit, goBack: () -> Unit, - onFinished: () -> Unit, + onFinish: () -> Unit, ) { val verificationViewState = screenState.verificationFlowStep val eventSink = screenState.eventSink @@ -239,37 +239,37 @@ private fun BottomMenu( if (verificationViewState.isLastDevice) { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key), - onPositiveButtonClicked = onEnterRecoveryKey, + onPositiveButtonClick = onEnterRecoveryKey, ) } else { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key), - onNegativeButtonClicked = onEnterRecoveryKey, + onNegativeButtonClick = onEnterRecoveryKey, ) } } is FlowStep.Canceled -> { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, negativeButtonTitle = stringResource(CommonStrings.action_cancel), - onNegativeButtonClicked = goBack, + onNegativeButtonClick = goBack, ) } is FlowStep.Ready -> { BottomMenu( positiveButtonTitle = stringResource(CommonStrings.action_start), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) }, negativeButtonTitle = stringResource(CommonStrings.action_cancel), - onNegativeButtonClicked = goBack, + onNegativeButtonClick = goBack, ) } is FlowStep.AwaitingOtherDeviceResponse -> { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device), - onPositiveButtonClicked = {}, + onPositiveButtonClick = {}, isLoading = true, ) } @@ -281,20 +281,20 @@ private fun BottomMenu( } BottomMenu( positiveButtonTitle = positiveButtonTitle, - onPositiveButtonClicked = { + onPositiveButtonClick = { if (!isVerifying) { eventSink(VerifySelfSessionViewEvents.ConfirmVerification) } }, negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match), - onNegativeButtonClicked = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }, + onNegativeButtonClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }, isLoading = isVerifying, ) } is FlowStep.Completed -> { BottomMenu( positiveButtonTitle = stringResource(CommonStrings.action_continue), - onPositiveButtonClicked = onFinished, + onPositiveButtonClick = onFinish, ) } is FlowStep.Skipped -> return @@ -304,11 +304,11 @@ private fun BottomMenu( @Composable private fun BottomMenu( positiveButtonTitle: String?, - onPositiveButtonClicked: () -> Unit, + onPositiveButtonClick: () -> Unit, modifier: Modifier = Modifier, negativeButtonTitle: String? = null, negativeButtonEnabled: Boolean = negativeButtonTitle != null, - onNegativeButtonClicked: () -> Unit = {}, + onNegativeButtonClick: () -> Unit = {}, isLoading: Boolean = false, ) { ButtonColumnMolecule( @@ -319,14 +319,14 @@ private fun BottomMenu( text = positiveButtonTitle, showProgress = isLoading, modifier = Modifier.fillMaxWidth(), - onClick = onPositiveButtonClicked, + onClick = onPositiveButtonClick, ) } if (negativeButtonTitle != null) { TextButton( text = negativeButtonTitle, modifier = Modifier.fillMaxWidth(), - onClick = onNegativeButtonClicked, + onClick = onNegativeButtonClick, enabled = negativeButtonEnabled, ) } else { @@ -341,6 +341,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta VerifySelfSessionView( state = state, onEnterRecoveryKey = {}, - onFinished = {}, + onFinish = {}, ) } diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml index f1b2fcc6e5..5460b76d12 100644 --- a/features/verifysession/impl/src/main/res/values-sv/translations.xml +++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml @@ -6,6 +6,7 @@ "Bekräfta att siffrorna nedan matchar de som visas på din andra session." "Jämför siffror" "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd." + "Ange återställningsnyckel" "Bevisa att det är du för att komma åt din krypterade meddelandehistorik." "Öppna en befintlig session" "Försök att verifiera igen" diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml index e98480316a..f5a26eaf33 100644 --- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -6,7 +6,7 @@ "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。" "裝置已驗證" "使用另一部裝置" - "正在等待其他裝置……" + "正在等待其他裝置…" "似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。" "確認顯示在其他工作階段上的表情符號是否和下方的相同。" "比對表情符號" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt similarity index 99% rename from features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt rename to features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt index 1a27891bef..6fd13c45bf 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTest.kt @@ -42,7 +42,7 @@ import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class VerifySelfSessionPresenterTests { +class VerifySelfSessionPresenterTest { @get:Rule val warmUpRule = WarmUpRule() diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt index 52158c2d7f..15a1ffedac 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt @@ -222,7 +222,7 @@ class VerifySelfSessionViewTest { VerifySelfSessionView( state = state, onEnterRecoveryKey = onEnterRecoveryKey, - onFinished = onFinished, + onFinish = onFinished, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt index 3d4ad727fe..2c063f5e16 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt @@ -41,7 +41,7 @@ class ViewFileNode @AssistedInject constructor( ) : NodeInputs interface Callback : Plugin { - fun onBackPressed() + fun onBackClick() } private val inputs: Inputs = inputs() @@ -51,8 +51,8 @@ class ViewFileNode @AssistedInject constructor( name = inputs.name, ) - private fun onBackPressed() { - plugins().forEach { it.onBackPressed() } + private fun onBackClick() { + plugins().forEach { it.onBackClick() } } @Composable @@ -61,7 +61,7 @@ class ViewFileNode @AssistedInject constructor( ViewFileView( state = state, modifier = modifier, - onBackPressed = ::onBackPressed, + onBackClick = ::onBackClick, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index f832617280..351b032e32 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -62,7 +62,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun ViewFileView( state: ViewFileState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -70,7 +70,7 @@ fun ViewFileView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -247,6 +247,6 @@ private val colorError = Color(0xFFFF6B68) internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview { ViewFileView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt index 23dac7bc4e..6e6c2a2415 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt @@ -42,7 +42,7 @@ class ViewFolderNode @AssistedInject constructor( ) : NodeInputs interface Callback : Plugin { - fun onBackPressed() + fun onBackClick() fun onNavigateTo(item: Item) } @@ -53,8 +53,8 @@ class ViewFolderNode @AssistedInject constructor( path = inputs.path, ) - private fun onBackPressed() { - plugins().forEach { it.onBackPressed() } + private fun onBackClick() { + plugins().forEach { it.onBackClick() } } private fun onNavigateTo(item: Item) { @@ -68,7 +68,7 @@ class ViewFolderNode @AssistedInject constructor( state = state, modifier = modifier, onNavigateTo = ::onNavigateTo, - onBackPressed = ::onBackPressed, + onBackClick = ::onBackClick, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt index 44453c253e..b8198cff21 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -53,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar fun ViewFolderView( state: ViewFolderState, onNavigateTo: (Item) -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -61,7 +61,7 @@ fun ViewFolderView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -85,7 +85,7 @@ fun ViewFolderView( ) { item -> ItemRow( item = item, - onItemClicked = { onNavigateTo(item) }, + onItemClick = { onNavigateTo(item) }, ) } if (state.content.none { it !is Item.Parent }) { @@ -108,7 +108,7 @@ fun ViewFolderView( @Composable private fun ItemRow( item: Item, - onItemClicked: () -> Unit, + onItemClick: () -> Unit, ) { when (item) { Item.Parent -> { @@ -121,7 +121,7 @@ private fun ItemRow( style = ElementTheme.typography.fontBodyMdMedium, ) }, - onClick = onItemClicked, + onClick = onItemClick, ) } is Item.Folder -> { @@ -134,7 +134,7 @@ private fun ItemRow( style = ElementTheme.typography.fontBodyMdMedium, ) }, - onClick = onItemClicked, + onClick = onItemClick, ) } is Item.File -> { @@ -148,7 +148,7 @@ private fun ItemRow( ) }, trailingContent = ListItemContent.Text(item.formattedSize), - onClick = onItemClicked, + onClick = onItemClick, ) } } @@ -160,6 +160,6 @@ internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::cl ViewFolderView( state = state, onNavigateTo = {}, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt index 697bd76d13..9379d86f26 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt @@ -97,7 +97,7 @@ class ViewFolderRootNode @AssistedInject constructor( } is NavTarget.File -> { val callback: ViewFileNode.Callback = object : ViewFileNode.Callback { - override fun onBackPressed() { + override fun onBackClick() { backstack.pop() } } @@ -115,7 +115,7 @@ class ViewFolderRootNode @AssistedInject constructor( inputs: ViewFolderNode.Inputs, ): Node { val callback: ViewFolderNode.Callback = object : ViewFolderNode.Callback { - override fun onBackPressed() { + override fun onBackClick() { onDone() } diff --git a/gradle.properties b/gradle.properties index 19237b74cf..9808f9eda1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseG1GC # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f9fc7f276..323c543679 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,24 +3,25 @@ [versions] # Project -android_gradle_plugin = "8.4.0" +android_gradle_plugin = "8.4.1" kotlin = "1.9.24" ksp = "1.9.24-1.0.20" firebaseAppDistribution = "5.0.0" # AndroidX -core = "1.13.0" -# Warning: there is an issue with 1.1.0, that I cannot repro on unit test. +core = "1.13.1" +# Warning: there is an issue with 1.1.0 and 1.1.1, that I cannot repro on unit test. # To repro with the application: -# Clear the cache of the application and run the app. Nearly each time, there is an infinite loading -# Due to the DefaultMigrationStore not bahaving as expected. +# Clear the storage of the application and run the app. Nearly each time, there is an infinite loading +# due to the DefaultMigrationStore not behaving as expected. # Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version. datastore = "1.0.0" constraintlayout = "2.1.4" constraintlayout_compose = "1.0.1" lifecycle = "2.7.0" -activity = "1.8.2" +activity = "1.9.0" media3 = "1.3.1" +camera = "1.3.3" # Compose compose_bom = "2024.05.00" @@ -38,9 +39,9 @@ test_core = "1.5.0" #other coil = "2.6.0" datetime = "0.6.0" -dependencyAnalysis = "1.31.0" +dependencyAnalysis = "1.32.0" serialization_json = "1.6.3" -showkase = "1.0.2" +showkase = "1.0.3" appyx = "1.4.0" sqldelight = "2.0.2" wysiwyg = "3.0.1" @@ -65,9 +66,9 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref android_desugar = "com.android.tools:desugar_jdk_libs:2.0.4" kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } -gms_google_services = "com.google.gms:google-services:4.4.1" +gms_google_services = "com.google.gms:google-services:4.4.2" # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:33.0.0" +google_firebase_bom = "com.google.firebase:firebase-bom:33.1.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } @@ -80,6 +81,9 @@ androidx_datastore_datastore = { module = "androidx.datastore:datastore", versio androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7" androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_constraintlayout_compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout_compose" } +androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" } +androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } androidx_recyclerview = "androidx.recyclerview:recyclerview:1.3.2" androidx_browser = "androidx.browser:browser:1.8.0" @@ -98,7 +102,9 @@ androidx_preference = "androidx.preference:preference:1.2.1" androidx_webkit = "androidx.webkit:webkit:1.11.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } -androidx_compose_material3 = "androidx.compose.material3:material3:1.2.1" +androidx_compose_material3 = { module = "androidx.compose.material3:material3" } +androidx_compose_material3_windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class" } +androidx_compose_material3_adaptive = "androidx.compose.material3:material3-adaptive-android:1.0.0-alpha06" androidx_compose_ui = { module = "androidx.compose.ui:ui" } androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" } androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -134,7 +140,7 @@ test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.5.2" test_mockk = "io.mockk:mockk:1.13.11" -test_konsist = "com.lemonappdev:konsist:0.13.0" +test_konsist = "com.lemonappdev:konsist:0.15.1" test_turbine = "app.cash.turbine:turbine:1.1.0" test_truth = "com.google.truth:truth:1.4.2" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.16" @@ -154,9 +160,9 @@ showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = "org.jsoup:jsoup:1.17.2" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } -molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.3" +molecule-runtime = "app.cash.molecule:molecule-runtime:2.0.0" timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "chat.schildi.rustcomponents:sdk-android:0.2.18" +matrix_sdk = "chat.schildi.rustcomponents:sdk-android:0.2.19" matrix_richtexteditor = { module = "chat.schildi:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "chat.schildi:wysiwyg-compose", version.ref = "wysiwyg" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } @@ -170,17 +176,18 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.1" -maplibre = "org.maplibre.gl:android-sdk:10.3.1" -maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2" -maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.2" +maplibre = "org.maplibre.gl:android-sdk:11.0.0" +maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.0" +maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0" opusencoder = "io.element.android:opusencoder:1.1.0" -kotlinpoet = "com.squareup:kotlinpoet:1.16.0" +kotlinpoet = "com.squareup:kotlinpoet:1.17.0" +zxing_cpp = "io.github.zxing-cpp:android:2.2.0" # Analytics -posthog = "com.posthog:posthog-android:3.2.1" +posthog = "com.posthog:posthog-android:3.3.0" sentry = "io.sentry:sentry-android:7.9.0" # main branch can be tested replacing the version with main-SNAPSHOT -matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.21.0" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.23.1" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.1.3" @@ -203,7 +210,7 @@ google_autoservice_annotations = { module = "com.google.auto.service:auto-servic android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -appcompat = "androidx.appcompat:appcompat:1.6.1" +appcompat = "androidx.appcompat:appcompat:1.7.0" # SC additions skydoves_colorpicker = "com.github.skydoves:colorpicker-compose:1.0.5" @@ -225,10 +232,10 @@ anvil = { id = "com.squareup.anvil", version.ref = "anvil" } detekt = "io.gitlab.arturbosch.detekt:1.23.6" ktlint = "org.jlleitschuh.gradle.ktlint:12.1.1" dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" -dependencycheck = "org.owasp.dependencycheck:9.1.0" +dependencycheck = "org.owasp.dependencycheck:9.2.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } -paparazzi = "app.cash.paparazzi:1.3.3" +paparazzi = "app.cash.paparazzi:1.3.4" sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } -sonarqube = "org.sonarqube:4.4.1.3373" +sonarqube = "org.sonarqube:5.0.0.4638" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fcbbad6dd6..515ab9d5f1 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=194717442575a6f96e1c1befa2c30e9a4fc90f701d7aee33eb879b79e7ff05c0 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionSha256Sum=f8b4f4772d302c8ff580bc40d0f56e715de69b163546944f787c87abf209c961 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4269..b740cf1339 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt index 3c897b3d88..7fe91eb2e9 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/compat/Compat.kt @@ -16,10 +16,22 @@ package io.element.android.libraries.androidutils.compat +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.content.pm.ResolveInfo import android.os.Build +fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities( + data, + PackageManager.ResolveInfoFlags.of(flags.toLong()) + ) + else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags) + } +} + fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo { return when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo( diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 737eab7ac7..49e055cc29 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -28,6 +28,7 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi +import androidx.core.content.pm.PackageInfoCompat import io.element.android.libraries.androidutils.R import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat import io.element.android.libraries.core.mimetype.MimeTypes @@ -47,6 +48,19 @@ fun Context.getApplicationLabel(packageName: String): String { } } +/** + * Retrieve the versionCode from the Manifest. + * The value is more accurate than BuildConfig.VERSION_CODE, as it is correct according to the + * computation in the `androidComponents` block of the app build.gradle.kts file. + * In other words, the last digit (for the architecture) will be set, whereas BuildConfig.VERSION_CODE + * last digit will always be 0. + */ +fun Context.getVersionCodeFromManifest(): Long { + return PackageInfoCompat.getLongVersionCode( + packageManager.getPackageInfo(packageName, 0) + ) +} + // ============================================================================================================== // Clipboard helper // ============================================================================================================== diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt index 75bb503390..dab7a5f3b1 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt @@ -91,6 +91,8 @@ sealed interface AsyncData { fun isSuccess(): Boolean = this is Success fun isUninitialized(): Boolean = this == Uninitialized + + fun isReady() = isSuccess() || isFailure() } suspend inline fun MutableState>.runCatchingUpdatingState( diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt new file mode 100644 index 0000000000..9943b43b6e --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BuildMeta.kt @@ -0,0 +1,29 @@ +/* + * 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.core.extensions + +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType + +fun BuildMeta.isElement(): Boolean { + return when (buildType) { + BuildType.RELEASE -> applicationId == "io.element.android.x" + BuildType.NIGHTLY -> applicationId == "io.element.android.x.nightly" + BuildType.DEBUG -> applicationId == "io.element.android.x.debug" + else -> false + } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt index ad5581f631..a9cb78548f 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt @@ -25,7 +25,7 @@ data class BuildMeta( val applicationId: String, val lowPrivacyLoggingEnabled: Boolean, val versionName: String, - val versionCode: Int, + val versionCode: Long, val gitRevision: String, val gitBranchName: String, val flavorDescription: String, diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index d592bd41e2..1df6d3ca05 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -39,7 +39,9 @@ android { api(projects.schildi.lib) api(projects.schildi.theme) api(libs.compound) - // Should not be there, but this is a POC + + implementation(libs.androidx.compose.material3.windowsizeclass) + implementation(libs.androidx.compose.material3.adaptive) implementation(libs.coil.compose) implementation(libs.vanniktech.blurhash) implementation(projects.libraries.architecture) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt index 54261bfebe..1474899273 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt @@ -45,8 +45,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun DialogLikeBannerMolecule( title: String, content: String, - onSubmitClicked: () -> Unit, - onDismissClicked: (() -> Unit)?, + onSubmitClick: () -> Unit, + onDismissClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { @@ -68,9 +68,9 @@ fun DialogLikeBannerMolecule( color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Start, ) - if (onDismissClicked != null) { + if (onDismissClick != null) { Icon( - modifier = Modifier.clickable(onClick = onDismissClicked), + modifier = Modifier.clickable(onClick = onDismissClick), imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close) ) @@ -86,7 +86,7 @@ fun DialogLikeBannerMolecule( text = stringResource(CommonStrings.action_continue), size = ButtonSize.Medium, modifier = Modifier.fillMaxWidth(), - onClick = onSubmitClicked, + onClick = onSubmitClick, ) } } @@ -99,7 +99,7 @@ internal fun DialogLikeBannerMoleculePreview() = ElementPreview { DialogLikeBannerMolecule( title = "Title", content = "Content", - onSubmitClicked = {}, - onDismissClicked = {} + onSubmitClick = {}, + onDismissClick = {} ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt new file mode 100644 index 0000000000..596995a2af --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/NumberedListMolecule.kt @@ -0,0 +1,67 @@ +/* + * 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.designsystem.atomic.molecules + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun NumberedListMolecule( + index: Int, + text: AnnotatedString, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ItemNumber(index = index) + Text(text = text, style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textPrimary) + } +} + +@Composable +private fun ItemNumber( + index: Int, +) { + val color = ElementTheme.colors.textPlaceholder + Box( + modifier = Modifier + .border(1.dp, color, CircleShape) + .squareSize() + ) { + Text( + modifier = Modifier.padding(1.5.dp), + text = index.toString(), + style = ElementTheme.typography.fontBodySmRegular, + color = color, + textAlign = TextAlign.Center, + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt new file mode 100644 index 0000000000..ccdd875939 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/NumberedListOrganism.kt @@ -0,0 +1,42 @@ +/* + * 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.designsystem.atomic.organisms + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.molecules.NumberedListMolecule +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun NumberedListOrganism( + items: ImmutableList, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + itemsIndexed(items) { index, item -> + NumberedListMolecule(index = index + 1, text = item) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt index 7eb4b6d413..9218061638 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt @@ -25,12 +25,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule -import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -41,7 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar /** * A Page with: - * - a top bar as TobAppBar with optional back button (displayed if [onBackClicked] is not null) + * - a top bar as TobAppBar with optional back button (displayed if [onBackClick] is not null) * - a header, as IconTitleSubtitleMolecule * - a content. * - a footer, as ButtonColumnMolecule @@ -49,34 +49,34 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @OptIn(ExperimentalMaterial3Api::class) @Composable fun FlowStepPage( - iconVector: ImageVector?, + iconStyle: BigIcon.Style, title: String, modifier: Modifier = Modifier, - onBackClicked: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null, subTitle: String? = null, - content: @Composable () -> Unit = {}, buttons: @Composable ColumnScope.() -> Unit = {}, + content: @Composable () -> Unit = {}, ) { - BackHandler(enabled = onBackClicked != null) { - onBackClicked?.invoke() + BackHandler(enabled = onBackClick != null) { + onBackClick?.invoke() } HeaderFooterPage( modifier = modifier, topBar = { TopAppBar( navigationIcon = { - if (onBackClicked != null) { - BackButton(onClick = onBackClicked) + if (onBackClick != null) { + BackButton(onClick = onBackClick) } }, title = {}, ) }, header = { - IconTitleSubtitleMolecule( - iconImageVector = iconVector, + PageTitle( title = title, - subTitle = subTitle, + subtitle = subTitle, + iconStyle = iconStyle, ) }, content = content, @@ -94,25 +94,24 @@ fun FlowStepPage( @Composable internal fun FlowStepPagePreview() = ElementPreview { FlowStepPage( - onBackClicked = {}, + onBackClick = {}, title = "Title", subTitle = "Subtitle", - iconVector = CompoundIcons.Computer(), - content = { - Box( - Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "Content", - style = ElementTheme.typography.fontHeadingXlBold - ) - } - }, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), buttons = { TextButton(text = "A button", onClick = { }) Button(text = "Continue", onClick = { }) } - ) + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Content", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt index eaa3cc1764..544a7f1c8b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt @@ -169,6 +169,7 @@ data class BloomLayer( * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param alpha The alpha value to apply to the bloom effect. */ +@SuppressWarnings("ModifierComposed") fun Modifier.bloom( hash: String?, background: Color, @@ -313,6 +314,7 @@ fun Modifier.bloom( * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param alpha The alpha value to apply to the bloom effect. */ +@SuppressWarnings("ModifierComposed") fun Modifier.avatarBloom( avatarData: AvatarData, background: Color, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt index 37429c9d36..e1872b0a98 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt @@ -67,7 +67,7 @@ fun ProgressDialog( modifier = modifier, text = text, isCancellable = isCancellable, - onCancelClicked = onDismissRequest, + onCancelClick = onDismissRequest, progressIndicator = { when (type) { is ProgressDialogType.Indeterminate -> { @@ -98,7 +98,7 @@ private fun ProgressDialogContent( modifier: Modifier = Modifier, text: String? = null, isCancellable: Boolean = false, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, progressIndicator: @Composable () -> Unit = { CircularProgressIndicator( color = MaterialTheme.colorScheme.primary @@ -133,7 +133,7 @@ private fun ProgressDialogContent( ) { TextButton( text = stringResource(id = CommonStrings.action_cancel), - onClick = onCancelClicked, + onClick = onCancelClick, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt index f82553d731..5bd3c045cf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt @@ -99,7 +99,7 @@ internal fun AsyncActionViewPreview( ConfirmationDialog( title = "Confirmation", content = "Are you sure?", - onSubmitClicked = {}, + onSubmitClick = {}, onDismiss = {}, ) }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index 49d3c98b4f..13e514c63a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ConfirmationDialog( content: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, title: String? = null, @@ -42,8 +42,9 @@ fun ConfirmationDialog( cancelText: String = stringResource(id = CommonStrings.action_cancel), destructiveSubmit: Boolean = false, thirdButtonText: String? = null, - onCancelClicked: () -> Unit = onDismiss, - onThirdButtonClicked: () -> Unit = {}, + onCancelClick: () -> Unit = onDismiss, + onThirdButtonClick: () -> Unit = {}, + icon: @Composable (() -> Unit)? = null, ) { BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) { ConfirmationDialogContent( @@ -53,9 +54,10 @@ fun ConfirmationDialog( cancelText = cancelText, thirdButtonText = thirdButtonText, destructiveSubmit = destructiveSubmit, - onSubmitClicked = onSubmitClicked, - onCancelClicked = onCancelClicked, - onThirdButtonClicked = onThirdButtonClicked, + onSubmitClick = onSubmitClick, + onCancelClick = onCancelClick, + onThirdButtonClick = onThirdButtonClick, + icon = icon, ) } } @@ -65,11 +67,11 @@ private fun ConfirmationDialogContent( content: String, submitText: String, cancelText: String, - onSubmitClicked: () -> Unit, - onCancelClicked: () -> Unit, + onSubmitClick: () -> Unit, + onCancelClick: () -> Unit, title: String? = null, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, destructiveSubmit: Boolean = false, icon: @Composable (() -> Unit)? = null, ) { @@ -77,11 +79,11 @@ private fun ConfirmationDialogContent( title = title, content = content, submitText = submitText, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, cancelText = cancelText, - onCancelClicked = onCancelClicked, + onCancelClick = onCancelClick, thirdButtonText = thirdButtonText, - onThirdButtonClicked = onThirdButtonClicked, + onThirdButtonClick = onThirdButtonClick, destructiveSubmit = destructiveSubmit, icon = icon, ) @@ -98,9 +100,9 @@ internal fun ConfirmationDialogContentPreview() = submitText = "OK", cancelText = "Cancel", thirdButtonText = "Disable", - onSubmitClicked = {}, - onCancelClicked = {}, - onThirdButtonClicked = {}, + onSubmitClick = {}, + onCancelClick = {}, + onThirdButtonClick = {}, ) } } @@ -114,7 +116,7 @@ internal fun ConfirmationDialogPreview() = ElementPreview { submitText = "OK", cancelText = "Cancel", thirdButtonText = "Disable", - onSubmitClicked = {}, + onSubmitClick = {}, onDismiss = {} ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index d8e9cbe1da..977246d9df 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -44,7 +44,7 @@ fun ErrorDialog( title = title, content = content, submitText = submitText, - onSubmitClicked = onDismiss, + onSubmitClick = onDismiss, ) } } @@ -52,7 +52,7 @@ fun ErrorDialog( @Composable private fun ErrorDialogContent( content: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, ) { @@ -60,7 +60,7 @@ private fun ErrorDialogContent( title = title, content = content, submitText = submitText, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, ) } @@ -76,7 +76,7 @@ internal fun ErrorDialogContentPreview() { DialogPreview { ErrorDialogContent( content = "Content", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index 4e44ce805d..1d06e6409c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -67,7 +67,7 @@ fun ListDialog( cancelText = cancelText, submitText = submitText, onDismissRequest = onDismissRequest, - onSubmitClicked = onSubmit, + onSubmitClick = onSubmit, enabled = enabled, listItems = listItems, ) @@ -78,7 +78,7 @@ fun ListDialog( private fun ListDialogContent( listItems: LazyListScope.() -> Unit, onDismissRequest: () -> Unit, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, cancelText: String, submitText: String, title: String? = null, @@ -90,8 +90,8 @@ private fun ListDialogContent( subtitle = subtitle, cancelText = cancelText, submitText = submitText, - onCancelClicked = onDismissRequest, - onSubmitClicked = onSubmitClicked, + onCancelClick = onDismissRequest, + onSubmitClick = onSubmitClick, enabled = enabled, applyPaddingToContents = false, ) { @@ -109,15 +109,15 @@ internal fun ListDialogContentPreview() { ListDialogContent( listItems = { item { - TextFieldListItem(placeholder = "Text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) } item { - TextFieldListItem(placeholder = "Another text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) } }, title = "Dialog title", onDismissRequest = {}, - onSubmitClicked = {}, + onSubmitClick = {}, cancelText = "Cancel", submitText = "Save", ) @@ -131,10 +131,10 @@ internal fun ListDialogPreview() = ElementPreview { ListDialog( listItems = { item { - TextFieldListItem(placeholder = "Text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) } item { - TextFieldListItem(placeholder = "Another text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) } }, title = "Dialog title", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt index 183ab92522..74d38f99bc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt @@ -44,7 +44,7 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun MultipleSelectionDialog( options: ImmutableList, - onConfirmClicked: (List) -> Unit, + onConfirmClick: (List) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, confirmButtonTitle: String = stringResource(CommonStrings.action_confirm), @@ -70,7 +70,7 @@ fun MultipleSelectionDialog( subtitle = decoratedSubtitle, options = options, confirmButtonTitle = confirmButtonTitle, - onConfirmClicked = onConfirmClicked, + onConfirmClick = onConfirmClick, dismissButtonTitle = dismissButtonTitle, onDismissRequest = onDismissRequest, initialSelected = initialSelection, @@ -82,7 +82,7 @@ fun MultipleSelectionDialog( private fun MultipleSelectionDialogContent( options: ImmutableList, confirmButtonTitle: String, - onConfirmClicked: (List) -> Unit, + onConfirmClick: (List) -> Unit, dismissButtonTitle: String, onDismissRequest: () -> Unit, title: String? = null, @@ -97,11 +97,11 @@ private fun MultipleSelectionDialogContent( title = title, subtitle = subtitle, submitText = confirmButtonTitle, - onSubmitClicked = { - onConfirmClicked(selectedOptionIndexes.toList()) + onSubmitClick = { + onConfirmClick(selectedOptionIndexes.toList()) }, cancelText = dismissButtonTitle, - onCancelClicked = onDismissRequest, + onCancelClick = onDismissRequest, applyPaddingToContents = false, ) { LazyColumn { @@ -138,7 +138,7 @@ internal fun MultipleSelectionDialogContentPreview() { MultipleSelectionDialogContent( title = "Dialog title", options = options, - onConfirmClicked = {}, + onConfirmClick = {}, onDismissRequest = {}, confirmButtonTitle = "Save", dismissButtonTitle = "Cancel", @@ -159,7 +159,7 @@ internal fun MultipleSelectionDialogPreview() = ElementPreview { MultipleSelectionDialog( title = "Dialog title", options = options, - onConfirmClicked = {}, + onConfirmClick = {}, onDismissRequest = {}, confirmButtonTitle = "Save", dismissButtonTitle = "Cancel", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt index e3884c58dc..d46b2c932b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -66,9 +66,9 @@ private fun RetryDialogContent( title = title, content = content, submitText = retryText, - onSubmitClicked = onRetry, + onSubmitClick = onRetry, cancelText = dismissText, - onCancelClicked = onDismiss, + onCancelClick = onDismiss, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt index a23a8068a9..813e4d92b9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt @@ -41,7 +41,7 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun SingleSelectionDialog( options: ImmutableList, - onOptionSelected: (Int) -> Unit, + onSelectOption: (Int) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, title: String? = null, @@ -65,7 +65,7 @@ fun SingleSelectionDialog( title = title, subtitle = decoratedSubtitle, options = options, - onOptionSelected = onOptionSelected, + onOptionClick = onSelectOption, dismissButtonTitle = dismissButtonTitle, onDismissRequest = onDismissRequest, initialSelection = initialSelection, @@ -76,7 +76,7 @@ fun SingleSelectionDialog( @Composable private fun SingleSelectionDialogContent( options: ImmutableList, - onOptionSelected: (Int) -> Unit, + onOptionClick: (Int) -> Unit, dismissButtonTitle: String, onDismissRequest: () -> Unit, title: String? = null, @@ -87,7 +87,7 @@ private fun SingleSelectionDialogContent( title = title, subtitle = subtitle, submitText = dismissButtonTitle, - onSubmitClicked = onDismissRequest, + onSubmitClick = onDismissRequest, applyPaddingToContents = false, ) { LazyColumn { @@ -96,7 +96,7 @@ private fun SingleSelectionDialogContent( headline = option.title, supportingText = option.subtitle, selected = index == initialSelection, - onSelected = { onOptionSelected(index) }, + onSelect = { onOptionClick(index) }, compactLayout = true, modifier = Modifier.padding(start = 8.dp) ) @@ -118,7 +118,7 @@ internal fun SingleSelectionDialogContentPreview() { SingleSelectionDialogContent( title = "Dialog title", options = options, - onOptionSelected = {}, + onOptionClick = {}, onDismissRequest = {}, dismissButtonTitle = "Cancel", initialSelection = 0 @@ -138,7 +138,7 @@ internal fun SingleSelectionDialogPreview() = ElementPreview { SingleSelectionDialog( title = "Dialog title", options = options, - onOptionSelected = {}, + onSelectOption = {}, onDismissRequest = {}, dismissButtonTitle = "Cancel", initialSelection = 0 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt index 8a0bc1b4a9..638930ab24 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt @@ -41,7 +41,7 @@ import kotlinx.collections.immutable.toImmutableList fun MultipleSelectionListItem( headline: String, options: ImmutableList, - onSelectionChanged: (List) -> Unit, + onSelectionChange: (List) -> Unit, resultFormatter: (List) -> String?, modifier: Modifier = Modifier, supportingText: String? = null, @@ -87,9 +87,9 @@ fun MultipleSelectionListItem( MultipleSelectionDialog( title = headline, options = options, - onConfirmClicked = { newSelectedIndexes -> + onConfirmClick = { newSelectedIndexes -> if (newSelectedIndexes != selectedIndexes.toList()) { - onSelectionChanged(newSelectedIndexes) + onSelectionChange(newSelectedIndexes) selectedIndexes.clear() selectedIndexes.addAll(newSelectedIndexes) } @@ -109,7 +109,7 @@ internal fun MutipleSelectionListItemPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { result -> formatResult(result, options) }, ) @@ -125,7 +125,7 @@ internal fun MutipleSelectionListItemSelectedPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { val selectedValues = formatResult(it, options) @@ -145,7 +145,7 @@ internal fun MutipleSelectionListItemSelectedTrailingContentPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { selected.size.toString() }, displayResultInTrailingContent = true, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt index fff038121e..fcef47734b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.Text fun RadioButtonListItem( headline: String, selected: Boolean, - onSelected: () -> Unit, + onSelect: () -> Unit, modifier: Modifier = Modifier, supportingText: String? = null, trailingContent: ListItemContent? = null, @@ -42,6 +42,6 @@ fun RadioButtonListItem( trailingContent = trailingContent, style = style, enabled = enabled, - onClick = onSelected, + onClick = onSelect, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt index d119025b9b..82887a2430 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt @@ -42,7 +42,7 @@ import kotlin.time.Duration.Companion.seconds fun SingleSelectionListItem( headline: String, options: ImmutableList, - onSelectionChanged: (Int) -> Unit, + onSelectionChange: (Int) -> Unit, modifier: Modifier = Modifier, supportingText: String? = null, leadingContent: ListItemContent? = null, @@ -86,9 +86,9 @@ fun SingleSelectionListItem( SingleSelectionDialog( title = headline, options = options, - onOptionSelected = { index -> + onSelectOption = { index -> if (index != selectedIndex) { - onSelectionChanged(index) + onSelectionChange(index) selectedIndex = index } // Delay hiding the dialog for a bit so the new state is displayed in it before being dismissed @@ -110,7 +110,7 @@ internal fun SingleSelectionListItemPreview() { SingleSelectionListItem( headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), - onSelectionChanged = {}, + onSelectionChange = {}, ) } } @@ -123,7 +123,7 @@ internal fun SingleSelectionListItemUnselectedWithSupportingTextPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, ) } } @@ -136,7 +136,7 @@ internal fun SingleSelectionListItemSelectedInSupportingTextPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, selected = 1, ) } @@ -150,7 +150,7 @@ internal fun SingleSelectionListItemSelectedInTrailingContentPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, selected = 1, displayResultInTrailingContent = true, ) @@ -165,7 +165,7 @@ internal fun SingleSelectionListItemCustomFormattertPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, resultFormatter = { "Selected index: $it" }, selected = 1, displayResultInTrailingContent = true, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index f0e9458a02..770d745b77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Text fun TextFieldListItem( placeholder: String?, text: String, - onTextChanged: (String) -> Unit, + onTextChange: (String) -> Unit, modifier: Modifier = Modifier, error: String? = null, maxLines: Int = 1, @@ -45,7 +45,7 @@ fun TextFieldListItem( OutlinedTextField( value = text, - onValueChange = { onTextChanged(it) }, + onValueChange = { onTextChange(it) }, placeholder = placeholder?.let { @Composable { Text(it) } }, colors = OutlinedTextFieldDefaults.colors( disabledBorderColor = Color.Transparent, @@ -68,7 +68,7 @@ fun TextFieldListItem( fun TextFieldListItem( placeholder: String?, text: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, + onTextChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, error: String? = null, maxLines: Int = 1, @@ -79,7 +79,7 @@ fun TextFieldListItem( OutlinedTextField( value = text, - onValueChange = { onTextChanged(it) }, + onValueChange = { onTextChange(it) }, placeholder = placeholder?.let { @Composable { Text(it) } }, colors = OutlinedTextFieldDefaults.colors( disabledBorderColor = Color.Transparent, @@ -105,7 +105,7 @@ internal fun TextFieldListItemEmptyPreview() { TextFieldListItem( placeholder = "Placeholder", text = "", - onTextChanged = {}, + onTextChange = {}, ) } } @@ -117,7 +117,7 @@ internal fun TextFieldListItemPreview() { TextFieldListItem( placeholder = "Placeholder", text = "Text", - onTextChanged = {}, + onTextChange = {}, ) } } @@ -129,7 +129,7 @@ internal fun TextFieldListItemTextFieldValuePreview() { TextFieldListItem( placeholder = "Placeholder", text = TextFieldValue("Text field value"), - onTextChanged = {}, + onTextChange = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt index f86177d98e..bae1301615 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt @@ -19,22 +19,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader @Composable fun PreferenceCategory( modifier: Modifier = Modifier, title: String? = null, - showDivider: Boolean = true, + showTopDivider: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -42,30 +39,17 @@ fun PreferenceCategory( .fillMaxWidth() ) { if (title != null) { - PreferenceCategoryTitle(title = title) - } - content() - if (showDivider) { + ListSectionHeader( + title = title, + hasDivider = showTopDivider, + ) + } else if (showTopDivider) { PreferenceDivider() } + content() } } -@Composable -private fun PreferenceCategoryTitle(title: String) { - Text( - modifier = Modifier.padding( - top = 20.dp, - bottom = 8.dp, - start = preferencePaddingHorizontal, - end = preferencePaddingHorizontal, - ), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.materialColors.primary, - text = title, - ) -} - @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceCategoryPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt index cebe0ddbe2..99c1ba3f6b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -17,25 +17,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor @@ -52,45 +45,36 @@ fun PreferenceCheckbox( @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = enabled.toEnabledColor(), ) - if (supportingText != null) { + }, + supportingContent = supportingText?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, - text = supportingText, + text = it, color = enabled.toSecondaryEnabledColor(), ) } - } - Checkbox( - modifier = Modifier - .align(Alignment.CenterVertically), + }, + trailingContent = ListItemContent.Checkbox( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange - ) - } + ), + ) } @Preview(group = PreviewGroup.Preferences) @@ -112,5 +96,31 @@ internal fun PreferenceCheckboxPreview() = ElementThemedPreview { isChecked = true, onCheckedChange = {}, ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = true, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = false, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt index 27a846aa10..57bd88b637 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -28,10 +27,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi fun PreferenceDivider( modifier: Modifier = Modifier, ) { - HorizontalDivider( - modifier = modifier, - color = ElementTheme.colors.borderDisabled, - ) + HorizontalDivider(modifier = modifier) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt index b02cf157d8..e2dca9641d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt @@ -44,7 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun PreferencePage( title: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, snackbarHost: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit, @@ -58,7 +58,7 @@ fun PreferencePage( topBar = { PreferenceTopAppBar( title = title, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) }, snackbarHost = snackbarHost, @@ -79,11 +79,11 @@ fun PreferencePage( @Composable private fun PreferenceTopAppBar( title: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -101,7 +101,7 @@ private fun PreferenceTopAppBar( internal fun PreferencePagePreview() = ElementPreview { PreferencePage( title = "Preference screen", - onBackPressed = {}, + onBackClick = {}, ) { PreferenceCategory( title = "Category title", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt index 80813a2d1a..e217f5150a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -19,14 +19,13 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text /** @@ -37,15 +36,17 @@ fun PreferenceRow( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, ) { - Row( - modifier = modifier - .padding(horizontal = preferencePaddingHorizontal) - .heightIn(min = preferenceMinHeight) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - content() - } + ListItem( + modifier = modifier, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } + } + ) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt index 392343f48e..9173c1e193 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -19,23 +19,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.toEnabledColor @Composable fun PreferenceSlide( @@ -51,51 +46,57 @@ fun PreferenceSlide( summary: String? = null, steps: Int = 0, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - isVisible = showIconAreaIfNoIcon, - ) - Column( - modifier = Modifier - .weight(1f), - ) { - Text( - style = ElementTheme.typography.fontBodyLgRegular, - text = title, - color = enabled.toEnabledColor(), - ) - summary?.let { + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Column { Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = summary, - color = enabled.toEnabledColor(), + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + ) + summary?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = summary, + ) + } + Slider( + value = value, + steps = steps, + onValueChange = onValueChange, + enabled = enabled, ) } - Slider( - value = value, - steps = steps, - onValueChange = onValueChange, - enabled = enabled, - ) } - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSlidePreview() = ElementThemedPreview { - PreferenceSlide( - icon = CompoundIcons.UserProfile(), - title = "Slide", - summary = "Summary", - value = 0.75F, - onValueChange = {}, - ) + Column { + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = true, + value = 0.75F, + onValueChange = {}, + ) + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = false, + value = 0.75F, + onValueChange = {}, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt index e79e915484..388dcec23f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -17,30 +17,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Switch +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.toEnabledColor -import io.element.android.libraries.designsystem.toSecondaryEnabledColor @Composable fun PreferenceSwitch( @@ -53,62 +42,65 @@ fun PreferenceSwitch( icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, - switchAlignment: Alignment.Vertical = Alignment.CenterVertically ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, - color = enabled.toEnabledColor(), ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(4.dp)) + }, + supportingContent = subtitle?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, - color = enabled.toSecondaryEnabledColor(), ) } - } - Spacer(modifier = Modifier.width(16.dp)) - // TODO Create a wrapper for Switch - Switch( - modifier = Modifier - .align(switchAlignment), + }, + trailingContent = ListItemContent.Switch( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange ) - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSwitchPreview() = ElementThemedPreview { - PreferenceSwitch( - title = "Switch", - subtitle = "Subtitle Switch", - icon = CompoundIcons.Threads(), - enabled = true, - isChecked = true, - onCheckedChange = {}, - ) + Column { + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch no subtitle", + subtitle = null, + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index e8a859b54a..85f1c10b21 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -17,12 +17,9 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics @@ -38,18 +35,17 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor -/** - * Tried to use ListItem, but it cannot really match the design. Keep custom Layout for now. - */ @Composable fun PreferenceText( title: String, @@ -67,76 +63,76 @@ fun PreferenceText( tintColor: Color? = null, onClick: () -> Unit = {}, ) { - val minHeight = if (subtitle == null && subtitleAnnotated == null) preferenceMinHeightOnlyTitle else preferenceMinHeight - - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = minHeight) - .clickable { onClick() } - .padding(horizontal = preferencePaddingHorizontal, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onClick, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, showIconBadge = showIconBadge, enabled = enabled, - isVisible = showIconAreaIfNoIcon, - tintColor = tintColor ?: enabled.toSecondaryEnabledColor(), - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + tintColor = tintColor, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = tintColor ?: enabled.toEnabledColor(), ) - if (subtitle != null) { + }, + supportingContent = if (subtitle != null) { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, color = tintColor ?: enabled.toSecondaryEnabledColor(), ) - } else if (subtitleAnnotated != null) { - Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = subtitleAnnotated, - color = tintColor ?: enabled.toSecondaryEnabledColor(), - ) } + } else { + subtitleAnnotated?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = tintColor ?: enabled.toSecondaryEnabledColor(), + ) + } + } + }, + trailingContent = if (currentValue != null || loadingCurrentValue || showEndBadge) { + ListItemContent.Custom { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentValue != null) { + Text( + text = currentValue, + style = ElementTheme.typography.fontBodyXsMedium, + color = enabled.toSecondaryEnabledColor(), + ) + } else if (loadingCurrentValue) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + if (showEndBadge) { + val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 16.dp else 0.dp + RedIndicatorAtom( + modifier = Modifier + .padding(start = endBadgeStartPadding) + ) + } + } + } + } else { + null } - if (currentValue != null) { - Text( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 16.dp, end = 8.dp), - text = currentValue, - style = ElementTheme.typography.fontBodyXsMedium, - color = enabled.toSecondaryEnabledColor(), - ) - } else if (loadingCurrentValue) { - CircularProgressIndicator( - modifier = Modifier - .progressSemantics() - .padding(start = 16.dp, end = 8.dp) - .size(20.dp) - .align(Alignment.CenterVertically), - strokeWidth = 2.dp - ) - } - if (showEndBadge) { - val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 8.dp else 16.dp - RedIndicatorAtom( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = endBadgeStartPadding) - ) - } - } + ) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt index 5b319e41d8..78cf40030e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -114,7 +114,7 @@ private fun TextFieldDialog( TextFieldListItem( placeholder = placeholder.orEmpty(), text = textFieldContents, - onTextChanged = { + onTextChange = { error = if (!validation(it.text)) onValidationErrorMessage else null textFieldContents = it }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt index 61e8aabdd7..f2f1e5ffb7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences.compone import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -31,13 +30,39 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.toSecondaryEnabledColor @Composable -fun PreferenceIcon( +fun preferenceIcon( + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconBadge: Boolean = false, + tintColor: Color? = null, + enabled: Boolean = true, + showIconAreaIfNoIcon: Boolean = false, +): ListItemContent.Custom? { + return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) { + ListItemContent.Custom { + PreferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + showIconBadge = showIconBadge, + enabled = enabled, + isVisible = showIconAreaIfNoIcon, + tintColor = tintColor, + ) + } + } else { + null + } +} + +@Composable +private fun PreferenceIcon( modifier: Modifier = Modifier, icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, @@ -54,19 +79,17 @@ fun PreferenceIcon( contentDescription = null, tint = tintColor ?: enabled.toSecondaryEnabledColor(), modifier = Modifier - .padding(end = 16.dp) .size(24.dp), ) if (showIconBadge) { RedIndicatorAtom( modifier = Modifier .align(Alignment.TopEnd) - .padding(end = 16.dp) ) } } } else if (isVisible) { - Spacer(modifier = modifier.width(40.dp)) + Spacer(modifier = modifier.width(24.dp)) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt index a18d0ef3ed..3a4a433821 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt @@ -16,30 +16,26 @@ package io.element.android.libraries.designsystem.modifiers -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable /** * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. */ -@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas fun Modifier.applyIf( condition: Boolean, - ifTrue: @Composable Modifier.() -> Modifier, - ifFalse: @Composable (Modifier.() -> Modifier)? = null -): Modifier = - composed( - inspectorInfo = debugInspectorInfo { - name = "applyIf" - value = condition - } - ) { - when { - condition -> then(ifTrue(Modifier)) - ifFalse != null -> then(ifFalse(Modifier)) - else -> this - } + ifTrue: Modifier.() -> Modifier, + ifFalse: (Modifier.() -> Modifier)? = null +): Modifier = this then inspectable( + inspectorInfo = debugInspectorInfo { + name = "applyIf" + value = condition } +) { + this then when { + condition -> ifTrue(Modifier) + ifFalse != null -> ifFalse(Modifier) + else -> Modifier + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt new file mode 100644 index 0000000000..b8d60f9acf --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/CornerBorder.kt @@ -0,0 +1,74 @@ +/* + * 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.designsystem.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import io.element.android.libraries.designsystem.text.toPx + +/** + * Draw a border on corners around the content. + */ +@Suppress("ModifierComposed") +fun Modifier.cornerBorder( + strokeWidth: Dp, + color: Color, + cornerSizeDp: Dp, +) = composed( + factory = { + val strokeWidthPx = strokeWidth.toPx() + val cornerSize = cornerSizeDp.toPx() + drawWithContent { + drawContent() + val width = size.width + val height = size.height + drawPath( + path = Path().apply { + // Top left corner + moveTo(0f, cornerSize) + lineTo(0f, 0f) + lineTo(cornerSize, 0f) + // Top right corner + moveTo(width - cornerSize, 0f) + lineTo(width, 0f) + lineTo(width, cornerSize) + // Bottom right corner + moveTo(width, height - cornerSize) + lineTo(width, height) + lineTo(width - cornerSize, height) + // Bottom left corner + moveTo(cornerSize, height) + lineTo(0f, height) + lineTo(0f, height - cornerSize) + }, + color = color, + style = Stroke( + width = strokeWidthPx, + pathEffect = PathEffect.cornerPathEffect(strokeWidthPx / 2), + cap = StrokeCap.Round, + ), + ) + } + } +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt index 4927a195e0..7f27927030 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -54,14 +55,14 @@ import kotlin.math.max internal fun SimpleAlertDialogContent( content: String, submitText: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String? = null, subtitle: @Composable (() -> Unit)? = null, destructiveSubmit: Boolean = false, cancelText: String? = null, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, applyPaddingToContents: Boolean = true, icon: @Composable (() -> Unit)? = null, ) { @@ -77,11 +78,11 @@ internal fun SimpleAlertDialogContent( }, submitText = submitText, destructiveSubmit = destructiveSubmit, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, cancelText = cancelText, - onCancelClicked = onCancelClicked, + onCancelClick = onCancelClick, thirdButtonText = thirdButtonText, - onThirdButtonClicked = onThirdButtonClicked, + onThirdButtonClick = onThirdButtonClick, applyPaddingToContents = applyPaddingToContents, ) } @@ -89,14 +90,14 @@ internal fun SimpleAlertDialogContent( @Composable internal fun SimpleAlertDialogContent( submitText: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String? = null, subtitle: @Composable (() -> Unit)? = null, destructiveSubmit: Boolean = false, cancelText: String? = null, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, applyPaddingToContents: Boolean = true, enabled: Boolean = true, icon: @Composable (() -> Unit)? = null, @@ -115,7 +116,7 @@ internal fun SimpleAlertDialogContent( modifier = Modifier.testTag(TestTags.dialogNeutral), text = thirdButtonText, size = ButtonSize.Medium, - onClick = onThirdButtonClicked, + onClick = onThirdButtonClick, ) } if (cancelText != null) { @@ -123,14 +124,14 @@ internal fun SimpleAlertDialogContent( modifier = Modifier.testTag(TestTags.dialogNegative), text = cancelText, size = ButtonSize.Medium, - onClick = onCancelClicked, + onClick = onCancelClick, ) Button( modifier = Modifier.testTag(TestTags.dialogPositive), text = submitText, enabled = enabled, size = ButtonSize.Medium, - onClick = onSubmitClicked, + onClick = onSubmitClick, destructive = destructiveSubmit, ) } else { @@ -139,7 +140,7 @@ internal fun SimpleAlertDialogContent( text = submitText, enabled = enabled, size = ButtonSize.Medium, - onClick = onSubmitClicked, + onClick = onSubmitClick, destructive = destructiveSubmit, ) } @@ -150,6 +151,7 @@ internal fun SimpleAlertDialogContent( Text( text = titleText, style = ElementTheme.typography.fontHeadingSmMedium, + textAlign = TextAlign.Center, ) } }, @@ -174,6 +176,7 @@ internal fun SimpleAlertDialogContent( /** * Copy of M3's `AlertDialogContent` so we can use it for previews. */ +@Suppress("ContentTrailingLambda") @Composable internal fun AlertDialogContent( buttons: @Composable () -> Unit, @@ -444,7 +447,7 @@ internal fun DialogWithTitleIconAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -461,7 +464,7 @@ internal fun DialogWithTitleAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -477,7 +480,7 @@ internal fun DialogWithOnlyMessageAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -494,7 +497,7 @@ internal fun DialogWithDestructiveButtonPreview() { cancelText = "Cancel", submitText = "Delete", destructiveSubmit = true, - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -511,7 +514,7 @@ internal fun DialogWithThirdButtonPreview() { cancelText = "Cancel", submitText = "Delete", thirdButtonText = "Other", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt index 65f43bb250..182ab961d6 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -39,7 +39,7 @@ fun Slider( valueRange: ClosedFloatingPointRange = 0f..1f, // @IntRange(from = 0) steps: Int = 0, - onValueChangeFinished: (() -> Unit)? = null, + onValueChangeFinish: (() -> Unit)? = null, colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { @@ -50,7 +50,7 @@ fun Slider( enabled = enabled, valueRange = valueRange, steps = steps, - onValueChangeFinished = onValueChangeFinished, + onValueChangeFinished = onValueChangeFinish, colors = colors, interactionSource = interactionSource, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index f2c004d2ca..10fb4a2906 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -213,6 +213,7 @@ private fun TextFieldValueContentToPreview() { } } +@Suppress("ModifierComposed") @OptIn(ExperimentalComposeUiApi::class) fun Modifier.autofill(autofillTypes: List, onFill: (String) -> Unit) = composed { val autofillNode = AutofillNode(autofillTypes, onFill = onFill) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt new file mode 100644 index 0000000000..562b0033b4 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/AnnotatedString.kt @@ -0,0 +1,36 @@ +/* + * 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.designsystem.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight + +@Composable +fun annotatedTextWithBold(text: String, boldText: String): AnnotatedString { + return buildAnnotatedString { + append(text) + val start = text.indexOf(boldText) + val end = start + boldText.length + val textRange = 0..text.length + if (start in textRange && end in textRange) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientation.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientation.kt new file mode 100644 index 0000000000..e3a349d479 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientation.kt @@ -0,0 +1,41 @@ +/* + * 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.designsystem.utils + +import android.content.pm.ActivityInfo +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +fun ForceOrientation(orientation: ScreenOrientation) { + val activity = LocalContext.current as? ComponentActivity ?: return + val orientationFlags = when (orientation) { + ScreenOrientation.PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + ScreenOrientation.LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + DisposableEffect(orientation) { + activity.requestedOrientation = orientationFlags + onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } + } +} + +enum class ScreenOrientation { + PORTRAIT, + LANDSCAPE +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt new file mode 100644 index 0000000000..0cab1f406a --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceOrientationInMobileDevices.kt @@ -0,0 +1,34 @@ +/* + * 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.designsystem.utils + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ForceOrientationInMobileDevices(orientation: ScreenOrientation) { + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + if (windowAdaptiveInfo.windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact || + windowAdaptiveInfo.windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact + ) { + ForceOrientation(orientation = orientation) + } +} diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTests.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt similarity index 99% rename from libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTests.kt rename to libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt index d82441b964..e001238aa6 100644 --- a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTests.kt +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/component/async/AsyncIndicatorTest.kt @@ -39,7 +39,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) -class AsyncIndicatorTests { +class AsyncIndicatorTest { @Test fun `initial state`() = runTest { val state = AsyncIndicatorState() diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTests.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt similarity index 98% rename from libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTests.kt rename to libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt index 68cded1601..0436424637 100644 --- a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTests.kt +++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt @@ -21,7 +21,7 @@ import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest import org.junit.Test -class SnackbarDispatcherTests { +class SnackbarDispatcherTest { @Test fun `given an empty queue the flow emits a null item`() = runTest { val snackbarDispatcher = SnackbarDispatcher() diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt index f5e151b541..5255fcea4a 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.timeline.aPollContent import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent @@ -106,7 +107,7 @@ class DefaultRoomLastMessageFormatterTest { fun `Sticker content`() { val body = "body" val info = ImageInfo(null, null, null, null, null, null, null) - val message = createRoomEvent(false, null, StickerContent(body, info, "url")) + val message = createRoomEvent(false, null, StickerContent(body, info, aMediaSource(url = "url"))) val result = formatter.format(message, false) assertThat(result).isEqualTo(body) } diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 98e3818ad3..4d0c50a533 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -81,5 +81,26 @@ enum class FeatureFlags( description = "Allow user to search for public rooms in their homeserver", defaultValue = false, isFinished = false, - ) + ), + ShowBlockedUsersDetails( + key = "feature.showBlockedUsersDetails", + title = "Show blocked users details", + description = "Show the name and avatar of blocked users in the blocked users list", + defaultValue = false, + isFinished = false, + ), + QrCodeLogin( + key = "feature.qrCodeLogin", + title = "Enable login using QR code", + description = "Allow the user to login using the QR code flow", + defaultValue = true, + isFinished = false, + ), + IncomingShare( + key = "feature.incomingShare", + title = "Incoming Share support", + description = "Allow the application to receive data from other applications", + defaultValue = true, + isFinished = false, + ), } diff --git a/libraries/featureflag/impl/build.gradle.kts b/libraries/featureflag/impl/build.gradle.kts index 64fcd1eece..76b6836eb3 100644 --- a/libraries/featureflag/impl/build.gradle.kts +++ b/libraries/featureflag/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { api(projects.libraries.featureflag.api) implementation(libs.dagger) implementation(libs.androidx.datastore.preferences) + implementation(projects.appconfig) implementation(projects.libraries.di) implementation(projects.libraries.core) implementation(libs.coroutines.core) diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt index ac5e0d56cf..0a6b97e13d 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt @@ -39,7 +39,7 @@ class DefaultFeatureFlagService @Inject constructor( } override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { - return providers.filterIsInstance(MutableFeatureFlagProvider::class.java) + return providers.filterIsInstance() .sortedBy(FeatureFlagProvider::priority) .firstOrNull() ?.setFeatureEnabled(feature, enabled) diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt index 2f01633858..7574144066 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.featureflag.impl +import io.element.android.appconfig.OnBoardingConfig import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.FeatureFlags import kotlinx.coroutines.flow.Flow @@ -41,6 +42,9 @@ class StaticFeatureFlagProvider @Inject constructor() : FeatureFlags.Mentions -> true FeatureFlags.MarkAsUnread -> true FeatureFlags.RoomDirectorySearch -> false + FeatureFlags.ShowBlockedUsersDetails -> false + FeatureFlags.QrCodeLogin -> OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE + FeatureFlags.IncomingShare -> true } } else { false diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt index 0c85d3dfb3..a527201b31 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.maplibre.compose import androidx.compose.runtime.Immutable -import com.mapbox.mapboxsdk.location.modes.CameraMode as InternalCameraMode +import org.maplibre.android.location.modes.CameraMode as InternalCameraMode @Immutable public enum class CameraMode { diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt index 10c9d8b69a..697bbed8ed 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt @@ -19,14 +19,14 @@ package io.element.android.libraries.maplibre.compose import androidx.compose.runtime.Immutable -import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_ANIMATION -import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_GESTURE -import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION +import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION +import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE +import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION /** * Enumerates the different reasons why the map camera started to move. * - * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. * * [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed. * @@ -44,11 +44,11 @@ public enum class CameraMoveStartedReason(public val value: Int) { public companion object { /** - * Converts from the Maps SDK [com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener] + * Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener] * constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such * [CameraMoveStartedReason] for the given [value]. * - * See https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener. + * See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. */ public fun fromInt(value: Int): CameraMoveStartedReason { return values().firstOrNull { it.value == value } ?: return UNKNOWN diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt index 114e6acc02..a71ece9732 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt @@ -28,11 +28,11 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -import com.mapbox.mapboxsdk.camera.CameraPosition -import com.mapbox.mapboxsdk.camera.CameraUpdateFactory -import com.mapbox.mapboxsdk.maps.MapboxMap -import com.mapbox.mapboxsdk.maps.Projection import kotlinx.parcelize.Parcelize +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Projection /** * Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver]. @@ -49,7 +49,7 @@ public inline fun rememberCameraPositionState( /** * A state object that can be hoisted to control and observe the map's camera state. - * A [CameraPositionState] may only be used by a single [MapboxMap] composable at a time + * A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time * as it reflects instance state for a single view of a map. * * @param position the initial camera position @@ -143,15 +143,15 @@ public class CameraPositionState( // The map currently associated with this CameraPositionState. // Guarded by `lock`. - private var map: MapboxMap? by mutableStateOf(null) + private var map: MapLibreMap? by mutableStateOf(null) // The current map is set and cleared by side effect. // There can be only one associated at a time. - internal fun setMap(map: MapboxMap?) { + internal fun setMap(map: MapLibreMap?) { synchronized(lock) { if (this.map == null && map == null) return if (this.map != null && map != null) { - error("CameraPositionState may only be associated with one MapboxMap at a time") + error("CameraPositionState may only be associated with one MapLibreMap at a time") } this.map = map if (map == null) { @@ -179,7 +179,7 @@ internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositio /** The current [CameraPositionState] used by the map. */ public val currentCameraPositionState: CameraPositionState - @[MapboxMapComposable ReadOnlyComposable Composable] + @[MapLibreMapComposable ReadOnlyComposable Composable] get() = LocalCameraPositionState.current @Parcelize diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt index 25f6f38c66..cb64f63a44 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.maplibre.compose import androidx.compose.runtime.Immutable -import com.mapbox.mapboxsdk.style.layers.Property +import org.maplibre.android.style.layers.Property @Immutable public enum class IconAnchor { diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt index 650e9d27ef..9b03a1e952 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt @@ -19,9 +19,9 @@ package io.element.android.libraries.maplibre.compose import androidx.compose.runtime.AbstractApplier -import com.mapbox.mapboxsdk.maps.MapboxMap -import com.mapbox.mapboxsdk.maps.Style -import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style +import org.maplibre.android.plugins.annotation.SymbolManager internal interface MapNode { fun onAttached() {} @@ -32,7 +32,7 @@ internal interface MapNode { private object MapNodeRoot : MapNode internal class MapApplier( - val map: MapboxMap, + val map: MapLibreMap, val style: Style, val symbolManager: SymbolManager, ) : AbstractApplier(MapNodeRoot) { diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt similarity index 93% rename from libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt rename to libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt index 4fab4b506f..352585cc1a 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMap.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt @@ -46,14 +46,14 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.mapbox.mapboxsdk.Mapbox -import com.mapbox.mapboxsdk.maps.MapView -import com.mapbox.mapboxsdk.maps.MapboxMap -import com.mapbox.mapboxsdk.maps.Style -import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.awaitCancellation +import org.maplibre.android.MapLibre +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import org.maplibre.android.plugins.annotation.SymbolManager import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -63,7 +63,7 @@ import kotlin.coroutines.suspendCoroutine * Heavily inspired by https://github.com/googlemaps/android-maps-compose * * @param styleUri a URI where to asynchronously fetch a style for the map - * @param modifier Modifier to be applied to the MapboxMap + * @param modifier Modifier to be applied to the MapLibreMap * @param images images added to the map's style to be later used with [Symbol] * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's * camera state @@ -73,7 +73,7 @@ import kotlin.coroutines.suspendCoroutine * @param content the content of the map */ @Composable -public fun MapboxMap( +public fun MapLibreMap( styleUri: String, modifier: Modifier = Modifier, images: ImmutableMap = persistentMapOf(), @@ -82,7 +82,7 @@ public fun MapboxMap( symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, locationSettings: MapLocationSettings = DefaultMapLocationSettings, content: ( - @Composable @MapboxMapComposable + @Composable @MapLibreMapComposable () -> Unit )? = null, ) { @@ -99,7 +99,7 @@ public fun MapboxMap( val context = LocalContext.current val mapView = remember { - Mapbox.getInstance(context) + MapLibre.getInstance(context) MapView(context) } @@ -168,13 +168,13 @@ private suspend inline fun CompositionContext.newComposition( } } -private suspend inline fun MapView.awaitMap(): MapboxMap = suspendCoroutine { continuation -> +private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation -> getMapAsync { map -> continuation.resume(map) } } -private suspend inline fun MapboxMap.awaitStyle( +private suspend inline fun MapLibreMap.awaitStyle( context: Context, styleUri: String, images: ImmutableMap, @@ -227,7 +227,7 @@ private fun MapView.lifecycleObserver(previousState: MutableState { // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in - // this case the MapboxMap composable also doesn't leave the composition. So, + // this case the MapLibreMap composable also doesn't leave the composition. So, // recreating the map does not restore state properly which must be avoided. if (previousState.value != Lifecycle.Event.ON_STOP) { this.onCreate(Bundle()) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt similarity index 83% rename from libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt rename to libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt index 15876b0033..19f5864815 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapboxMapComposable.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt @@ -22,10 +22,10 @@ import androidx.compose.runtime.ComposableTargetMarker /** * An annotation that can be used to mark a composable function as being expected to be use in a - * composable function that is also marked or inferred to be marked as a [MapboxMapComposable]. + * composable function that is also marked or inferred to be marked as a [MapLibreMapComposable]. * - * This will produce build warnings when [MapboxMapComposable] composable functions are used outside - * of a [MapboxMapComposable] content lambda, and vice versa. + * This will produce build warnings when [MapLibreMapComposable] composable functions are used outside + * of a [MapLibreMapComposable] content lambda, and vice versa. */ @Retention(AnnotationRetention.BINARY) @ComposableTargetMarker(description = "MapLibre Map Composable") @@ -36,4 +36,4 @@ import androidx.compose.runtime.ComposableTargetMarker AnnotationTarget.TYPE, AnnotationTarget.TYPE_PARAMETER, ) -public annotation class MapboxMapComposable +public annotation class MapLibreMapComposable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt index 759061ac3d..7a41c9f7b9 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt @@ -26,17 +26,17 @@ import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.currentComposer import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions -import com.mapbox.mapboxsdk.location.LocationComponentOptions -import com.mapbox.mapboxsdk.location.OnCameraTrackingChangedListener -import com.mapbox.mapboxsdk.location.engine.LocationEngineRequest -import com.mapbox.mapboxsdk.maps.MapboxMap -import com.mapbox.mapboxsdk.maps.Style +import org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.LocationComponentOptions +import org.maplibre.android.location.OnCameraTrackingChangedListener +import org.maplibre.android.location.engine.LocationEngineRequest +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.Style private const val LOCATION_REQUEST_INTERVAL = 750L internal class MapPropertiesNode( - val map: MapboxMap, + val map: MapLibreMap, style: Style, context: Context, cameraPositionState: CameraPositionState, diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt index bb40c7dfa9..18d942ec80 100644 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt +++ b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt @@ -26,10 +26,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import com.mapbox.mapboxsdk.geometry.LatLng -import com.mapbox.mapboxsdk.plugins.annotation.Symbol -import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager -import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.plugins.annotation.Symbol +import org.maplibre.android.plugins.annotation.SymbolManager +import org.maplibre.android.plugins.annotation.SymbolOptions internal class SymbolNode( val symbolManager: SymbolManager, @@ -85,7 +85,7 @@ public fun rememberSymbolState( * @param iconAnchor the anchor for the symbol image */ @Composable -@MapboxMapComposable +@MapLibreMapComposable public fun Symbol( iconId: String, state: SymbolState = rememberSymbolState(), diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt index 501b40508e..c7d3e79144 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/MatrixAuthenticationService.kt @@ -17,6 +17,8 @@ package io.element.android.libraries.matrix.api.auth import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.sessionstorage.api.LoggedInState import kotlinx.coroutines.flow.Flow @@ -53,4 +55,6 @@ interface MatrixAuthenticationService { * Attempt to login using the [callbackUrl] provided by the Oidc page. */ suspend fun loginWithOidc(callbackUrl: String): Result + + suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt new file mode 100644 index 0000000000..541675b0a8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginData.kt @@ -0,0 +1,19 @@ +/* + * 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.auth.qrlogin + +interface MatrixQrCodeLoginData diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt new file mode 100644 index 0000000000..0258d55106 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/MatrixQrCodeLoginDataFactory.kt @@ -0,0 +1,21 @@ +/* + * 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.auth.qrlogin + +interface MatrixQrCodeLoginDataFactory { + fun parseQrCodeData(data: ByteArray): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt new file mode 100644 index 0000000000..a0719fa8f0 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeDecodeException.kt @@ -0,0 +1,35 @@ +/* + * 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.auth.qrlogin + +sealed class QrCodeDecodeException(message: String) : Exception(message) { + class Crypto( + message: String, +// val reason: Reason + ) : QrCodeDecodeException(message) { + // We plan to restore it in the future when UniFFi can process them +// enum class Reason { +// NOT_ENOUGH_DATA, +// NOT_UTF8, +// URL_PARSE, +// INVALID_MODE, +// INVALID_VERSION, +// BASE64, +// INVALID_PREFIX +// } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt new file mode 100644 index 0000000000..4ecc7a6cd6 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrCodeLoginStep.kt @@ -0,0 +1,26 @@ +/* + * 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.auth.qrlogin + +sealed interface QrCodeLoginStep { + data object Uninitialized : QrCodeLoginStep + data class EstablishingSecureChannel(val checkCode: String) : QrCodeLoginStep + data object Starting : QrCodeLoginStep + data class WaitingForToken(val userCode: String) : QrCodeLoginStep + data class Failed(val error: QrLoginException) : QrCodeLoginStep + data object Finished : QrCodeLoginStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt new file mode 100644 index 0000000000..0a31a46236 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt @@ -0,0 +1,29 @@ +/* + * 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.auth.qrlogin + +sealed class QrLoginException : Exception() { + data object Cancelled : QrLoginException() + data object ConnectionInsecure : QrLoginException() + data object Declined : QrLoginException() + data object Expired : QrLoginException() + data object LinkingNotSupported : QrLoginException() + data object OidcMetadataInvalid : QrLoginException() + data object SlidingSyncNotAvailable : QrLoginException() + data object OtherDeviceNotSignedIn : QrLoginException() + data object Unknown : QrLoginException() +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt index 5dd4117b0a..f7377d77c2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt @@ -17,8 +17,10 @@ package io.element.android.libraries.matrix.api.core import android.os.Parcelable +import androidx.compose.runtime.Immutable import kotlinx.parcelize.Parcelize +@Immutable sealed interface RoomIdOrAlias : Parcelable { @Parcelize @JvmInline diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt index 09ba7f37f8..8e5845f621 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/ThreadId.kt @@ -33,3 +33,5 @@ value class ThreadId(val value: String) : Serializable { override fun toString(): String = value } + +fun ThreadId.asEventId(): EventId = EventId(value) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 36c786a26f..f47487c634 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -50,4 +50,16 @@ interface EncryptionService { * Wait for backup upload steady state. */ fun waitForBackupUploadSteadyState(): Flow + + /** + * Get the public curve25519 key of our own device in base64. This is usually what is + * called the identity key of the device. + */ + suspend fun deviceCurve25519(): String? + + /** + * Get the public ed25519 key of our own device. This is usually what is + * called the fingerprint of the device. + */ + suspend fun deviceEd25519(): String? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index 9df9698eec..55bce7c5f8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -52,6 +52,10 @@ sealed interface NotificationContent { data class CallInvite( val senderId: UserId, ) : MessageLike + data class CallNotify( + val senderId: UserId, + val type: CallNotifyType, + ) : MessageLike data object CallHangup : MessageLike data object CallCandidates : MessageLike @@ -108,3 +112,8 @@ sealed interface NotificationContent { data object SpaceParent : StateEvent } } + +enum class CallNotifyType { + RING, + NOTIFY +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt index ea9e37cfd6..753cefda83 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt @@ -21,6 +21,7 @@ enum class MessageEventType { CALL_INVITE, CALL_HANGUP, CALL_CANDIDATES, + CALL_NOTIFY, KEY_VERIFICATION_READY, KEY_VERIFICATION_START, KEY_VERIFICATION_CANCEL, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt index 8f5526a6e0..271b1d9b24 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListFilter.kt @@ -20,6 +20,7 @@ sealed interface RoomListFilter { companion object { /** * Create a filter that matches all the given filters. + * If no filters are provided, all the rooms will match. */ fun all(vararg filters: RoomListFilter): RoomListFilter { return All(filters.toList()) @@ -35,6 +36,7 @@ sealed interface RoomListFilter { /** * A filter that matches all the given filters. + * If [filters] is empty, all the room will match. */ data class All( val filters: List diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt index bdbb040de2..bdd9de2df0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomListService.kt @@ -54,8 +54,8 @@ interface RoomListService { ): DynamicRoomList /** - * returns a [DynamicRoomList] object of all rooms we want to display. - * This will exclude some rooms like the invites, or spaces. + * Returns a [DynamicRoomList] object of all rooms we want to display. + * If you want to get a filtered room list, consider using [createRoomList]. */ val allRooms: DynamicRoomList diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt index af1c7707da..aeaaa61862 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt @@ -57,7 +57,13 @@ interface Timeline : AutoCloseable { suspend fun enterSpecialMode(eventId: EventId?): Result - suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List): Result + suspend fun replyMessage( + eventId: EventId, + body: String, + htmlBody: String?, + mentions: List, + fromNotification: Boolean = false, + ): Result suspend fun sendImage( file: File, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index 0150c78a4a..18b8f51317 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollKind import kotlinx.collections.immutable.ImmutableList @@ -40,7 +41,7 @@ data object RedactedContent : EventContent data class StickerContent( val body: String, val info: ImageInfo, - val url: String + val source: MediaSource, ) : EventContent data class PollContent( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 423edda37f..fe7822bc30 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -16,7 +16,6 @@ package io.element.android.libraries.matrix.impl -import io.element.android.appconfig.TimelineConfig import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.androidutils.file.safeDelete import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -42,7 +41,6 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService -import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults @@ -56,17 +54,12 @@ import io.element.android.libraries.matrix.impl.notification.RustNotificationSer import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService import io.element.android.libraries.matrix.impl.oidc.toRustAction import io.element.android.libraries.matrix.impl.pushers.RustPushersService -import io.element.android.libraries.matrix.impl.room.MatrixRoomInfoMapper import io.element.android.libraries.matrix.impl.room.RoomContentForwarder -import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber -import io.element.android.libraries.matrix.impl.room.RustMatrixRoom -import io.element.android.libraries.matrix.impl.room.map +import io.element.android.libraries.matrix.impl.room.RustRoomFactory import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewMapper import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService -import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline -import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper @@ -102,14 +95,10 @@ import kotlinx.coroutines.withTimeout import org.matrix.rustcomponents.sdk.BackupState import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate -import org.matrix.rustcomponents.sdk.FilterTimelineEventType import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.PowerLevels -import org.matrix.rustcomponents.sdk.Room -import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.TaskHandle -import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File @@ -150,7 +139,6 @@ class RustMatrixClient( private val notificationService = RustNotificationService(sessionId, notificationClient, dispatchers, clock) private val notificationSettingsService = RustNotificationSettingsService(client, dispatchers) .apply { start() } - private val roomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers) private val encryptionService = RustEncryptionService( client = client, syncService = rustSyncService, @@ -237,15 +225,18 @@ class RustMatrixClient( sessionCoroutineScope = sessionCoroutineScope, ) - private val eventFilters = TimelineConfig.excludedEvents - .takeIf { it.isNotEmpty() } - ?.let { listStateEventType -> - TimelineEventTypeFilter.exclude( - listStateEventType.map { stateEventType -> - FilterTimelineEventType.State(stateEventType.map()) - } - ) - } + private val roomFactory = RustRoomFactory( + roomListService = roomListService, + innerRoomListService = innerRoomListService, + sessionId = sessionId, + notificationSettingsService = notificationSettingsService, + sessionCoroutineScope = sessionCoroutineScope, + dispatchers = dispatchers, + systemClock = clock, + roomContentForwarder = RoomContentForwarder(innerRoomListService), + isKeyBackupEnabled = { client.encryption().use { it.backupState() == BackupState.ENABLED } }, + getSessionData = { sessionStore.getSession(sessionId.value)!! }, + ) override val mediaLoader: MatrixMediaLoader = RustMediaLoader( baseCacheDirectory = baseCacheDirectory, @@ -255,8 +246,6 @@ class RustMatrixClient( private val roomMembershipObserver = RoomMembershipObserver() - private val roomContentForwarder = RoomContentForwarder(innerRoomListService) - private val clientDelegateTaskHandle: TaskHandle? = client.setDelegate(clientDelegate) private val _userProfile: MutableStateFlow = MutableStateFlow( @@ -287,31 +276,8 @@ class RustMatrixClient( } } - override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) { - // Check if already in memory... - var cachedPairOfRoom = pairOfRoom(roomId) - if (cachedPairOfRoom == null) { - // ... otherwise, lets wait for the SS to load all rooms and check again. - roomListService.allRooms.awaitLoaded() - cachedPairOfRoom = pairOfRoom(roomId) - } - cachedPairOfRoom?.let { (roomListItem, fullRoom) -> - RustMatrixRoom( - sessionId = sessionId, - isKeyBackupEnabled = client.encryption().backupState() == BackupState.ENABLED, - roomListItem = roomListItem, - innerRoom = fullRoom, - innerTimeline = fullRoom.timeline(), - roomNotificationSettingsService = notificationSettingsService, - sessionCoroutineScope = sessionCoroutineScope, - coroutineDispatchers = dispatchers, - systemClock = clock, - roomContentForwarder = roomContentForwarder, - sessionData = sessionStore.getSession(sessionId.value)!!, - roomSyncSubscriber = roomSyncSubscriber, - matrixRoomInfoMapper = MatrixRoomInfoMapper(), - ) - } + override suspend fun getRoom(roomId: RoomId): MatrixRoom? { + return roomFactory.create(roomId) } /** @@ -330,18 +296,6 @@ class RustMatrixClient( } } - private suspend fun pairOfRoom(roomId: RoomId): Pair? { - val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value) - val fullRoom = cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters) - return if (cachedRoomListItem == null || fullRoom == null) { - Timber.d("No room cached for $roomId") - null - } else { - Timber.d("Found room cached for $roomId") - Pair(cachedRoomListItem, fullRoom) - } - } - // SC additions override suspend fun getAccountData(eventType: String): String? { return runCatching { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 80302933d5..cb0fbbefa4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -46,25 +46,11 @@ class RustMatrixClientFactory @Inject constructor( private val utdTracker: UtdTracker, ) { suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) { - val client = ClientBuilder() - .basePath(baseDirectory.absolutePath) + val client = getBaseClientBuilder() .homeserverUrl(sessionData.homeserverUrl) .username(sessionData.userId) .passphrase(sessionData.passphrase) - .userAgent(userAgentProvider.provide()) - .let { - // Sadly ClientBuilder.proxy() does not accept null :/ - // Tracked by https://github.com/matrix-org/matrix-rust-sdk/issues/3159 - val proxy = proxyProvider.provides() - if (proxy != null) { - it.proxy(proxy) - } else { - it - } - } - .addRootCertificates(userCertificatesProvider.provides()) // FIXME Quick and dirty fix for stopping version requests on startup https://github.com/matrix-org/matrix-rust-sdk/pull/1376 - .serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5")) .use { it.build() } client.restoreSession(sessionData.toSession()) @@ -84,6 +70,24 @@ class RustMatrixClientFactory @Inject constructor( clock = clock, ) } + + internal fun getBaseClientBuilder(): ClientBuilder { + return ClientBuilder() + .basePath(baseDirectory.absolutePath) + .userAgent(userAgentProvider.provide()) + .addRootCertificates(userCertificatesProvider.provides()) + .serverVersions(listOf("v1.0", "v1.1", "v1.2", "v1.3", "v1.4", "v1.5")) + .let { + // Sadly ClientBuilder.proxy() does not accept null :/ + // Tracked by https://github.com/matrix-org/matrix-rust-sdk/issues/3159 + val proxy = proxyProvider.provides() + if (proxy != null) { + it.proxy(proxy) + } else { + it + } + } + } } private fun SessionData.toSession() = Session( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 10b646041a..097765e849 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -25,8 +25,13 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.RustMatrixClientFactory +import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData +import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator @@ -36,11 +41,16 @@ import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.HumanQrLoginException import org.matrix.rustcomponents.sdk.OidcAuthenticationData +import org.matrix.rustcomponents.sdk.QrCodeDecodeException +import org.matrix.rustcomponents.sdk.QrLoginProgress +import org.matrix.rustcomponents.sdk.QrLoginProgressListener import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File @@ -197,4 +207,43 @@ class RustMatrixAuthenticationService @Inject constructor( } } } + + override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) = + withContext(coroutineDispatchers.io) { + runCatching { + val client = rustMatrixClientFactory.getBaseClientBuilder() + .passphrase(pendingPassphrase) + .buildWithQrCode( + qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData, + oidcConfiguration = oidcConfiguration, + progressListener = object : QrLoginProgressListener { + override fun onUpdate(state: QrLoginProgress) { + Timber.d("QR Code login progress: $state") + progress(state.toStep()) + } + } + ) + client.use { rustClient -> + val sessionData = rustClient.session() + .toSessionData( + isTokenValid = true, + loginType = LoginType.QR, + passphrase = pendingPassphrase, + ) + sessionStore.storeData(sessionData) + SessionId(sessionData.userId) + } + }.mapFailure { + when (it) { + is QrCodeDecodeException -> QrErrorMapper.map(it) + is HumanQrLoginException -> QrErrorMapper.map(it) + else -> it + } + }.onFailure { throwable -> + if (throwable is CancellationException) { + throw throwable + } + Timber.e(throwable, "Failed to login with QR code") + } + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt new file mode 100644 index 0000000000..e55b662b72 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt @@ -0,0 +1,55 @@ +/* + * 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.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException +import org.matrix.rustcomponents.sdk.HumanQrLoginException as RustHumanQrLoginException +import org.matrix.rustcomponents.sdk.QrCodeDecodeException as RustQrCodeDecodeException + +object QrErrorMapper { + fun map(qrCodeDecodeException: RustQrCodeDecodeException): QrCodeDecodeException = when (qrCodeDecodeException) { + is RustQrCodeDecodeException.Crypto -> { + // We plan to restore it in the future when UniFFi can process them +// val reason = when (qrCodeDecodeException.error) { +// LoginQrCodeDecodeError.NOT_ENOUGH_DATA -> QrCodeDecodeException.Crypto.Reason.NOT_ENOUGH_DATA +// LoginQrCodeDecodeError.NOT_UTF8 -> QrCodeDecodeException.Crypto.Reason.NOT_UTF8 +// LoginQrCodeDecodeError.URL_PARSE -> QrCodeDecodeException.Crypto.Reason.URL_PARSE +// LoginQrCodeDecodeError.INVALID_MODE -> QrCodeDecodeException.Crypto.Reason.INVALID_MODE +// LoginQrCodeDecodeError.INVALID_VERSION -> QrCodeDecodeException.Crypto.Reason.INVALID_VERSION +// LoginQrCodeDecodeError.BASE64 -> QrCodeDecodeException.Crypto.Reason.BASE64 +// LoginQrCodeDecodeError.INVALID_PREFIX -> QrCodeDecodeException.Crypto.Reason.INVALID_PREFIX +// } + QrCodeDecodeException.Crypto( + qrCodeDecodeException.message.orEmpty(), +// reason + ) + } + } + + fun map(humanQrLoginError: RustHumanQrLoginException): QrLoginException = when (humanQrLoginError) { + is RustHumanQrLoginException.Cancelled -> QrLoginException.Cancelled + is RustHumanQrLoginException.ConnectionInsecure -> QrLoginException.ConnectionInsecure + is RustHumanQrLoginException.Declined -> QrLoginException.Declined + is RustHumanQrLoginException.Expired -> QrLoginException.Expired + is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn + is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported + is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown + is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid + is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt new file mode 100644 index 0000000000..1dc6029700 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrLoginProgressExtensions.kt @@ -0,0 +1,29 @@ +/* + * 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.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep +import org.matrix.rustcomponents.sdk.QrLoginProgress + +fun QrLoginProgress.toStep(): QrCodeLoginStep { + return when (this) { + is QrLoginProgress.EstablishingSecureChannel -> QrCodeLoginStep.EstablishingSecureChannel(checkCodeString) + is QrLoginProgress.Starting -> QrCodeLoginStep.Starting + is QrLoginProgress.WaitingForToken -> QrCodeLoginStep.WaitingForToken(userCode) + is QrLoginProgress.Done -> QrCodeLoginStep.Finished + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt new file mode 100644 index 0000000000..9cf55e018a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/RustQrCodeLoginDataFactory.kt @@ -0,0 +1,31 @@ +/* + * 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.impl.auth.qrlogin + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import org.matrix.rustcomponents.sdk.QrCodeData +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class RustQrCodeLoginDataFactory @Inject constructor() : MatrixQrCodeLoginDataFactory { + override fun parseQrCodeData(data: ByteArray): Result { + return runCatching { SdkQrCodeLoginData(QrCodeData.fromBytes(data)) } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt new file mode 100644 index 0000000000..da24fbf4bb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginData.kt @@ -0,0 +1,24 @@ +/* + * 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.impl.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import org.matrix.rustcomponents.sdk.QrCodeData as RustQrCodeData + +class SdkQrCodeLoginData( + internal val rustQrCodeData: RustQrCodeData, +) : MatrixQrCodeLoginData diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index f5a6390989..68ab4a611e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -190,4 +190,12 @@ internal class RustEncryptionService( it.mapRecoveryException() } } + + override suspend fun deviceCurve25519(): String? { + return service.curve25519Key() + } + + override suspend fun deviceEd25519(): String? { + return service.ed25519Key() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index f599b09079..4591a2ef52 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -17,10 +17,12 @@ package io.element.android.libraries.matrix.impl.notification import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.notification.CallNotifyType import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper import org.matrix.rustcomponents.sdk.MessageLikeEventContent +import org.matrix.rustcomponents.sdk.NotifyType import org.matrix.rustcomponents.sdk.StateEventContent import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEventType @@ -79,6 +81,7 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite(senderId) + is MessageLikeEventContent.CallNotify -> NotificationContent.MessageLike.CallNotify(senderId, notifyType.map()) MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone @@ -97,3 +100,8 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon } } } + +private fun NotifyType.map(): CallNotifyType = when (this) { + NotifyType.NOTIFY -> CallNotifyType.NOTIFY + NotifyType.RING -> CallNotifyType.RING +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt index bce74bc2db..e0f56288db 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt @@ -24,6 +24,7 @@ fun MessageEventType.map(): MessageLikeEventType = when (this) { MessageEventType.CALL_INVITE -> MessageLikeEventType.CALL_INVITE MessageEventType.CALL_HANGUP -> MessageLikeEventType.CALL_HANGUP MessageEventType.CALL_CANDIDATES -> MessageLikeEventType.CALL_CANDIDATES + MessageEventType.CALL_NOTIFY -> MessageLikeEventType.CALL_NOTIFY MessageEventType.KEY_VERIFICATION_READY -> MessageLikeEventType.KEY_VERIFICATION_READY MessageEventType.KEY_VERIFICATION_START -> MessageLikeEventType.KEY_VERIFICATION_START MessageEventType.KEY_VERIFICATION_CANCEL -> MessageLikeEventType.KEY_VERIFICATION_CANCEL @@ -49,6 +50,7 @@ fun MessageLikeEventType.map(): MessageEventType = when (this) { MessageLikeEventType.CALL_INVITE -> MessageEventType.CALL_INVITE MessageLikeEventType.CALL_HANGUP -> MessageEventType.CALL_HANGUP MessageLikeEventType.CALL_CANDIDATES -> MessageEventType.CALL_CANDIDATES + MessageLikeEventType.CALL_NOTIFY -> MessageEventType.CALL_NOTIFY MessageLikeEventType.KEY_VERIFICATION_READY -> MessageEventType.KEY_VERIFICATION_READY MessageLikeEventType.KEY_VERIFICATION_START -> MessageEventType.KEY_VERIFICATION_START MessageLikeEventType.KEY_VERIFICATION_CANCEL -> MessageEventType.KEY_VERIFICATION_CANCEL diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt index 1688763d4c..a1ea8a0a37 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt @@ -49,7 +49,13 @@ class RoomContentForwarder( toRoomIds: List, timeoutMs: Long = 5000L ) { - val content = fromTimeline.getTimelineEventContentByEventId(eventId.value) + val content = fromTimeline + .getEventTimelineItemByEventId(eventId.value) + .content() + .asMessage() + ?.content() + ?: throw ForwardEventException(toRoomIds) + val targetSlidingSyncRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) } val targetRooms = targetSlidingSyncRooms.map { slidingSyncRoom -> slidingSyncRoom.use { it.fullRoomWithTimeline(null) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt index 1b6b364844..a65dc17a43 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt @@ -27,6 +27,8 @@ import org.matrix.rustcomponents.sdk.RoomListService import org.matrix.rustcomponents.sdk.RoomSubscription import timber.log.Timber +private const val DEFAULT_TIMELINE_LIMIT = 20u + class RoomSyncSubscriber( private val roomListService: RoomListService, private val dispatchers: CoroutineDispatchers, @@ -41,7 +43,9 @@ class RoomSyncSubscriber( RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""), RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""), ), - timelineLimit = null + timelineLimit = DEFAULT_TIMELINE_LIMIT, + // We don't need heroes here as they're already included in the `all_rooms` list + includeHeroes = false, ) suspend fun subscribe(roomId: RoomId) = mutex.withLock { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 93720df55c..2827ad366c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaUploadHandler import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo @@ -48,7 +49,6 @@ import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings -import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsMapper @@ -82,6 +82,7 @@ import org.matrix.rustcomponents.sdk.TypingNotificationsListener import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate import org.matrix.rustcomponents.sdk.WidgetCapabilities import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider +import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions import org.matrix.rustcomponents.sdk.use import uniffi.matrix_sdk.RoomPowerLevelChanges import java.io.File @@ -95,7 +96,7 @@ class RustMatrixRoom( private val roomListItem: RoomListItem, private val innerRoom: InnerRoom, innerTimeline: InnerTimeline, - private val roomNotificationSettingsService: RustNotificationSettingsService, + private val notificationSettingsService: NotificationSettingsService, sessionCoroutineScope: CoroutineScope, private val coroutineDispatchers: CoroutineDispatchers, private val systemClock: SystemClock, @@ -261,7 +262,7 @@ class RustMatrixRoom( val currentRoomNotificationSettings = currentState.roomNotificationSettings() _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings) runCatching { - roomNotificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow() + notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow() }.map { _roomNotificationSettingsStateFlow.value = MatrixRoomNotificationSettingsState.Ready(it) }.onFailure { @@ -581,7 +582,7 @@ class RustMatrixRoom( room = innerRoom, widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider { override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities { - return capabilities + return getElementCallRequiredPermissions(sessionId.value) } }, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt new file mode 100644 index 0000000000..19d9401a27 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -0,0 +1,124 @@ +/* + * 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.impl.room + +import io.element.android.appconfig.TimelineConfig +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +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.notificationsettings.NotificationSettingsService +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.awaitLoaded +import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline +import io.element.android.libraries.matrix.impl.roomlist.roomOrNull +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.FilterTimelineEventType +import org.matrix.rustcomponents.sdk.Room +import org.matrix.rustcomponents.sdk.RoomListException +import org.matrix.rustcomponents.sdk.RoomListItem +import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter +import timber.log.Timber +import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService + +class RustRoomFactory( + private val sessionId: SessionId, + private val notificationSettingsService: NotificationSettingsService, + private val sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, + private val systemClock: SystemClock, + private val roomContentForwarder: RoomContentForwarder, + private val roomListService: RoomListService, + private val innerRoomListService: InnerRoomListService, + private val isKeyBackupEnabled: suspend () -> Boolean, + private val getSessionData: suspend () -> SessionData, +) { + @OptIn(ExperimentalCoroutinesApi::class) + private val createRoomDispatcher = dispatchers.io.limitedParallelism(1) + private val mutex = Mutex() + + private val matrixRoomInfoMapper = MatrixRoomInfoMapper() + + private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers) + + private val eventFilters = TimelineConfig.excludedEvents + .takeIf { it.isNotEmpty() } + ?.let { listStateEventType -> + TimelineEventTypeFilter.exclude( + listStateEventType.map { stateEventType -> + FilterTimelineEventType.State(stateEventType.map()) + } + ) + } + + suspend fun create(roomId: RoomId): MatrixRoom? = withContext(createRoomDispatcher) { + var cachedPairOfRoom: Pair? + mutex.withLock { + // Check if already in memory... + cachedPairOfRoom = pairOfRoom(roomId) + if (cachedPairOfRoom == null) { + // ... otherwise, lets wait for the SS to load all rooms and check again. + roomListService.allRooms.awaitLoaded() + cachedPairOfRoom = pairOfRoom(roomId) + } + } + if (cachedPairOfRoom == null) { + Timber.d("No room found for $roomId") + return@withContext null + } + cachedPairOfRoom?.let { (roomListItem, fullRoom) -> + RustMatrixRoom( + sessionId = sessionId, + isKeyBackupEnabled = isKeyBackupEnabled(), + roomListItem = roomListItem, + innerRoom = fullRoom, + innerTimeline = fullRoom.timeline(), + notificationSettingsService = notificationSettingsService, + sessionCoroutineScope = sessionCoroutineScope, + coroutineDispatchers = dispatchers, + systemClock = systemClock, + roomContentForwarder = roomContentForwarder, + sessionData = getSessionData(), + roomSyncSubscriber = roomSyncSubscriber, + matrixRoomInfoMapper = matrixRoomInfoMapper, + ) + } + } + + private suspend fun pairOfRoom(roomId: RoomId): Pair? { + val cachedRoomListItem = innerRoomListService.roomOrNull(roomId.value) + val fullRoom = try { + cachedRoomListItem?.fullRoomWithTimeline(filter = eventFilters) + } catch (e: RoomListException) { + Timber.e(e, "Failed to get full room with timeline for $roomId") + null + } + return if (cachedRoomListItem == null || fullRoom == null) { + Timber.d("No room cached for $roomId") + null + } else { + Timber.d("Found room cached for $roomId") + Pair(cachedRoomListItem, fullRoom) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt index 8094f0bee7..d987e332c8 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListExtensions.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.onEach import org.matrix.rustcomponents.sdk.RoomListEntriesDynamicFilterKind import org.matrix.rustcomponents.sdk.RoomListEntriesListener import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate +import org.matrix.rustcomponents.sdk.RoomListException import org.matrix.rustcomponents.sdk.RoomListInterface import org.matrix.rustcomponents.sdk.RoomListItem import org.matrix.rustcomponents.sdk.RoomListLoadingState @@ -129,7 +130,7 @@ internal fun RoomListServiceInterface.syncIndicator(): Flow): Result = withContext(dispatcher) { + override suspend fun replyMessage( + eventId: EventId, + body: String, + htmlBody: String?, + mentions: List, + fromNotification: Boolean, + ): Result = withContext(dispatcher) { runCatching { - val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value) - inReplyTo.use { eventTimelineItem -> - inner.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem) + val msg = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()) + if (fromNotification) { + // When replying from a notification, do not interfere with `specialModeEventTimelineItem` + val inReplyTo = inner.getEventTimelineItemByEventId(eventId.value) + inReplyTo.use { eventTimelineItem -> + inner.sendReply(msg, eventTimelineItem) + } + } else { + val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value) + inReplyTo.use { eventTimelineItem -> + inner.sendReply(msg, eventTimelineItem) + } + specialModeEventTimelineItem = null } - specialModeEventTimelineItem = null } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 04e3632d09..6ee241a690 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -103,7 +103,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap StickerContent( body = kind.body, info = kind.info.map(), - url = kind.url, + source = kind.source.map(), ) } is TimelineItemContentKind.Poll -> { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt index 8bbdf47e21..6296f219de 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt @@ -57,9 +57,9 @@ class DefaultJoinRoomTest { .isNeverCalled() joinRoomLambda .assertions() - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID)) + .isCalledOnce() + .with( + value(A_ROOM_ID) ) assertThat(analyticsService.capturedEvents).containsExactly( roomResult.toAnalyticsJoinedRoom(aTrigger) @@ -88,9 +88,10 @@ class DefaultJoinRoomTest { sut.invoke(A_ROOM_ID, A_SERVER_LIST, aTrigger) joinRoomByIdOrAliasLambda .assertions() - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(A_SERVER_LIST)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(A_SERVER_LIST) ) joinRoomLambda .assertions() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTests.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt similarity index 95% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTests.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt index dfc1907095..02fb9cd24b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTests.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFilterTest.kt @@ -24,7 +24,7 @@ import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import kotlinx.coroutines.test.runTest import org.junit.Test -class RoomListFilterTests { +class RoomListFilterTest { private val regularRoom = aRoomSummaryFilled( aRoomSummaryDetails( isDirect = false @@ -136,4 +136,10 @@ class RoomListFilterTests { ) assertThat(roomSummaries.filter(filter)).isEmpty() } + + @Test + fun `Room list filter all with empty list`() = runTest { + val filter = RoomListFilter.all() + assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries) + } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt similarity index 99% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt index 6442987770..4ff46c2bc4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTests.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt @@ -39,7 +39,7 @@ import org.matrix.rustcomponents.sdk.TaskHandle // NOTE: this class is using a fake implementation of a Rust SDK interface which returns actual Rust objects with pointers. // Since we don't access the data in those objects, this is fine for our tests, but that's as far as we can test this class. -class RoomSummaryListProcessorTests { +class RoomSummaryListProcessorTest { private val summaries = MutableStateFlow>(emptyList()) @Test diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index bc7778a7e6..96431a3b63 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -40,7 +40,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.pushers.FakePushersService @@ -66,7 +66,7 @@ class FakeMatrixClient( private val userDisplayName: String? = A_USER_NAME, private val userAvatarUrl: String? = AN_AVATAR_URL, override val roomListService: RoomListService = FakeRoomListService(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index e507e34033..d10d1ad0d2 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings const val A_USER_NAME = "alice" const val A_PASSWORD = "password" +const val A_SECRET = "secret" val A_USER_ID = UserId("@alice:server.org") val A_USER_ID_2 = UserId("@bob:server.org") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt similarity index 79% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index c24df7d717..920ad130df 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -20,9 +20,13 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +35,11 @@ import kotlinx.coroutines.flow.flowOf val A_OIDC_DATA = OidcDetails(url = "a-url") -class FakeAuthenticationService : MatrixAuthenticationService { +class FakeMatrixAuthenticationService( + var matrixClientResult: ((SessionId) -> Result)? = null, + var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result = + lambdaRecorder Unit, Result> { _, _ -> Result.success(A_SESSION_ID) }, +) : MatrixAuthenticationService { private val homeserver = MutableStateFlow(null) private var oidcError: Throwable? = null private var oidcCancelError: Throwable? = null @@ -48,6 +56,9 @@ class FakeAuthenticationService : MatrixAuthenticationService { override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda() override suspend fun restoreSession(sessionId: SessionId): Result { + matrixClientResult?.let { + return it.invoke(sessionId) + } return if (matrixClient != null) { Result.success(matrixClient!!) } else { @@ -83,6 +94,10 @@ class FakeAuthenticationService : MatrixAuthenticationService { loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID) } + override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit): Result = simulateLongTask { + loginWithQrCodeResult(qrCodeData, progress) + } + fun givenOidcError(throwable: Throwable?) { oidcError = throwable } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt new file mode 100644 index 0000000000..6e542dd739 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/qrlogin/FakeMatrixQrCodeLoginDataFactory.kt @@ -0,0 +1,32 @@ +/* + * 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.test.auth.qrlogin + +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData +import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeMatrixQrCodeLoginDataFactory( + var parseQrCodeLoginDataResult: () -> Result = + lambdaRecorder> { Result.success(FakeMatrixQrCodeLoginData()) }, +) : MatrixQrCodeLoginDataFactory { + override fun parseQrCodeData(data: ByteArray): Result { + return parseQrCodeLoginDataResult() + } +} + +class FakeMatrixQrCodeLoginData : MatrixQrCodeLoginData diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt index 2a70a56051..52c15d05a4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt @@ -28,7 +28,7 @@ fun aBuildMeta( applicationId: String = "", lowPrivacyLoggingEnabled: Boolean = true, versionName: String = "", - versionCode: Int = 0, + versionCode: Long = 0, gitRevision: String = "", gitBranchName: String = "", flavorDescription: String = "", diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index cc7f53eca3..b864c69b0b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -39,6 +39,9 @@ class FakeEncryptionService : EncryptionService { private var enableBackupsFailure: Exception? = null + private var curve25519: String? = null + private var ed25519: String? = null + fun givenEnableBackupsFailure(exception: Exception?) { enableBackupsFailure = exception } @@ -94,6 +97,15 @@ class FakeEncryptionService : EncryptionService { return waitForBackupUploadSteadyStateFlow } + fun givenDeviceKeys(curve25519: String?, ed25519: String?) { + this.curve25519 = curve25519 + this.ed25519 = ed25519 + } + + override suspend fun deviceCurve25519(): String? = curve25519 + + override suspend fun deviceEd25519(): String? = ed25519 + suspend fun emitBackupState(state: BackupState) { backupStateStateFlow.emit(state) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt similarity index 97% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt index c31beb8e4a..b161a082e7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.tests.testutils.simulateLongTask -class FakeMediaLoader : MatrixMediaLoader { +class FakeMatrixMediaLoader : MatrixMediaLoader { var shouldFail = false var path: String = "" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt index d1ffb70f99..525746e690 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -18,9 +18,10 @@ package io.element.android.libraries.matrix.test.permalink import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError class FakePermalinkParser( - private var result: () -> PermalinkData = { TODO("Not implemented") } + private var result: () -> PermalinkData = { lambdaError() } ) : PermalinkParser { fun givenResult(result: PermalinkData) { this.result = { result } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt index 05a40e9995..3ede3b272f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -19,8 +19,12 @@ package io.element.android.libraries.matrix.test.pushers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.tests.testutils.lambda.lambdaError -class FakePushersService : PushersService { - override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) - override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = Result.success(Unit) +class FakePushersService( + private val setHttpPusherResult: (SetHttpPusherData) -> Result = { lambdaError() }, + private val unsetHttpPusherResult: (UnsetHttpPusherData) -> Result = { lambdaError() }, +) : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = setHttpPusherResult(setHttpPusherData) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = unsetHttpPusherResult(unsetHttpPusherData) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index a780d33ecd..4b4a8e1af0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -55,7 +55,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.tests.testutils.simulateLongTask import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -125,7 +125,7 @@ class FakeMatrixRoom( private var endPollResult = Result.success(Unit) private var progressCallbackValues = emptyList>() private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") - private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) + private var getWidgetDriverResult: Result = Result.success(FakeMatrixWidgetDriver()) private var canUserTriggerRoomNotificationResult: Result = Result.success(true) private var canUserJoinCallResult: Result = Result.success(true) private var setIsFavoriteResult = Result.success(Unit) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt index c2f67882c8..15ec946d5e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt @@ -104,7 +104,8 @@ class FakeTimeline( body: String, htmlBody: String?, mentions: List, - ) -> Result = { _, _, _, _ -> + fromNotification: Boolean, + ) -> Result = { _, _, _, _, _ -> Result.success(Unit) } @@ -113,11 +114,13 @@ class FakeTimeline( body: String, htmlBody: String?, mentions: List, + fromNotification: Boolean, ): Result = replyMessageLambda( eventId, body, htmlBody, - mentions + mentions, + fromNotification, ) var sendImageLambda: ( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt similarity index 98% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt index a64691fb40..be60b0011b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import kotlinx.coroutines.flow.MutableSharedFlow import java.util.UUID -class FakeWidgetDriver( +class FakeMatrixWidgetDriver( override val id: String = UUID.randomUUID().toString(), ) : MatrixWidgetDriver { private val _sentMessages = mutableListOf() diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt index 84caa006d2..41aecd16f3 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -51,7 +51,7 @@ import kotlinx.collections.immutable.persistentListOf fun AvatarActionBottomSheet( actions: ImmutableList, isVisible: Boolean, - onActionSelected: (action: AvatarAction) -> Unit, + onSelectAction: (action: AvatarAction) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -64,8 +64,8 @@ fun AvatarActionBottomSheet( sheetState.hide(coroutineScope, then = { onDismiss() }) } - fun onItemActionClicked(itemAction: AvatarAction) { - onActionSelected(itemAction) + fun onItemActionClick(itemAction: AvatarAction) { + onSelectAction(itemAction) sheetState.hide(coroutineScope, then = { onDismiss() }) } @@ -79,7 +79,7 @@ fun AvatarActionBottomSheet( ) { AvatarActionBottomSheetContent( actions = actions, - onActionClicked = ::onItemActionClicked, + onActionClick = ::onItemActionClick, modifier = Modifier .navigationBarsPadding() .imePadding() @@ -92,7 +92,7 @@ fun AvatarActionBottomSheet( private fun AvatarActionBottomSheetContent( actions: ImmutableList, modifier: Modifier = Modifier, - onActionClicked: (AvatarAction) -> Unit = { }, + onActionClick: (AvatarAction) -> Unit = { }, ) { LazyColumn( modifier = modifier.fillMaxWidth() @@ -101,7 +101,7 @@ private fun AvatarActionBottomSheetContent( items = actions, ) { action -> ListItem( - modifier = Modifier.clickable { onActionClicked(action) }, + modifier = Modifier.clickable { onActionClick(action) }, headlineContent = { Text( text = stringResource(action.titleResId), @@ -125,7 +125,7 @@ internal fun AvatarActionBottomSheetPreview() = ElementPreview { AvatarActionBottomSheet( actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), isVisible = true, - onActionSelected = { }, + onSelectAction = { }, onDismiss = { }, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt index ad9babb4a4..c9dc2c7c5a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt @@ -52,7 +52,7 @@ fun EditableAvatarView( displayName: String?, avatarUrl: Uri?, avatarSize: AvatarSize, - onAvatarClicked: () -> Unit, + onAvatarClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -64,7 +64,7 @@ fun EditableAvatarView( .size(avatarSize.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, - onClick = onAvatarClicked, + onClick = onAvatarClick, indication = rememberRipple(bounded = false), ) .testTag(TestTags.editAvatar) @@ -113,7 +113,7 @@ internal fun EditableAvatarViewPreview( displayName = "A room", avatarUrl = uri, avatarSize = AvatarSize.EditRoomDetails, - onAvatarClicked = {}, + onAvatarClick = {}, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt index c95f3e4cde..7f8f9f2f56 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -50,7 +50,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SelectedRoom( roomSummary: RoomSummaryDetails, - onRoomRemoved: (RoomSummaryDetails) -> Unit, + onRemoveRoom: (RoomSummaryDetails) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -78,7 +78,7 @@ fun SelectedRoom( .clickable( indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() }, - onClick = { onRoomRemoved(roomSummary) } + onClick = { onRemoveRoom(roomSummary) } ), ) { Icon( @@ -98,6 +98,6 @@ internal fun SelectedRoomPreview( ) = ElementPreview { SelectedRoom( roomSummary = roomSummaryDetails, - onRoomRemoved = {}, + onRemoveRoom = {}, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt index 418e2ca2ef..8e94763f3c 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt @@ -53,7 +53,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun SelectedUser( matrixUser: MatrixUser, canRemove: Boolean, - onUserRemoved: (MatrixUser) -> Unit, + onUserRemove: (MatrixUser) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -83,7 +83,7 @@ fun SelectedUser( .clickable( indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() }, - onClick = { onUserRemoved(matrixUser) } + onClick = { onUserRemove(matrixUser) } ), ) { Icon( @@ -103,7 +103,7 @@ internal fun SelectedUserPreview() = ElementPreview { SelectedUser( aMatrixUser(displayName = "John Doe"), canRemove = true, - onUserRemoved = {}, + onUserRemove = {}, ) } @@ -113,6 +113,6 @@ internal fun SelectedUserCannotRemovePreview() = ElementPreview { SelectedUser( aMatrixUser(), canRemove = false, - onUserRemoved = {}, + onUserRemove = {}, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt index 065f23a661..9299f4eecc 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt @@ -48,7 +48,7 @@ import kotlin.math.floor @Composable fun SelectedUsersRowList( selectedUsers: ImmutableList, - onUserRemoved: (MatrixUser) -> Unit, + onUserRemove: (MatrixUser) -> Unit, modifier: Modifier = Modifier, autoScroll: Boolean = false, canDeselect: (MatrixUser) -> Boolean = { true }, @@ -112,7 +112,7 @@ fun SelectedUsersRowList( SelectedUser( matrixUser = selectedUser, canRemove = canDeselect(selectedUser), - onUserRemoved = onUserRemoved, + onUserRemove = onUserRemove, ) }, measurePolicy = { measurables, constraints -> @@ -137,7 +137,7 @@ internal fun SelectedUsersRowListPreview() = ElementPreview { // Two users that will be visible with no scrolling SelectedUsersRowList( selectedUsers = aMatrixUserList().take(2).toImmutableList(), - onUserRemoved = {}, + onUserRemove = {}, modifier = Modifier .width(200.dp) .border(1.dp, Color.Red) @@ -147,7 +147,7 @@ internal fun SelectedUsersRowListPreview() = ElementPreview { for (i in 0..5) { SelectedUsersRowList( selectedUsers = aMatrixUserList().take(6).toImmutableList(), - onUserRemoved = {}, + onUserRemove = {}, modifier = Modifier .width((200 + i * 20).dp) .border(1.dp, Color.Red) diff --git a/libraries/matrixui/src/main/res/values-be/translations.xml b/libraries/matrixui/src/main/res/values-be/translations.xml index 70b3651411..f2c4756e7c 100644 --- a/libraries/matrixui/src/main/res/values-be/translations.xml +++ b/libraries/matrixui/src/main/res/values-be/translations.xml @@ -1,4 +1,4 @@ - "%1$s (%2$s) запрасіў вас" + "%1$s (%2$s) запрасіў(-ла) вас" diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt similarity index 98% rename from libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt rename to libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt index d5e092f9ca..606d023154 100644 --- a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/DefaultPickerProvider.kt @@ -37,7 +37,7 @@ import java.util.UUID import javax.inject.Inject @ContributesBinding(AppScope::class) -class PickerProviderImpl(private val isInTest: Boolean) : PickerProvider { +class DefaultPickerProvider(private val isInTest: Boolean) : PickerProvider { @Inject constructor() : this(false) diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt similarity index 99% rename from libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt rename to libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt index 1cd14051a7..71ec49df80 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImpl.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt @@ -43,7 +43,7 @@ import kotlin.time.Duration.Companion.seconds */ @ContributesBinding(RoomScope::class) @SingleIn(RoomScope::class) -class MediaPlayerImpl @Inject constructor( +class DefaultMediaPlayer @Inject constructor( private val player: SimplePlayer, ) : MediaPlayer { private val listener = object : SimplePlayer.Listener { diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index ff8ff786ec..94495dfaa9 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -55,13 +55,13 @@ object SimplePlayerModule { @Provides fun simplePlayerProvider( @ApplicationContext context: Context, - ): SimplePlayer = SimplePlayerImpl(ExoPlayer.Builder(context).build()) + ): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).build()) } /** * Default implementation of [SimplePlayer] backed by a media3 [Player]. */ -class SimplePlayerImpl( +class DefaultSimplePlayer( private val p: Player ) : SimplePlayer { override fun addListener(listener: SimplePlayer.Listener) { diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImplTest.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt similarity index 96% rename from libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImplTest.kt rename to libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt index bf111026e1..3a1dc96333 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/MediaPlayerImplTest.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayerTest.kt @@ -19,7 +19,7 @@ package io.element.android.libraries.mediaplayer.impl import kotlinx.coroutines.test.runTest import org.junit.Test -class MediaPlayerImplTest { +class DefaultMediaPlayerTest { @Test fun `default test`() = runTest { // TODO diff --git a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt similarity index 99% rename from libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt rename to libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt index f4e58530f8..20bdf034c1 100644 --- a/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTests.kt +++ b/libraries/mediaupload/api/src/test/kotlin/io/element/android/libraries/mediaupload/api/MediaSenderTest.kt @@ -32,7 +32,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) -class MediaSenderTests { +class MediaSenderTest { @Test fun `given an attachment when sending it the preprocessor always runs`() = runTest { val preProcessor = FakeMediaPreProcessor() diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3d0f9be181..fbe43426aa 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -58,17 +58,16 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric - assertThat(info.thumbnailFile).isNull() + assertThat(info.thumbnailFile).isNotNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( height = 1_178, width = 1_818, mimetype = MimeTypes.Png, size = 114_867, - thumbnailInfo = null, + ThumbnailInfo(height = 294, width = 454, mimetype = "image/jpeg", size = 4567), thumbnailSource = null, - blurhash = null, + blurhash = "K13]7q%zWC00R4of%\$baad" ) ) assertThat(file.exists()).isTrue() @@ -88,7 +87,6 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric assertThat(info.thumbnailFile).isNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt index d56e3e4d71..340f8a624d 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt @@ -57,7 +57,7 @@ open class MediaViewerNode @AssistedInject constructor( MediaViewerView( state = state, modifier = modifier, - onBackPressed = this::navigateUp + onBackClick = this::navigateUp ) } } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt index 1e8261d431..ac9db95644 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt @@ -84,7 +84,7 @@ import kotlin.time.Duration @Composable fun MediaViewerView( state: MediaViewerState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -99,9 +99,9 @@ fun MediaViewerView( showOverlay = showOverlay, state = state, onDismiss = { - onBackPressed() + onBackClick() }, - onShowOverlayChanged = { + onShowOverlayChange = { showOverlay = it } ) @@ -109,7 +109,7 @@ fun MediaViewerView( MediaViewerTopBar( actionsEnabled = state.downloadedMedia is AsyncData.Success, mimeType = state.mediaInfo.mimeType, - onBackPressed = onBackPressed, + onBackClick = onBackClick, canDownload = state.canDownload, canShare = state.canShare, eventSink = state.eventSink @@ -123,7 +123,7 @@ private fun MediaViewerPage( showOverlay: Boolean, state: MediaViewerState, onDismiss: () -> Unit, - onShowOverlayChanged: (Boolean) -> Unit, + onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { fun onRetry() { @@ -135,7 +135,7 @@ private fun MediaViewerPage( } val currentShowOverlay by rememberUpdatedState(showOverlay) - val currentOnShowOverlayChanged by rememberUpdatedState(onShowOverlayChanged) + val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) DismissFlickEffects( @@ -145,7 +145,7 @@ private fun MediaViewerPage( onDismiss() }, onDragging = { - currentOnShowOverlayChanged(false) + currentOnShowOverlayChange(false) } ) @@ -171,7 +171,7 @@ private fun MediaViewerPage( LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { - currentOnShowOverlayChanged(playableState.isShowingControls) + currentOnShowOverlayChange(playableState.isShowingControls) } } @@ -182,7 +182,7 @@ private fun MediaViewerPage( mediaInfo = state.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { - currentOnShowOverlayChanged(!currentShowOverlay) + currentOnShowOverlayChange(!currentShowOverlay) } }, ) @@ -263,7 +263,7 @@ private fun MediaViewerTopBar( canDownload: Boolean, canShare: Boolean, mimeType: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { TopAppBar( @@ -271,7 +271,7 @@ private fun MediaViewerTopBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent.copy(0.6f), ), - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { IconButton( enabled = actionsEnabled, @@ -386,6 +386,6 @@ private fun backgroundColorFor(flickState: FlickToDismissState): Color { internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { MediaViewerView( state = state, - onBackPressed = {} + onBackClick = {} ) } diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt index 42a0a93c64..67d8f7c3a1 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt @@ -25,7 +25,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents @@ -51,9 +51,9 @@ class MediaViewerPresenterTest { @Test fun `present - download media success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -71,10 +71,10 @@ class MediaViewerPresenterTest { @Test fun `present - check all actions `() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions, snackbarDispatcher) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -118,13 +118,13 @@ class MediaViewerPresenterTest { @Test fun `present - download media failure then retry with success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - mediaLoader.shouldFail = true + matrixMediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) @@ -132,7 +132,7 @@ class MediaViewerPresenterTest { assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) - mediaLoader.shouldFail = false + matrixMediaLoader.shouldFail = false failureState.eventSink(MediaViewerEvents.RetryLoading) // There is one recomposition because of the retry mechanism skipItems(1) @@ -146,7 +146,7 @@ class MediaViewerPresenterTest { } private fun createMediaViewerPresenter( - mediaLoader: FakeMediaLoader, + matrixMediaLoader: FakeMatrixMediaLoader, localMediaActions: FakeLocalMediaActions, snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), canShare: Boolean = true, @@ -161,7 +161,7 @@ class MediaViewerPresenterTest { canDownload = canDownload, ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader, + mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, ) diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt index e6c210a19d..38c41fac4b 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt @@ -53,7 +53,7 @@ class MediaViewerViewTest { aMediaViewerState( eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -127,7 +127,7 @@ class MediaViewerViewTest { mediaInfo = anImageMediaInfo(), eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) val imageContentDescription = rule.activity.getString(CommonStrings.common_image) rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown() } @@ -166,12 +166,12 @@ class MediaViewerViewTest { private fun AndroidComposeTestRule.setMediaViewerView( state: MediaViewerState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { MediaViewerView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt index 38b40e6403..790084f193 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -30,18 +30,22 @@ import io.element.android.libraries.ui.strings.CommonStrings fun PermissionsView( state: PermissionsState, modifier: Modifier = Modifier, + title: String = stringResource(id = CommonStrings.common_permission), + content: String? = null, + icon: @Composable (() -> Unit)? = null, ) { if (state.showDialog.not()) return ConfirmationDialog( modifier = modifier, - title = stringResource(id = CommonStrings.common_permission), - content = state.permission.toDialogContent(), + title = title, + content = content ?: state.permission.toDialogContent(), submitText = stringResource(id = CommonStrings.action_open_settings), - onSubmitClicked = { + onSubmitClick = { state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) }, onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + icon = icon, ) } diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt similarity index 80% rename from libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt rename to libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt index 264ac4ec3a..aee59c942b 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt @@ -21,12 +21,13 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope -class FakeSessionPreferenceStoreFactory( - var getLambda: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> throw NotImplementedError() }, - var removeLambda: LambdaOneParamRecorder = lambdaRecorder { _ -> }, +class FakeSessionPreferencesStoreFactory( + val getLambda: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> lambdaError() }, + val removeLambda: LambdaOneParamRecorder = lambdaRecorder { _ -> lambdaError() }, ) : SessionPreferencesStoreFactory { override fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore { return getLambda(sessionId, sessionCoroutineScope) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index 607213953d..ce27acb7b3 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -21,9 +21,6 @@ import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider interface PushService { - // TODO Move away - fun notificationStyleChanged() - /** * Return the current push provider, or null if none. */ diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt index ecdf32f906..f7babb1e35 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt @@ -16,10 +16,15 @@ package io.element.android.libraries.push.api.notifications +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId interface NotificationDrawerManager { + fun clearAllMessagesEvents(sessionId: SessionId) + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) + fun clearEvent(sessionId: SessionId, eventId: EventId) + fun clearMembershipNotificationForSession(sessionId: SessionId) - fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) + fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index cd37d54ec4..37732bf37e 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) api(projects.libraries.pushproviders.api) @@ -64,15 +65,17 @@ dependencies { implementation(projects.services.toolbox.api) testImplementation(libs.test.junit) - testImplementation(libs.test.robolectric) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.coil.test) testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.toolbox.impl) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index 356be64d84..073f452596 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -22,7 +22,7 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.test.TestPush import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.UserPushStoreFactory @@ -31,17 +31,12 @@ import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPushService @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, - private val pushersManager: PushersManager, + private val testPush: TestPush, private val userPushStoreFactory: UserPushStoreFactory, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val scAppStateStore: ScAppStateStore, private val getCurrentPushProvider: GetCurrentPushProvider, ) : PushService { - override fun notificationStyleChanged() { - defaultNotificationDrawerManager.notificationStyleChanged() - } - override suspend fun getCurrentPushProvider(): PushProvider? { val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() return pushProviders.find { it.name == currentPushProvider } @@ -53,21 +48,21 @@ class DefaultPushService @Inject constructor( .sortedBy { it.index } } - /** - * 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, ): Result { + Timber.d("Registering with ${pushProvider.name}/${distributor.name}}") val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val currentPushProviderName = userPushStore.getPushProviderName() 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 - currentPushProvider?.unregister(matrixClient) + currentPushProvider + ?.also { Timber.d("Unregistering previous push provider $currentPushProviderName/$currentDistributorValue") } + ?.unregister(matrixClient) ?.onFailure { Timber.w(it, "Failed to unregister previous push provider") //return Result.failure(it) @@ -83,7 +78,7 @@ class DefaultPushService @Inject constructor( override suspend fun testPush(): Boolean { val pushProvider = getCurrentPushProvider() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false - pushersManager.testPush(config) + testPush.execute(config) return true } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt similarity index 79% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index eb1eda2fb6..c40ecc9f13 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -22,13 +22,9 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData -import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest -import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret @@ -37,29 +33,14 @@ import javax.inject.Inject internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" -private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultPusherSubscriber", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) -class PushersManager @Inject constructor( - // private val localeProvider: LocaleProvider, +class DefaultPusherSubscriber @Inject constructor( private val buildMeta: BuildMeta, - // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, - private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, private val pushClientSecret: PushClientSecret, private val userPushStoreFactory: UserPushStoreFactory, ) : PusherSubscriber { - suspend fun testPush(config: CurrentUserPushConfig) { - pushGatewayNotifyRequest.execute( - PushGatewayNotifyRequest.Params( - url = config.url, - appId = PushConfig.PUSHER_APP_ID, - pushKey = config.pushKey, - eventId = TEST_EVENT_ID, - roomId = TEST_ROOM_ID, - ) - ) - } - /** * Register a pusher to the server if not done yet. */ @@ -131,9 +112,4 @@ class PushersManager @Inject constructor( Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher") } } - - companion object { - val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") - val TEST_ROOM_ID = RoomId("!room:domain") - } } diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt similarity index 54% rename from features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt index 65403adb60..4a5c7d7a44 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenterImplModule.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,17 +14,21 @@ * limitations under the License. */ -package io.element.android.features.leaveroom.impl +package io.element.android.libraries.push.impl.di +import android.content.Context +import androidx.core.app.NotificationManagerCompat import com.squareup.anvil.annotations.ContributesTo -import dagger.Binds import dagger.Module -import io.element.android.features.leaveroom.api.LeaveRoomPresenter -import io.element.android.libraries.di.SessionScope +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext @Module -@ContributesTo(SessionScope::class) -interface LeaveRoomPresenterImplModule { - @Binds - fun leaveRoomPresenter(leaveRoomPresenter: LeaveRoomPresenterImpl): LeaveRoomPresenter +@ContributesTo(AppScope::class) +object PushModule { + @Provides + fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt new file mode 100644 index 0000000000..0746e9c5cd --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt @@ -0,0 +1,73 @@ +/* + * 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.push.impl.notifications + +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject + +interface ActiveNotificationsProvider { + fun getAllNotifications(): List + fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List + fun getNotificationsForSession(sessionId: SessionId): List + fun getMembershipNotificationForSession(sessionId: SessionId): List + fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List + fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? + fun count(sessionId: SessionId): Int +} + +@ContributesBinding(AppScope::class) +class DefaultActiveNotificationsProvider @Inject constructor( + private val notificationManager: NotificationManagerCompat, + private val notificationIdProvider: NotificationIdProvider, +) : ActiveNotificationsProvider { + override fun getAllNotifications(): List { + return notificationManager.activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return notificationManager.activeNotifications.filter { it.notification.group == sessionId.value } + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId } + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId) + return getNotificationsForSession(sessionId).find { it.id == summaryId } + } + + override fun count(sessionId: SessionId): Int { + return getNotificationsForSession(sessionId).size + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt similarity index 95% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index c0eae30c4a..0c284da351 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider @@ -56,7 +58,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.NotificationLoggerTag) +private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag) /** * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. @@ -64,15 +66,20 @@ private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.Notificat * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. */ -class NotifiableEventResolver @Inject constructor( +interface NotifiableEventResolver { + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? +} + +@ContributesBinding(AppScope::class) +class DefaultNotifiableEventResolver @Inject constructor( private val stringProvider: StringProvider, private val clock: SystemClock, private val matrixClientProvider: MatrixClientProvider, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, @ApplicationContext private val context: Context, private val permalinkParser: PermalinkParser, -) { - suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val notificationService = client.notificationService() @@ -145,8 +152,10 @@ class NotifiableEventResolver @Inject constructor( } NotificationContent.MessageLike.CallAnswer, NotificationContent.MessageLike.CallCandidates, - NotificationContent.MessageLike.CallHangup -> null.also { + NotificationContent.MessageLike.CallHangup, + is NotificationContent.MessageLike.CallNotify -> { // TODO CallNotify will be handled separately in the future Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}") + null } is NotificationContent.MessageLike.CallInvite -> { buildNotifiableMessageEvent( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 7c9a60c279..03c1cd21a2 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -16,14 +16,13 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.androidutils.throttler.FirstThrottler -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationManagerCompat import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -33,45 +32,36 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.currentSessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag) /** - * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and + * The NotificationDrawerManager receives notification events as they arrive (from event stream or fcm) and * organise them in order to display them in the notification drawer. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. */ @SingleIn(AppScope::class) class DefaultNotificationDrawerManager @Inject constructor( - private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationManager: NotificationManagerCompat, private val notificationRenderer: NotificationRenderer, - private val notificationEventPersistence: NotificationEventPersistence, - private val filteredEventDetector: FilteredEventDetector, + private val notificationIdProvider: NotificationIdProvider, private val appNavigationStateService: AppNavigationStateService, - private val coroutineScope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val buildMeta: BuildMeta, + coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, + private val activeNotificationsProvider: ActiveNotificationsProvider, ) : NotificationDrawerManager { private var appNavigationStateObserver: Job? = null - /** - * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. - */ - private val notificationState by lazy { createInitialNotificationState() } - private val firstThrottler = FirstThrottler(200) - // TODO EAx add a setting per user for this private var useCompleteNotificationFormat = true @@ -84,7 +74,8 @@ class DefaultNotificationDrawerManager @Inject constructor( } // For test only - fun destroy() { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun destroy() { appNavigationStateObserver?.cancel() } @@ -105,7 +96,6 @@ class DefaultNotificationDrawerManager @Inject constructor( clearMessagesForRoom( sessionId = navigationState.parentSpace.parentSession.sessionId, roomId = navigationState.roomId, - doRender = true, ) } is NavigationState.Thread -> { @@ -119,95 +109,71 @@ class DefaultNotificationDrawerManager @Inject constructor( currentAppNavigationState = navigationState } - private fun createInitialNotificationState(): NotificationState { - val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - }) - val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList() - return NotificationState(queuedEvents, renderedEvents) - } - - private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - if (buildMeta.lowPrivacyLoggingEnabled) { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): $notifiableEvent") - } else { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") - } - - if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): ignore the event") - return - } - - add(notifiableEvent) - } - /** - * Should be called as soon as a new event is ready to be displayed. - * The notification corresponding to this event will not be displayed until - * #refreshNotificationDrawer() is called. + * Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed. * Events might be grouped and there might not be one notification per event! */ - fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - updateEvents(doRender = true) { - it.onNotifiableEventReceived(notifiableEvent) + suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { + return } + renderEvents(listOf(notifiableEvent)) } /** - * Clear all known events and refresh the notification drawer. + * Clear all known message events for a [sessionId]. */ - fun clearAllMessagesEvents(sessionId: SessionId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearMessagesForSession(sessionId) - } + override fun clearAllMessagesEvents(sessionId: SessionId) { + notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) } /** - * Clear all notifications related to the session and refresh the notification drawer. + * Clear all notifications related to the session. */ fun clearAllEvents(sessionId: SessionId) { - updateEvents(doRender = true) { - it.clearAllForSession(sessionId) - } + activeNotificationsProvider.getNotificationsForSession(sessionId) + .forEach { notificationManager.cancel(it.tag, it.id) } } /** - * Should be called when the application is currently opened and showing timeline for the given roomId. + * Should be called when the application is currently opened and showing timeline for the given [roomId]. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. * Can also be called when a notification for this room is dismissed by the user. */ - fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearMessagesForRoom(sessionId, roomId) - } + override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) } override fun clearMembershipNotificationForSession(sessionId: SessionId) { - updateEvents(doRender = true) { - it.clearMembershipNotificationForSession(sessionId) - } + activeNotificationsProvider.getMembershipNotificationForSession(sessionId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear invitation notification for the provided room. */ - override fun clearMembershipNotificationForRoom( - sessionId: SessionId, - roomId: RoomId, - doRender: Boolean, - ) { - updateEvents(doRender = doRender) { - it.clearMembershipNotificationForRoom(sessionId, roomId) - } + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear the notifications for a single event. */ - fun clearEvent(sessionId: SessionId, eventId: EventId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearEvent(sessionId, eventId) + override fun clearEvent(sessionId: SessionId, eventId: EventId) { + val id = notificationIdProvider.getRoomEventNotificationId(sessionId) + notificationManager.cancel(eventId.value, id) + clearSummaryNotificationIfNeeded(sessionId) + } + + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { + val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) + if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { + notificationManager.cancel(null, summaryNotification.id) } } @@ -215,81 +181,19 @@ class DefaultNotificationDrawerManager @Inject constructor( * Should be called when the application is currently opened and showing timeline for the given threadId. * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. */ + @Suppress("UNUSED_PARAMETER") private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { - updateEvents(doRender = true) { - it.clearMessagesForThread(sessionId, roomId, threadId) - } + // TODO maybe we'll have to embed more data in the tag to get a threadId + // Do nothing for now } - // TODO EAx Must be per account - fun notificationStyleChanged() { - updateEvents(doRender = true) { - val newSettings = true // pushDataStore.useCompleteNotificationFormat() - if (newSettings != useCompleteNotificationFormat) { - // Settings has changed, remove all current notifications - notificationRenderer.cancelAllNotifications() - useCompleteNotificationFormat = newSettings - } - } - } - - private fun updateEvents( - doRender: Boolean, - action: (NotificationEventQueue) -> Unit, - ) { - notificationState.updateQueuedEvents { queuedEvents, _ -> - action(queuedEvents) - } - coroutineScope.refreshNotificationDrawer(doRender) - } - - private fun CoroutineScope.refreshNotificationDrawer(doRender: Boolean) = launch { - // Implement last throttler - val canHandle = firstThrottler.canHandle() - Timber.tag(loggerTag.value).v("refreshNotificationDrawer($doRender), delay: ${canHandle.waitMillis()} ms") - withContext(dispatchers.io) { - delay(canHandle.waitMillis()) - try { - refreshNotificationDrawerBg(doRender) - } catch (throwable: Throwable) { - // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer - Timber.tag(loggerTag.value).w(throwable, "refreshNotificationDrawerBg failure") - } - } - } - - private suspend fun refreshNotificationDrawerBg(doRender: Boolean) { - Timber.tag(loggerTag.value).v("refreshNotificationDrawerBg($doRender)") - val eventsToRender = notificationState.updateQueuedEvents { queuedEvents, renderedEvents -> - notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also { - queuedEvents.clearAndAdd(it.onlyKeptEvents()) - } - } - - if (notificationState.hasAlreadyRendered(eventsToRender)) { - Timber.tag(loggerTag.value).d("Skipping notification update due to event list not changing") - } else { - notificationState.clearAndAddRenderedEvents(eventsToRender) - if (doRender) { - renderEvents(eventsToRender) - } - persistEvents() - } - } - - private fun persistEvents() { - notificationState.queuedEvents { queuedEvents -> - notificationEventPersistence.persistEvents(queuedEvents) - } - } - - private suspend fun renderEvents(eventsToRender: List>) { + private suspend fun renderEvents(eventsToRender: List) { // Group by sessionId val eventsForSessions = eventsToRender.groupBy { - it.event.sessionId + it.sessionId } - eventsForSessions.forEach { (sessionId, notifiableEvents) -> + for ((sessionId, notifiableEvents) in eventsForSessions) { val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() val imageLoader = imageLoaderHolder.get(client) val userFromCache = client.userProfile.value @@ -297,27 +201,29 @@ class DefaultNotificationDrawerManager @Inject constructor( // We have an avatar and a display name, use it userFromCache } else { - tryOrNull( - onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, - operation = { - client.getUserProfile().getOrNull() - ?.let { - // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash - if (it.displayName.isNullOrEmpty()) { - it.copy(displayName = sessionId.value) - } else { - it - } - } - } - ) ?: MatrixUser( - userId = sessionId, - displayName = sessionId.value, - avatarUrl = null - ) + client.getSafeUserProfile() } notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) } } + + private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser { + return tryOrNull( + onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, + operation = { + val profile = getUserProfile().getOrNull() + // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash + if (profile?.displayName.isNullOrEmpty()) { + profile?.copy(displayName = sessionId.value) + } else { + profile + } + } + ) ?: MatrixUser( + userId = sessionId, + displayName = sessionId.value, + avatarUrl = null + ) + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt deleted file mode 100644 index 3646837937..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import android.content.Context -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidutils.file.EncryptedFileFactory -import io.element.android.libraries.androidutils.file.safeDelete -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import timber.log.Timber -import java.io.File -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import javax.inject.Inject - -private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache" -private const val FILE_NAME = "notifications.bin" - -private val loggerTag = LoggerTag("NotificationEventPersistence", LoggerTag.NotificationLoggerTag) - -@ContributesBinding(AppScope::class) -class DefaultNotificationEventPersistence @Inject constructor( - @ApplicationContext private val context: Context, -) : NotificationEventPersistence { - private val file by lazy { - deleteLegacyFileIfAny() - context.getDatabasePath(FILE_NAME) - } - - private val encryptedFile by lazy { - EncryptedFileFactory(context).create(file) - } - - override fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - val rawEvents: ArrayList? = file - .takeIf { it.exists() } - ?.let { - try { - encryptedFile.openFileInput().use { fis -> - ObjectInputStream(fis).use { ois -> - @Suppress("UNCHECKED_CAST") - ois.readObject() as? ArrayList - } - }.also { - Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)") - } - } catch (e: Throwable) { - Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info") - null - } - } - return factory(rawEvents.orEmpty()) - } - - override fun persistEvents(queuedEvents: NotificationEventQueue) { - Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") - // Always delete file before writing, or encryptedFile.openFileOutput() will throw - file.safeDelete() - if (queuedEvents.isEmpty()) return - try { - encryptedFile.openFileOutput().use { fos -> - ObjectOutputStream(fos).use { oos -> - oos.writeObject(queuedEvents.rawEvents()) - } - } - } catch (e: Throwable) { - Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info") - } - } - - private fun deleteLegacyFileIfAny() { - tryOrNull { - File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete() - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt deleted file mode 100644 index 3219427b8a..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2023 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.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import javax.inject.Inject - -class FilteredEventDetector @Inject constructor( - // private val activeSessionDataSource: ActiveSessionDataSource -) { - /** - * Returns true if the given event should be ignored. - * Used to skip notifications if a non expected message is received. - */ - fun shouldBeIgnored(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean { - /* TODO EAx - val session = activeSessionDataSource.currentValue?.orNull() ?: return false - - if (notifiableEvent is NotifiableMessageEvent) { - val room = session.getRoom(notifiableEvent.roomId) ?: return false - val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false - return timelineEvent.shouldBeIgnored() - } - - */ - return false - } - - /* - /** - * Whether the timeline event should be ignored. - */ - private fun TimelineEvent.shouldBeIgnored(): Boolean { - if (root.isVoiceMessage()) { - val audioEvent = root.asMessageAudioEvent() - // if the event is a voice message related to a voice broadcast, only show the event on the first chunk. - return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1 - } - - return false - } - */ -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt deleted file mode 100644 index 4da6dbdd59..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.matrix.api.timeline.item.event.EventType -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom -import io.element.android.services.appnavstate.api.AppNavigationStateService -import timber.log.Timber -import javax.inject.Inject - -private typealias ProcessedEvents = List> - -private val loggerTag = LoggerTag("NotifiableEventProcessor", LoggerTag.NotificationLoggerTag) - -class NotifiableEventProcessor @Inject constructor( - private val outdatedDetector: OutdatedEventDetector, - private val appNavigationStateService: AppNavigationStateService, -) { - fun process( - queuedEvents: List, - renderedEvents: ProcessedEvents, - ): ProcessedEvents { - val appState = appNavigationStateService.appNavigationState.value - val processedEvents = queuedEvents.map { - val type = when (it) { - is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP - is NotifiableMessageEvent -> when { - it.shouldIgnoreEventInRoom(appState) -> { - ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification message removed due to currently viewing the same room or thread") } - } - outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification message removed due to being read") } - else -> ProcessedEvent.Type.KEEP - } - is SimpleNotifiableEvent -> when (it.type) { - EventType.REDACTION -> ProcessedEvent.Type.REMOVE - else -> ProcessedEvent.Type.KEEP - } - is FallbackNotifiableEvent -> when { - it.shouldIgnoreEventInRoom(appState) -> { - ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification fallback removed due to currently viewing the same room or thread") } - } - else -> ProcessedEvent.Type.KEEP - } - } - ProcessedEvent(type, it) - } - - val removedEventsDiff = renderedEvents.filter { renderedEvent -> - queuedEvents.none { it.eventId == renderedEvent.event.eventId } - }.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) } - - return removedEventsDiff + processedEvents - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt index 6141079130..1907d11732 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationActionIds.kt @@ -20,16 +20,13 @@ import io.element.android.libraries.core.meta.BuildMeta import javax.inject.Inject /** - * Util class for creating notifications. - * Note: Cannot inject ColorProvider in the constructor, because it requires an Activity + * Util class for creating notifications action Ids, using the application id. */ - data class NotificationActionIds @Inject constructor( private val buildMeta: BuildMeta, ) { val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION" val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION" - val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION" val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION" val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" @@ -37,5 +34,4 @@ data class NotificationActionIds @Inject constructor( val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION" val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION" val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC" - val push = "${buildMeta.applicationId}.PUSH" } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt index 360357af54..5933cb885f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -20,221 +20,19 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import io.element.android.libraries.architecture.bindings -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId -import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("NotificationBroadcastReceiver", LoggerTag.NotificationLoggerTag) - /** * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). */ class NotificationBroadcastReceiver : BroadcastReceiver() { - @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager - @Inject lateinit var actionIds: NotificationActionIds + @Inject lateinit var notificationBroadcastReceiverHandler: NotificationBroadcastReceiverHandler override fun onReceive(context: Context?, intent: Intent?) { if (intent == null || context == null) return context.bindings().inject(this) - val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return - val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) - val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId) - Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") - when (intent.action) { - actionIds.smartReply -> - handleSmartReply(intent, context) - actionIds.dismissRoom -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = false) - } - actionIds.dismissSummary -> - defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId, doRender = false) - actionIds.dismissInvite -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = false) - } - actionIds.dismissEvent -> if (eventId != null) { - defaultNotificationDrawerManager.clearEvent(sessionId, eventId, doRender = false) - } - actionIds.markRoomRead -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = true) - handleMarkAsRead(sessionId, roomId) - } - actionIds.join -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) - handleJoinRoom(sessionId, roomId) - } - actionIds.reject -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) - handleRejectRoom(sessionId, roomId) - } - } - } - - @Suppress("UNUSED_PARAMETER") - private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { - session.roomService().joinRoom(room.roomId) - analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification)) - } - } - } - } - */ - } - - @Suppress("UNUSED_PARAMETER") - private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - session.coroutineScope.launch { - tryOrNull { session.roomService().leaveRoom(roomId) } - } - } - - */ - } - - @Suppress("UNUSED_PARAMETER") - private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getActiveSession().let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } - } - } - } - - */ - } - - @Suppress("UNUSED_PARAMETER") - private fun handleSmartReply(intent: Intent, context: Context) { - /* - val message = getReplyMessage(intent) - val sessionId = intent.getStringExtra(KEY_SESSION_ID)?.let(::SessionId) - val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) - val threadId = intent.getStringExtra(KEY_THREAD_ID)?.let(::ThreadId) - - if (message.isNullOrBlank() || roomId == null) { - // ignore this event - // Can this happen? should we update notification? - return - } - activeSessionHolder.getActiveSession().let { session -> - session.getRoom(roomId)?.let { room -> - sendMatrixEvent(message, threadId, session, room, context) - } - } - */ - } - - /* - private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) { - if (threadId != null) { - room.relationService().replyInThread( - rootThreadEventId = threadId, - replyInThreadText = message, - ) - } else { - room.sendService().sendTextMessage(message) - } - - // Create a new event to be displayed in the notification drawer, right now - - val notifiableMessageEvent = NotifiableMessageEvent( - // Generate a Fake event id - eventId = UUID.randomUUID().toString(), - editedEventId = null, - noisy = false, - timestamp = clock.epochMillis(), - senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName - ?: context?.getString(R.string.notification_sender_me), - senderId = session.myUserId, - body = message, - imageUriString = null, - roomId = room.roomId, - threadId = threadId, - roomName = room.roomSummary()?.displayName ?: room.roomId, - roomIsDirect = room.roomSummary()?.isDirect == true, - outGoingMessage = true, - canBeReplaced = false - ) - - notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) } - - /* - // TODO Error cannot be managed the same way than in Riot - - val event = Event(mxMessage, session.credentials.userId, roomId) - room.storeOutgoingEvent(event) - room.sendEvent(event, object : MatrixCallback { - override fun onSuccess(info: Void?) { - Timber.v("Send message : onSuccess ") - } - - override fun onNetworkError(e: Exception) { - Timber.e(e, "Send message : onNetworkError") - onSmartReplyFailed(e.localizedMessage) - } - - override fun onMatrixError(e: MatrixError) { - Timber.v("Send message : onMatrixError " + e.message) - if (e is MXCryptoError) { - Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show() - onSmartReplyFailed(e.detailedErrorDescription) - } else { - Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show() - onSmartReplyFailed(e.localizedMessage) - } - } - - override fun onUnexpectedError(e: Exception) { - Timber.e(e, "Send message : onUnexpectedError " + e.message) - onSmartReplyFailed(e.message) - } - - - fun onSmartReplyFailed(reason: String?) { - val notifiableMessageEvent = NotifiableMessageEvent( - event.eventId, - false, - clock.epochMillis(), - session.myUser?.displayname - ?: context?.getString(R.string.notification_sender_me), - session.myUserId, - message, - roomId, - room.getRoomDisplayName(context), - room.isDirect) - notifiableMessageEvent.outGoingMessage = true - notifiableMessageEvent.outGoingMessageFailed = true - - VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) - VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null) - } - }) - */ - } - - private fun getReplyMessage(intent: Intent?): String? { - if (intent != null) { - val remoteInput = RemoteInput.getResultsFromIntent(intent) - if (remoteInput != null) { - return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() - } - } - return null + notificationBroadcastReceiverHandler.onReceive(intent) } - */ companion object { const val KEY_SESSION_ID = "sessionID" diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt new file mode 100644 index 0000000000..1dc529cf58 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandler.kt @@ -0,0 +1,191 @@ +/* + * 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.push.impl.notifications + +import android.content.Intent +import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +private val loggerTag = LoggerTag("NotificationBroadcastReceiverHandler", LoggerTag.NotificationLoggerTag) + +class NotificationBroadcastReceiverHandler @Inject constructor( + private val appCoroutineScope: CoroutineScope, + private val matrixClientProvider: MatrixClientProvider, + private val sessionPreferencesStore: SessionPreferencesStoreFactory, + private val notificationDrawerManager: NotificationDrawerManager, + private val actionIds: NotificationActionIds, + private val systemClock: SystemClock, + private val onNotifiableEventReceived: OnNotifiableEventReceived, + private val stringProvider: StringProvider, + private val replyMessageExtractor: ReplyMessageExtractor, +) { + fun onReceive(intent: Intent) { + val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return + val roomId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_ROOM_ID)?.let(::RoomId) + val threadId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_THREAD_ID)?.let(::ThreadId) + val eventId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_EVENT_ID)?.let(::EventId) + + Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") + when (intent.action) { + actionIds.smartReply -> if (roomId != null) { + handleSmartReply(sessionId, roomId, threadId, intent) + } + actionIds.dismissRoom -> if (roomId != null) { + notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + } + actionIds.dismissSummary -> + notificationDrawerManager.clearAllMessagesEvents(sessionId) + actionIds.dismissInvite -> if (roomId != null) { + notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + } + actionIds.dismissEvent -> if (eventId != null) { + notificationDrawerManager.clearEvent(sessionId, eventId) + } + actionIds.markRoomRead -> if (roomId != null) { + notificationDrawerManager.clearMessagesForRoom(sessionId, roomId) + handleMarkAsRead(sessionId, roomId) + } + actionIds.join -> if (roomId != null) { + notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + handleJoinRoom(sessionId, roomId) + } + actionIds.reject -> if (roomId != null) { + notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) + handleRejectRoom(sessionId, roomId) + } + } + } + + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.joinRoom(roomId) + } + + private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.getRoom(roomId)?.leave() + } + + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first() + val receiptType = if (isSendPublicReadReceiptsEnabled) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + client.getRoom(roomId)?.markAsRead(receiptType = receiptType) + } + + private fun handleSmartReply( + sessionId: SessionId, + roomId: RoomId, + threadId: ThreadId?, + intent: Intent, + ) = appCoroutineScope.launch { + val message = replyMessageExtractor.getReplyMessage(intent) + if (message.isNullOrBlank()) { + // ignore this event + // Can this happen? should we update notification? + return@launch + } + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.getRoom(roomId)?.let { room -> + sendMatrixEvent( + sessionId = sessionId, + roomId = roomId, + threadId = threadId, + room = room, + message = message, + ) + } + } + + private suspend fun sendMatrixEvent( + sessionId: SessionId, + roomId: RoomId, + threadId: ThreadId?, + room: MatrixRoom, + message: String, + ) { + // Create a new event to be displayed in the notification drawer, right now + val notifiableMessageEvent = NotifiableMessageEvent( + sessionId = sessionId, + roomId = roomId, + // Generate a Fake event id + eventId = EventId("\$" + UUID.randomUUID().toString()), + editedEventId = null, + canBeReplaced = false, + senderId = sessionId, + noisy = false, + timestamp = systemClock.epochMillis(), + senderDisambiguatedDisplayName = room.getUpdatedMember(sessionId).getOrNull() + ?.disambiguatedDisplayName + ?: stringProvider.getString(R.string.notification_sender_me), + body = message, + imageUriString = null, + threadId = threadId, + roomName = room.displayName, + roomIsDirect = room.isDirect, + outGoingMessage = true, + ) + onNotifiableEventReceived.onNotifiableEventReceived(notifiableMessageEvent) + + if (threadId != null) { + room.liveTimeline.replyMessage( + eventId = threadId.asEventId(), + body = message, + htmlBody = null, + mentions = emptyList(), + fromNotification = true, + ) + } else { + room.liveTimeline.sendMessage( + body = message, + htmlBody = null, + mentions = emptyList() + ) + }.onFailure { + Timber.e(it, "Failed to send smart reply message") + onNotifiableEventReceived.onNotifiableEventReceived( + notifiableMessageEvent.copy( + outGoingMessageFailed = true + ) + ) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt new file mode 100644 index 0000000000..aff2cc7fca --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2021 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.push.impl.notifications + +import android.app.Notification +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +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.user.MatrixUser +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +interface NotificationDataFactory { + suspend fun toNotifications( + messages: List, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(invites: List): List + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(simpleEvents: List): List + fun toNotifications(fallback: List): List + + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): SummaryNotification +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationDataFactory @Inject constructor( + private val notificationCreator: NotificationCreator, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator, + private val activeNotificationsProvider: ActiveNotificationsProvider, + private val stringProvider: StringProvider, +) : NotificationDataFactory { + override suspend fun toNotifications( + messages: List, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List { + val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } + .groupBy { it.roomId } + return messagesToDisplay.map { (roomId, events) -> + val roomName = events.lastOrNull()?.roomName ?: roomId.value + val isDirect = events.lastOrNull()?.roomIsDirect ?: false + val notification = roomGroupMessageCreator.createRoomMessage( + currentUser = currentUser, + events = events, + roomId = roomId, + imageLoader = imageLoader, + existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId), + ) + RoomNotification( + notification = notification, + roomId = roomId, + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDirect), + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy } + ) + } + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? { + return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(invites: List): List { + return invites.map { event -> + OneShotNotification( + key = event.roomId.value, + notification = notificationCreator.createRoomInvitationNotification(event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(simpleEvents: List): List { + return simpleEvents.map { event -> + OneShotNotification( + key = event.eventId.value, + notification = notificationCreator.createSimpleEventNotification(event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + override fun toNotifications(fallback: List): List { + return fallback.map { event -> + OneShotNotification( + key = event.eventId.value, + notification = notificationCreator.createFallbackNotification(event), + summaryLine = event.description.orEmpty(), + isNoisy = false, + timestamp = event.timestamp + ) + } + } + + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): SummaryNotification { + return when { + roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + currentUser = currentUser, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + ) + ) + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { + return when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence { + return if (roomIsDirect) { + buildSpannedString { + event.senderDisambiguatedDisplayName?.let { + inSpans(StyleSpan(Typeface.BOLD)) { + append(it) + append(": ") + } + } + append(event.description) + } + } else { + buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append(roomName) + append(": ") + event.senderDisambiguatedDisplayName?.let { + append(it) + append(" ") + } + } + append(event.description) + } + } + } +} + +data class RoomNotification( + val notification: Notification, + val roomId: RoomId, + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val shouldBing: Boolean, +) { + fun isDataEqualTo(other: RoomNotification): Boolean { + return notification == other.notification && + roomId == other.roomId && + summaryLine.toString() == other.summaryLine.toString() && + messageCount == other.messageCount && + latestTimestamp == other.latestTimestamp && + shouldBing == other.shouldBing + } +} + +data class OneShotNotification( + val notification: Notification, + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, +) + +sealed interface SummaryNotification { + data object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 04202bbb2f..96b6cac8d3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -22,16 +22,25 @@ import android.content.Context import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import timber.log.Timber import javax.inject.Inject -class NotificationDisplayer @Inject constructor( - @ApplicationContext private val context: Context, -) { - private val notificationManager = NotificationManagerCompat.from(context) +interface NotificationDisplayer { + fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean + fun cancelNotificationMessage(tag: String?, id: Int) + fun displayDiagnosticNotification(notification: Notification): Boolean + fun dismissDiagnosticNotification() +} - fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { +@ContributesBinding(AppScope::class) +class DefaultNotificationDisplayer @Inject constructor( + @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat +) : NotificationDisplayer { + override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Timber.w("Not allowed to notify.") return false @@ -40,20 +49,11 @@ class NotificationDisplayer @Inject constructor( return true } - fun cancelNotificationMessage(tag: String?, id: Int) { + override fun cancelNotificationMessage(tag: String?, id: Int) { notificationManager.cancel(tag, id) } - fun cancelAllNotifications() { - // Keep this try catch (reported by GA) - try { - notificationManager.cancelAll() - } catch (e: Exception) { - Timber.e(e, "## cancelAllNotifications() failed") - } - } - - fun displayDiagnosticNotification(notification: Notification): Boolean { + override fun displayDiagnosticNotification(notification: Notification): Boolean { return showNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC, @@ -61,33 +61,17 @@ class NotificationDisplayer @Inject constructor( ) } - fun dismissDiagnosticNotification() { + override fun dismissDiagnosticNotification() { cancelNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC ) } - /** - * Cancel the foreground notification service. - */ - fun cancelNotificationForegroundService() { - notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) - } - companion object { /* ========================================================================================== * IDs for notifications * ========================================================================================== */ - - /** - * Identifier of the foreground notification used to keep the application alive - * when it runs in background. - * This notification, which is not removable by the end user, displays what - * the application is doing while in background. - */ - private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61 - private const val NOTIFICATION_ID_DIAGNOSTIC = 888 } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt deleted file mode 100644 index c78244356e..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.ThreadId -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import timber.log.Timber - -data class NotificationEventQueue( - private val queue: MutableList, - /** - * An in memory FIFO cache of the seen events. - * Acts as a notification debouncer to stop already dismissed push notifications from - * displaying again when the /sync response is delayed. - * TODO Should be per session, so the key must be Pair. - */ - private val seenEventIds: CircularCache -) { - fun markRedacted(eventIds: List) { - eventIds.forEach { redactedId -> - queue.replace(redactedId) { - when (it) { - is InviteNotifiableEvent -> it.copy(isRedacted = true) - is NotifiableMessageEvent -> it.copy(isRedacted = true) - is SimpleNotifiableEvent -> it.copy(isRedacted = true) - is FallbackNotifiableEvent -> it.copy(isRedacted = true) - } - } - } - } - - // TODO EAx call this - fun syncRoomEvents(roomsLeft: Collection, roomsJoined: Collection) { - if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) { - queue.removeAll { - when (it) { - is NotifiableMessageEvent -> roomsLeft.contains(it.roomId) - is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId) - is SimpleNotifiableEvent -> false - is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId) - } - } - } - } - - fun isEmpty() = queue.isEmpty() - - fun clearAndAdd(events: List) { - queue.clear() - queue.addAll(events) - } - - fun clear() { - queue.clear() - } - - fun add(notifiableEvent: NotifiableEvent) { - val existing = findExistingById(notifiableEvent) - val edited = findEdited(notifiableEvent) - when { - existing != null -> { - if (existing.canBeReplaced) { - // Use the event coming from the event stream as it may contains more info than - // the fcm one (like type/content/clear text) (e.g when an encrypted message from - // FCM should be update with clear text after a sync) - // In this case the message has already been notified, and might have done some noise - // So we want the notification to be updated even if it has already been displayed - // Use setOnlyAlertOnce to ensure update notification does not interfere with sound - // from first notify invocation as outlined in: - // https://developer.android.com/training/notify-user/build-notification#Updating - replace(replace = existing, with = notifiableEvent) - } else { - // keep the existing one, do not replace - } - } - edited != null -> { - // Replace the existing notification with the new content - replace(replace = edited, with = notifiableEvent) - } - seenEventIds.contains(notifiableEvent.eventId) -> { - // we've already seen the event, lets skip - Timber.d("onNotifiableEventReceived(): skipping event, already seen") - } - else -> { - seenEventIds.put(notifiableEvent.eventId) - queue.add(notifiableEvent) - } - } - } - - private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? { - return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId } - } - - private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? { - return notifiableEvent.editedEventId?.let { editedId -> - queue.firstOrNull { - it.eventId == editedId || it.editedEventId == editedId - } - } - } - - private fun replace(replace: NotifiableEvent, with: NotifiableEvent) { - queue.remove(replace) - queue.add( - when (with) { - is InviteNotifiableEvent -> with.copy(isUpdated = true) - is NotifiableMessageEvent -> with.copy(isUpdated = true) - is SimpleNotifiableEvent -> with.copy(isUpdated = true) - is FallbackNotifiableEvent -> with.copy(isUpdated = true) - } - ) - } - - fun clearEvent(sessionId: SessionId, eventId: EventId) { - val isFallback = queue.firstOrNull { it.sessionId == sessionId && it.eventId == eventId } is FallbackNotifiableEvent - if (isFallback) { - Timber.d("Removing all the fallbacks") - queue.removeAll { it.sessionId == sessionId && it is FallbackNotifiableEvent } - } else { - queue.removeAll { it.sessionId == sessionId && it.eventId == eventId } - } - } - - fun clearMembershipNotificationForSession(sessionId: SessionId) { - Timber.d("clearMemberShipOfSession $sessionId") - queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId } - } - - fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { - Timber.d("clearMemberShipOfRoom $sessionId, $roomId") - queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } - } - - fun clearMessagesForSession(sessionId: SessionId) { - Timber.d("clearMessagesForSession $sessionId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId } - } - - fun clearAllForSession(sessionId: SessionId) { - Timber.d("clearAllForSession $sessionId") - queue.removeAll { it.sessionId == sessionId } - } - - fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { - Timber.d("clearMessageEventOfRoom $sessionId, $roomId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId } - } - - fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { - Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId } - } - - fun rawEvents(): List = queue -} - -private fun MutableList.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) { - val indexToReplace = indexOfFirst { it.eventId == eventId } - if (indexToReplace == -1) { - return - } - set(indexToReplace, block(get(indexToReplace))) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt deleted file mode 100644 index ef3623f302..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import android.app.Notification -import coil.ImageLoader -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import javax.inject.Inject - -private typealias ProcessedMessageEvents = List> - -class NotificationFactory @Inject constructor( - private val notificationCreator: NotificationCreator, - private val roomGroupMessageCreator: RoomGroupMessageCreator, - private val summaryGroupMessageCreator: SummaryGroupMessageCreator -) { - suspend fun Map.toNotifications( - currentUser: MatrixUser, - imageLoader: ImageLoader, - ): List { - return map { (roomId, events) -> - when { - events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) - else -> { - val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } - roomGroupMessageCreator.createRoomMessage( - currentUser = currentUser, - events = messageEvents, - roomId = roomId, - imageLoader = imageLoader, - ) - } - } - } - } - - private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { - it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() - } - - private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted - - @JvmName("toNotificationsInviteNotifiableEvent") - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createRoomInvitationNotification(event), - OneShotNotification.Append.Meta( - key = event.roomId.value, - summaryLine = event.description, - isNoisy = event.noisy, - timestamp = event.timestamp - ) - ) - } - } - } - - @JvmName("toNotificationsSimpleNotifiableEvent") - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createSimpleEventNotification(event), - OneShotNotification.Append.Meta( - key = event.eventId.value, - summaryLine = event.description, - isNoisy = event.noisy, - timestamp = event.timestamp - ) - ) - } - } - } - - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createFallbackNotification(event), - OneShotNotification.Append.Meta( - key = event.eventId.value, - summaryLine = event.description.orEmpty(), - isNoisy = false, - timestamp = event.timestamp - ) - ) - } - } - } - - fun createSummaryNotification( - currentUser: MatrixUser, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - useCompleteNotificationFormat: Boolean - ): SummaryNotification { - val roomMeta = roomNotifications.filterIsInstance().map { it.meta } - val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } - val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } - val fallbackMeta = fallbackNotifications.filterIsInstance().map { it.meta } - return when { - roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed - else -> SummaryNotification.Update( - summaryGroupMessageCreator.createSummaryNotification( - currentUser = currentUser, - roomNotifications = roomMeta, - invitationNotifications = invitationMeta, - simpleNotifications = simpleMeta, - fallbackNotifications = fallbackMeta, - useCompleteNotificationFormat = useCompleteNotificationFormat - ) - ) - } - } -} - -sealed interface RoomNotification { - data class Removed(val roomId: RoomId) : RoomNotification - data class Message(val notification: Notification, val meta: Meta) : RoomNotification { - data class Meta( - val roomId: RoomId, - val summaryLine: CharSequence, - val messageCount: Int, - val latestTimestamp: Long, - val shouldBing: Boolean - ) - } -} - -sealed interface OneShotNotification { - data class Removed(val key: String) : OneShotNotification - data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { - data class Meta( - val key: String, - val summaryLine: CharSequence, - val isNoisy: Boolean, - val timestamp: Long, - ) - } -} - -sealed interface SummaryNotification { - data object Removed : SummaryNotification - data class Update(val notification: Notification) : SummaryNotification -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt index 2c826abf10..a1509aaa96 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.push.impl.notifications import coil.ImageLoader import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -33,27 +32,26 @@ private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.Notification class NotificationRenderer @Inject constructor( private val notificationIdProvider: NotificationIdProvider, private val notificationDisplayer: NotificationDisplayer, - private val notificationFactory: NotificationFactory, + private val notificationDataFactory: NotificationDataFactory, ) { suspend fun render( currentUser: MatrixUser, useCompleteNotificationFormat: Boolean, - eventsToProcess: List>, + eventsToProcess: List, imageLoader: ImageLoader, ) { val groupedEvents = eventsToProcess.groupByType() - with(notificationFactory) { - val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser, imageLoader) - val invitationNotifications = groupedEvents.invitationEvents.toNotifications() - val simpleNotifications = groupedEvents.simpleEvents.toNotifications() - val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications() + with(notificationDataFactory) { + val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader) + val invitationNotifications = toNotifications(groupedEvents.invitationEvents) + val simpleNotifications = toNotifications(groupedEvents.simpleEvents) + val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents) val summaryNotification = createSummaryNotification( currentUser = currentUser, roomNotifications = roomNotifications, invitationNotifications = invitationNotifications, simpleNotifications = simpleNotifications, fallbackNotifications = fallbackNotifications, - useCompleteNotificationFormat = useCompleteNotificationFormat ) // Remove summary first to avoid briefly displaying it after dismissing the last notification @@ -65,101 +63,43 @@ class NotificationRenderer @Inject constructor( ) } - roomNotifications.forEach { wrapper -> - when (wrapper) { - is RoomNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing room messages notification ${wrapper.roomId}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.roomId.value, - id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId) - ) - } - is RoomNotification.Message -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating room messages notification ${wrapper.meta.roomId}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.roomId.value, - id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } + roomNotifications.forEach { notificationData -> + notificationDisplayer.showNotificationMessage( + tag = notificationData.roomId.value, + id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), + notification = notificationData.notification + ) } - invitationNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing invitation notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating invitation notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } + invitationNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}") + notificationDisplayer.showNotificationMessage( + tag = notificationData.key, + id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), + notification = notificationData.notification + ) } } - simpleNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing simple notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating simple notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } + simpleNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}") + notificationDisplayer.showNotificationMessage( + tag = notificationData.key, + id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId), + notification = notificationData.notification + ) } } - /* - fallbackNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing fallback notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating fallback notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } - } - */ - val removedFallback = fallbackNotifications.filterIsInstance() - val appendFallback = fallbackNotifications.filterIsInstance() - if (appendFallback.isEmpty() && removedFallback.isNotEmpty()) { - Timber.tag(loggerTag.value).d("Removing global fallback notification") - notificationDisplayer.cancelNotificationMessage( - tag = "FALLBACK", - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) - ) - } else if (appendFallback.isNotEmpty()) { + // Show only the first fallback notification + if (fallbackNotifications.isNotEmpty()) { Timber.tag(loggerTag.value).d("Showing fallback notification") notificationDisplayer.showNotificationMessage( tag = "FALLBACK", id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), - notification = appendFallback.first().notification + notification = fallbackNotifications.first().notification ) } @@ -174,39 +114,30 @@ class NotificationRenderer @Inject constructor( } } } - - fun cancelAllNotifications() { - notificationDisplayer.cancelAllNotifications() - } } -private fun List>.groupByType(): GroupedNotificationEvents { - val roomIdToEventMap: MutableMap>> = LinkedHashMap() - val simpleEvents: MutableList> = ArrayList() - val invitationEvents: MutableList> = ArrayList() - val fallbackEvents: MutableList> = ArrayList() - forEach { - when (val event = it.event) { - is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) - is NotifiableMessageEvent -> { - val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } - roomEvents.add(it.castedToEventType()) - } - is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) - is FallbackNotifiableEvent -> { - fallbackEvents.add(it.castedToEventType()) - } +private fun List.groupByType(): GroupedNotificationEvents { + val roomEvents: MutableList = mutableListOf() + val simpleEvents: MutableList = mutableListOf() + val invitationEvents: MutableList = mutableListOf() + val fallbackEvents: MutableList = mutableListOf() + forEach { event -> + when (event) { + is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType()) + is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType()) + is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType()) + is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType()) } } - return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents) + return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents) } @Suppress("UNCHECKED_CAST") -private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent +private fun NotifiableEvent.castedToEventType(): T = this as T data class GroupedNotificationEvents( - val roomEvents: Map>>, - val simpleEvents: List>, - val invitationEvents: List>, - val fallbackEvents: List>, + val roomEvents: List, + val simpleEvents: List, + val invitationEvents: List, + val fallbackEvents: List, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt deleted file mode 100644 index fb19bd76fd..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent - -class NotificationState( - /** - * The notifiable events queued for rendering or currently rendered. - * - * This is our source of truth for notifications, any changes to this list will be rendered as notifications. - * When events are removed the previously rendered notifications will be cancelled. - * When adding or updating, the notifications will be notified. - * - * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id. - */ - private val queuedEvents: NotificationEventQueue, - /** - * The last known rendered notifiable events. - * We keep track of them in order to know which events have been removed from the eventList - * allowing us to cancel any notifications previous displayed by now removed events - */ - private val renderedEvents: MutableList>, -) { - fun updateQueuedEvents( - action: (NotificationEventQueue, List>) -> T - ): T { - return synchronized(queuedEvents) { - action(queuedEvents, renderedEvents) - } - } - - fun clearAndAddRenderedEvents(eventsToRender: List>) { - renderedEvents.clear() - renderedEvents.addAll(eventsToRender) - } - - fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender - - fun queuedEvents(block: (NotificationEventQueue) -> Unit) { - synchronized(queuedEvents) { - block(queuedEvents) - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt deleted file mode 100644 index 52e61a7ec6..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2023 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.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import javax.inject.Inject - -class OutdatedEventDetector @Inject constructor( - // / private val activeSessionDataSource: ActiveSessionDataSource -) { - /** - * Returns true if the given event is outdated. - * Used to clean up notifications if a displayed message has been read on an - * other device. - */ - fun isMessageOutdated(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean { - /* TODO EAx - val session = activeSessionDataSource.currentValue?.orNull() ?: return false - - if (notifiableEvent is NotifiableMessageEvent) { - val eventID = notifiableEvent.eventId - val roomID = notifiableEvent.roomId - val room = session.getRoom(roomID) ?: return false - return room.readService().isEventRead(eventID) - } - - */ - return false - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt new file mode 100644 index 0000000000..8c7fc5dbd8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ReplyMessageExtractor.kt @@ -0,0 +1,36 @@ +/* + * 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.push.impl.notifications + +import android.content.Intent +import androidx.core.app.RemoteInput +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +interface ReplyMessageExtractor { + fun getReplyMessage(intent: Intent): String? +} + +@ContributesBinding(AppScope::class) +class AndroidReplyMessageExtractor @Inject constructor() : ReplyMessageExtractor { + override fun getReplyMessage(intent: Intent): String? { + return RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + ?.toString() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index 855d8cd66d..f621b561d3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -16,52 +16,50 @@ package io.element.android.libraries.push.impl.notifications +import android.app.Notification import android.graphics.Bitmap -import android.graphics.Typeface -import android.text.style.StyleSpan -import androidx.core.app.NotificationCompat -import androidx.core.app.Person -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans import chat.schildi.lib.preferences.ScPreferencesStore import chat.schildi.lib.preferences.ScPrefs import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R -import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.coroutines.flow.first import javax.inject.Inject -class RoomGroupMessageCreator @Inject constructor( +interface RoomGroupMessageCreator { + suspend fun createRoomMessage( + currentUser: MatrixUser, + events: List, + roomId: RoomId, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification +} + +@ContributesBinding(AppScope::class) +class DefaultRoomGroupMessageCreator @Inject constructor( private val scPreferencesStore: ScPreferencesStore, private val bitmapLoader: NotificationBitmapLoader, private val stringProvider: StringProvider, - private val notificationCreator: NotificationCreator -) { - suspend fun createRoomMessage( + private val notificationCreator: NotificationCreator, +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( currentUser: MatrixUser, events: List, roomId: RoomId, imageLoader: ImageLoader, - ): RoomNotification.Message { + existingNotification: Notification?, + ): Notification { val lastKnownRoomEvent = events.last() val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" val roomIsGroup = !lastKnownRoomEvent.roomIsDirect - val style = NotificationCompat.MessagingStyle( - Person.Builder() - .setName(currentUser.displayName?.annotateForDebug(50)) - .setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl, imageLoader)) - .setKey(lastKnownRoomEvent.sessionId.value) - .build() - ).also { - it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51) - it.isGroupConversation = roomIsGroup - it.addMessagesFromEvents(events, imageLoader) - } val tickerText = if (roomIsGroup) { stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description) @@ -73,123 +71,29 @@ class RoomGroupMessageCreator @Inject constructor( val lastMessageTimestamp = events.last().timestamp val smartReplyErrors = events.filter { it.isSmartReplyError() } - val messageCount = events.size - smartReplyErrors.size - val meta = RoomNotification.Message.Meta( - summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), - messageCount = messageCount, - latestTimestamp = lastMessageTimestamp, - roomId = roomId, - shouldBing = events.any { it.noisy } - ) - val forceOnlyAlertOnce = scPreferencesStore.settingFlow(ScPrefs.NOTIFICATION_ONLY_ALERT_ONCE).first() - return RoomNotification.Message( - notificationCreator.createMessagesListNotification( - style, + return notificationCreator.createMessagesListNotification( RoomEventGroupInfo( sessionId = currentUser.userId, roomId = roomId, roomDisplayName = roomName, isDirect = !roomIsGroup, hasSmartReplyError = smartReplyErrors.isNotEmpty(), - shouldBing = meta.shouldBing, + shouldBing = events.any { it.noisy }, customSound = events.last().soundName, isUpdated = events.last().isUpdated, ), threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, - lastMessageTimestamp, - tickerText, - forceOnlyAlertOnce = forceOnlyAlertOnce, - ), - meta + lastMessageTimestamp = lastMessageTimestamp, + tickerText = tickerText, + forceOnlyAlertOnce = scPreferencesStore.settingFlow(ScPrefs.NOTIFICATION_ONLY_ALERT_ONCE).first(), + currentUser = currentUser, + existingNotification = existingNotification, + imageLoader = imageLoader, + events = events, ) } - private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents( - events: List, - imageLoader: ImageLoader, - ) { - events.forEach { event -> - val senderPerson = if (event.outGoingMessage) { - null - } else { - Person.Builder() - .setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70)) - .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader)) - .setKey(event.senderId.value) - .build() - } - when { - event.isSmartReplyError() -> addMessage( - stringProvider.getString(R.string.notification_inline_reply_failed), - event.timestamp, - senderPerson - ) - else -> { - val message = NotificationCompat.MessagingStyle.Message( - event.body?.annotateForDebug(71), - event.timestamp, - senderPerson - ).also { message -> - event.imageUri?.let { - message.setData("image/", it) - } - } - addMessage(message) - - // Add additional message for captions - if (event.imageUri != null && event.caption != null) { - addMessage(NotificationCompat.MessagingStyle.Message( - event.caption, - event.timestamp, - senderPerson, - )) - } - } - } - } - } - - private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { - return when (events.size) { - 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) - else -> { - stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, - events.size, - roomName, - events.size - ) - } - } - } - - private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence { - return if (roomIsDirect) { - buildSpannedString { - event.senderDisambiguatedDisplayName?.let { - inSpans(StyleSpan(Typeface.BOLD)) { - append(it) - append(": ") - } - } - append(event.description) - } - } else { - buildSpannedString { - inSpans(StyleSpan(Typeface.BOLD)) { - append(roomName) - append(": ") - event.senderDisambiguatedDisplayName?.let { - append(it) - append(" ") - } - } - append(event.description) - } - } - } - private suspend fun getRoomBitmap( events: List, imageLoader: ImageLoader, @@ -199,5 +103,3 @@ class RoomGroupMessageCreator @Inject constructor( ?.let { bitmapLoader.getRoomBitmap(it, imageLoader) } } } - -private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index 18aadb5de9..def1c58d3e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -17,53 +17,49 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification -import androidx.core.app.NotificationCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R -import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject +interface SummaryGroupMessageCreator { + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): Notification +} + /** * ======== Build summary notification ========= * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for * your group using snippets of text from each notification. The user can expand this * notification to see each separate notification. - * To support older versions, which cannot show a nested group of notifications, - * you must create an extra notification that acts as the summary. - * This appears as the only notification and the system hides all the others. - * So this summary should include a snippet from all the other notifications, - * which the user can tap to open your app. * The behavior of the group summary may vary on some device types such as wearables. * To ensure the best experience on all devices and versions, always include a group summary when you create a group * https://developer.android.com/training/notify-user/group */ -class SummaryGroupMessageCreator @Inject constructor( +@ContributesBinding(AppScope::class) +class DefaultSummaryGroupMessageCreator @Inject constructor( private val stringProvider: StringProvider, private val notificationCreator: NotificationCreator, -) { - fun createSummaryNotification( +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( currentUser: MatrixUser, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - useCompleteNotificationFormat: Boolean + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, ): Notification { - val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> - roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) } - invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) } - simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) } - fallbackNotifications.forEach { style.addLine(it.summaryLine) } - } - val summaryIsNoisy = roomNotifications.any { it.shouldBing } || invitationNotifications.any { it.isNoisy } || simpleNotifications.any { it.isNoisy } - val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount } - val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp ?: invitationNotifications.lastOrNull()?.timestamp ?: simpleNotifications.last().timestamp @@ -71,109 +67,9 @@ class SummaryGroupMessageCreator @Inject constructor( // FIXME roomIdToEventMap.size is not correct, this is the number of rooms val nbEvents = roomNotifications.size + simpleNotifications.size val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) - summaryInboxStyle.setBigContentTitle(sumTitle.annotateForDebug(43)) - // .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents).annotateForDebug(44)) - // Use account name now, for multi-session - .setSummaryText(currentUser.userId.value.annotateForDebug(44)) - return if (useCompleteNotificationFormat) { - notificationCreator.createSummaryListNotification( - currentUser, - summaryInboxStyle, - sumTitle, - noisy = summaryIsNoisy, - lastMessageTimestamp = lastMessageTimestamp - ) - } else { - processSimpleGroupSummary( - currentUser, - summaryIsNoisy, - messageCount, - simpleNotifications.size, - invitationNotifications.size, - roomNotifications.size, - lastMessageTimestamp - ) - } - } - - private fun processSimpleGroupSummary( - currentUser: MatrixUser, - summaryIsNoisy: Boolean, - messageEventsCount: Int, - simpleEventsCount: Int, - invitationEventsCount: Int, - roomCount: Int, - lastMessageTimestamp: Long - ): Notification { - // Add the simple events as message (?) - val messageNotificationCount = messageEventsCount + simpleEventsCount - - val privacyTitle = if (invitationEventsCount > 0) { - val invitationsStr = stringProvider.getQuantityString( - R.plurals.notification_invitations, - invitationEventsCount, - invitationEventsCount - ) - if (messageNotificationCount > 0) { - // Invitation and message - val messageStr = stringProvider.getQuantityString( - R.plurals.notification_new_messages_for_room, - messageNotificationCount, - messageNotificationCount - ) - if (roomCount > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString( - R.plurals.notification_unread_notified_messages_in_room_rooms, - roomCount, - roomCount - ) - stringProvider.getString( - R.string.notification_unread_notified_messages_in_room_and_invitation, - messageStr, - roomStr, - invitationsStr - ) - } else { - // In one room - stringProvider.getString( - R.string.notification_unread_notified_messages_and_invitation, - messageStr, - invitationsStr - ) - } - } else { - // Only invitation - invitationsStr - } - } else { - // No invitation, only messages - val messageStr = stringProvider.getQuantityString( - R.plurals.notification_new_messages_for_room, - messageNotificationCount, - messageNotificationCount - ) - if (roomCount > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString( - R.plurals.notification_unread_notified_messages_in_room_rooms, - roomCount, - roomCount - ) - stringProvider.getString( - R.string.notification_unread_notified_messages_in_room, - messageStr, - roomStr - ) - } else { - // In one room - messageStr - } - } return notificationCreator.createSummaryListNotification( - currentUser = currentUser, - style = null, - compatSummary = privacyTitle, + currentUser, + sumTitle, noisy = summaryIsNoisy, lastMessageTimestamp = lastMessageTimestamp ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt index c66e530837..8c74f845b7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -36,10 +36,9 @@ import javax.inject.Inject @SingleIn(AppScope::class) class NotificationChannels @Inject constructor( @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, private val stringProvider: StringProvider, ) { - private val notificationManager = NotificationManagerCompat.from(context) - init { createNotificationChannels() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 99a2b1a27b..e11cce163c 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -22,26 +22,80 @@ import android.graphics.Bitmap import android.graphics.Canvas import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.MessagingStyle +import androidx.core.app.Person import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.NotificationConfig import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug +import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory +import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -class NotificationCreator @Inject constructor( +interface NotificationCreator { + /** + * Create a notification for a Room. + */ + suspend fun createMessagesListNotification( + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + forceOnlyAlertOnce: Boolean = false, // SC + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, + ): Notification + + fun createRoomInvitationNotification( + inviteNotifiableEvent: InviteNotifiableEvent + ): Notification + + fun createSimpleEventNotification( + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification + + fun createFallbackNotification( + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification + + /** + * Create the summary notification. + */ + fun createSummaryListNotification( + currentUser: MatrixUser, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification + + fun createDiagnosticNotification(): Notification +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationCreator @Inject constructor( @ApplicationContext private val context: Context, private val notificationChannels: NotificationChannels, private val stringProvider: StringProvider, @@ -49,18 +103,24 @@ class NotificationCreator @Inject constructor( private val pendingIntentFactory: PendingIntentFactory, private val markAsReadActionFactory: MarkAsReadActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory, -) { + private val bitmapLoader: NotificationBitmapLoader, + private val acceptInvitationActionFactory: AcceptInvitationActionFactory, + private val rejectInvitationActionFactory: RejectInvitationActionFactory +) : NotificationCreator { /** * Create a notification for a Room. */ - fun createMessagesListNotification( - messageStyle: NotificationCompat.MessagingStyle, + override suspend fun createMessagesListNotification( roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, tickerText: String, - forceOnlyAlertOnce: Boolean = false, + forceOnlyAlertOnce: Boolean, // SC + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) // Build the pending intent for when the notification is clicked @@ -72,17 +132,39 @@ class NotificationCreator @Inject constructor( val smallIcon = CommonDrawables.ic_notification_small val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing) - return NotificationCompat.Builder(context, channelId) + val builder = if (existingNotification != null) { + NotificationCompat.Builder(context, existingNotification) + } else { + NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(roomInfo.isUpdated || forceOnlyAlertOnce) + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // ID of the corresponding shortcut, for conversation features under API 30+ + .setShortcutId(roomInfo.roomId.value) + // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) + // devices and all Wear devices. But we want a custom grouping, so we specify the groupID + .setGroup(roomInfo.sessionId.value) + // In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + // Remove notification after opening it or using an action + .setAutoCancel(true) + } + + val messagingStyle = existingNotification?.let { + MessagingStyle.extractMessagingStyleFromNotification(it) + } ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDirect) + + messagingStyle.addMessagesFromEvents(events, imageLoader) + + return builder + .setNumber(events.size) .setOnlyAlertOnce(roomInfo.isUpdated || forceOnlyAlertOnce) .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. - .setStyle(messageStyle) - // A category allows groups of notifications to be ranked and filtered – per user or system settings. - // For example, alarm notifications should display before promo notifications, or message from known contact - // that can be displayed in not disturb mode if white listed (the later will need compat28.x) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - // ID of the corresponding shortcut, for conversation features under API 30+ - .setShortcutId(roomInfo.roomId.value) + .setStyle(messagingStyle) + // Not needed anymore? // Title for API < 16 devices. .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1)) // Content for API < 16 devices. @@ -91,15 +173,10 @@ class NotificationCreator @Inject constructor( .setSubText( stringProvider.getQuantityString( R.plurals.notification_new_messages_for_room, - messageStyle.messages.size, - messageStyle.messages.size + messagingStyle.messages.size, + messagingStyle.messages.size ).annotateForDebug(3) ) - // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) - // devices and all Wear devices. But we want a custom grouping, so we specify the groupID - .setGroup(roomInfo.sessionId.value) - // In order to avoid notification making sound twice (due to the summary notification) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setSmallIcon(smallIcon) // Set primary color (important for Wear 2.0 Notifications). .setColor(accentColor) @@ -119,7 +196,8 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - + // Clear existing actions since we might be updating an existing notification + clearActions() // Add actions and notification intents // Mark room as read addAction(markAsReadActionFactory.create(roomInfo)) @@ -135,11 +213,11 @@ class NotificationCreator @Inject constructor( } setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) } - .setTicker(tickerText.annotateForDebug(4)) + .setTicker(tickerText) .build() } - fun createRoomInvitationNotification( + override fun createRoomInvitationNotification( inviteNotifiableEvent: InviteNotifiableEvent ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -153,10 +231,11 @@ class NotificationCreator @Inject constructor( .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setSmallIcon(smallIcon) .setColor(accentColor) - // TODO removed for now, will be added back later -// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) -// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) .apply { + if (NotificationConfig.SUPPORT_JOIN_DECLINE_INVITE) { + addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) + addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) + } // Build the pending intent for when the notification is clicked setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId)) @@ -183,7 +262,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createSimpleEventNotification( + override fun createSimpleEventNotification( simpleNotifiableEvent: SimpleNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -213,12 +292,11 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - setAutoCancel(true) } .build() } - fun createFallbackNotification( + override fun createFallbackNotification( fallbackNotifiableEvent: FallbackNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -245,19 +323,15 @@ class NotificationCreator @Inject constructor( fallbackNotifiableEvent.eventId ) ) - .apply { - priority = NotificationCompat.PRIORITY_LOW - setAutoCancel(true) - } + .setPriority(NotificationCompat.PRIORITY_LOW) .build() } /** * Create the summary notification. */ - fun createSummaryListNotification( + override fun createSummaryListNotification( currentUser: MatrixUser, - style: NotificationCompat.InboxStyle?, compatSummary: String, noisy: Boolean, lastMessageTimestamp: Long @@ -269,12 +343,9 @@ class NotificationCreator @Inject constructor( .setOnlyAlertOnce(true) // used in compat < N, after summary is built based on child notifications .setWhen(lastMessageTimestamp) - .setStyle(style) - .setContentTitle(currentUser.userId.value.annotateForDebug(9)) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setSmallIcon(smallIcon) // set content text to support devices running API level < 24 - .setContentText(compatSummary.annotateForDebug(10)) .setGroup(currentUser.userId.value) // set this notification as the summary for the group .setGroupSummary(true) @@ -299,7 +370,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createDiagnosticNotification(): Notification { + override fun createDiagnosticNotification(): Notification { val intent = pendingIntentFactory.createTestPendingIntent() return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) .setContentTitle(buildMeta.applicationName) @@ -315,6 +386,70 @@ class NotificationCreator @Inject constructor( .build() } + private suspend fun MessagingStyle.addMessagesFromEvents( + events: List, + imageLoader: ImageLoader, + ) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70)) + .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader)) + .setKey(event.senderId.value) + .build() + } + when { + event.isSmartReplyError() -> addMessage( + stringProvider.getString(R.string.notification_inline_reply_failed), + event.timestamp, + senderPerson + ) + else -> { + val message = MessagingStyle.Message( + event.body?.annotateForDebug(71), + event.timestamp, + senderPerson + ).also { message -> + event.imageUri?.let { + message.setData("image/", it) + } + } + addMessage(message) + + // Add additional message for captions + if (event.imageUri != null && event.caption != null) { + addMessage(NotificationCompat.MessagingStyle.Message( + event.caption, + event.timestamp, + senderPerson, + )) + } + } + } + } + } + + private suspend fun messagingStyleFromCurrentUser( + sessionId: SessionId, + user: MatrixUser, + imageLoader: ImageLoader, + roomName: String, + roomIsGroup: Boolean + ): MessagingStyle { + return MessagingStyle( + Person.Builder() + .setName(user.displayName?.annotateForDebug(50)) + .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader)) + .setKey(sessionId.value) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + } + } + private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? { val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val canvas = Canvas() @@ -325,3 +460,5 @@ class NotificationCreator @Inject constructor( return bitmap } } + +fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt index 53af2c71d5..56fb952c24 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -46,21 +46,20 @@ class QuickReplyActionFactory @Inject constructor( if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null val sessionId = roomInfo.sessionId val roomId = roomInfo.roomId - return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent -> - val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) - .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) - .build() + val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId) + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() - NotificationCompat.Action.Builder( - R.drawable.vector_notification_quick_reply, - stringProvider.getString(R.string.notification_room_action_quick_reply), - replyPendingIntent - ) - .addRemoteInput(remoteInput) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .build() - } + return NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(R.string.notification_room_action_quick_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .build() } /* @@ -74,30 +73,26 @@ class QuickReplyActionFactory @Inject constructor( sessionId: SessionId, roomId: RoomId, threadId: ThreadId?, - ): PendingIntent? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val intent = Intent(context, NotificationBroadcastReceiver::class.java) - intent.action = actionIds.smartReply - intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) - intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) - intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) - threadId?.let { - intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) - } - - PendingIntent.getBroadcast( - context, - clock.epochMillis().toInt(), - intent, - // PendingIntents attached to actions with remote inputs must be mutable - PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } - ) - } else { - null + ): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.smartReply + intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) } + + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + // PendingIntents attached to actions with remote inputs must be mutable + PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + ) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt index 867eb70155..fd992b5bd6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/model/NotifiableMessageEvent.kt @@ -38,7 +38,7 @@ data class NotifiableMessageEvent( val timestamp: Long, val senderDisambiguatedDisplayName: String?, val body: String?, - val caption: String?, + val caption: String? = null, // SC // We cannot use Uri? type here, as that could trigger a // NotSerializableException when persisting this to storage val imageUriString: String?, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index bf8cef3eb3..1317c8e16b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -16,28 +16,20 @@ package io.element.android.libraries.push.impl.push -import android.os.Handler -import android.os.Looper import chat.schildi.lib.preferences.ScAppStateStore import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver -import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -45,24 +37,16 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) class DefaultPushHandler @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val onNotifiableEventReceived: OnNotifiableEventReceived, private val notifiableEventResolver: NotifiableEventResolver, - private val defaultPushDataStore: DefaultPushDataStore, + private val incrementPushDataStore: IncrementPushDataStore, private val userPushStoreFactory: UserPushStoreFactory, private val scAppStateStore: ScAppStateStore, private val pushClientSecret: PushClientSecret, - // private val actionIds: NotificationActionIds, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, private val diagnosticPushHandler: DiagnosticPushHandler, ) : PushHandler { - private val coroutineScope = CoroutineScope(SupervisorJob()) - - // UI handler - private val uiHandler by lazy { - Handler(Looper.getMainLooper()) - } - /** * Called when message is received. * @@ -70,21 +54,15 @@ class DefaultPushHandler @Inject constructor( */ override suspend fun handle(pushData: PushData) { Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") - if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## pushData: $pushData") } - - defaultPushDataStore.incrementPushCounter() - + incrementPushDataStore.incrementPushCounter() // Diagnostic Push - if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) { diagnosticPushHandler.handlePush() - return - } - - uiHandler.post { - coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } + } else { + handleInternal(pushData) } } @@ -100,7 +78,6 @@ class DefaultPushHandler @Inject constructor( } else { Timber.tag(loggerTag.value).d("## handleInternal()") } - val clientSecret = pushData.clientSecret // clientSecret should not be null. If this happens, restore default session val userId = clientSecret @@ -111,29 +88,24 @@ class DefaultPushHandler @Inject constructor( ?: run { matrixAuthenticationService.getLatestSessionId() } - if (userId == null) { Timber.w("Unable to get a session") return } - - val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) - - if (notifiableEvent == null) { - Timber.w("Unable to get a notification data") - return - } - val userPushStore = userPushStoreFactory.getOrCreate(userId) - if (!userPushStore.getNotificationEnabledForDevice().first()) { + if (userPushStore.getNotificationEnabledForDevice().first()) { + val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) + if (notifiableEvent == null) { + Timber.w("Unable to get a notification data") + return + } + onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) + } else { // TODO We need to check if this is an incoming call Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") - return } scAppStateStore.onPushReceived(userPushStore.getPushProviderName()) - - defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt new file mode 100644 index 0000000000..9a7f99176c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt @@ -0,0 +1,35 @@ +/* + * 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.push.impl.push + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import javax.inject.Inject + +interface IncrementPushDataStore { + suspend fun incrementPushCounter() +} + +@ContributesBinding(AppScope::class) +class DefaultIncrementPushDataStore @Inject constructor( + private val defaultPushDataStore: DefaultPushDataStore +) : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + defaultPushDataStore.incrementPushCounter() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt new file mode 100644 index 0000000000..cdcc6a1d93 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -0,0 +1,41 @@ +/* + * 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.push.impl.push + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface OnNotifiableEventReceived { + fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) +} + +@ContributesBinding(AppScope::class) +class DefaultOnNotifiableEventReceived @Inject constructor( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val coroutineScope: CoroutineScope, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + coroutineScope.launch { + defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt index d8de5429ef..4df39587f1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.pushgateway import retrofit2.http.Body import retrofit2.http.POST -internal interface PushGatewayAPI { +interface PushGatewayAPI { /** * Ask the Push Gateway to send a push to the current device. * diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt new file mode 100644 index 0000000000..1ea4c72fbb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt @@ -0,0 +1,36 @@ +/* + * 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.push.impl.pushgateway + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import javax.inject.Inject + +interface PushGatewayApiFactory { + fun create(baseUrl: String): PushGatewayAPI +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : PushGatewayApiFactory { + override fun create(baseUrl: String): PushGatewayAPI { + return retrofitFactory.create(baseUrl) + .create(PushGatewayAPI::class.java) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt index 7adedfcfd2..ad5a264168 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayDevice( +data class PushGatewayDevice( /** * Required. The app_id given when the pusher was created. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt index 5e341e3286..28ad04a078 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotification( +data class PushGatewayNotification( @SerialName("event_id") val eventId: String, @SerialName("room_id") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt index ce41d2d83e..14727cab2f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyBody( +data class PushGatewayNotifyBody( /** * Required. Information about the push notification */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt index e8c01493ab..41c8a05423 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -15,15 +15,14 @@ */ package io.element.android.libraries.push.impl.pushgateway +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.push.api.gateway.PushGatewayFailure import javax.inject.Inject -class PushGatewayNotifyRequest @Inject constructor( - private val retrofitFactory: RetrofitFactory, -) { +interface PushGatewayNotifyRequest { data class Params( val url: String, val appId: String, @@ -32,13 +31,18 @@ class PushGatewayNotifyRequest @Inject constructor( val roomId: RoomId, ) - suspend fun execute(params: Params) { - val sygnalApi = retrofitFactory.create( + suspend fun execute(params: Params) +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayNotifyRequest @Inject constructor( + private val pushGatewayApiFactory: PushGatewayApiFactory, +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + val pushGatewayApi = pushGatewayApiFactory.create( params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) ) - .create(PushGatewayAPI::class.java) - - val response = sygnalApi.notify( + val response = pushGatewayApi.notify( PushGatewayNotifyBody( PushGatewayNotification( eventId = params.eventId.value, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt index 13d9cbad1d..75b5e52111 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyResponse( +data class PushGatewayNotifyResponse( @SerialName("rejected") val rejectedPushKeys: List ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt new file mode 100644 index 0000000000..667918941e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -0,0 +1,52 @@ +/* + * 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.push.impl.test + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import javax.inject.Inject + +interface TestPush { + suspend fun execute(config: CurrentUserPushConfig) +} + +@ContributesBinding(AppScope::class) +class DefaultTestPush @Inject constructor( + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = config.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = config.pushKey, + eventId = TEST_EVENT_ID, + roomId = TEST_ROOM_ID, + ) + ) + } + + companion object { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + val TEST_ROOM_ID = RoomId("!room:domain") + } +} diff --git a/libraries/push/impl/src/main/res/values-be/translations.xml b/libraries/push/impl/src/main/res/values-be/translations.xml index fd85f11ac3..bccd5df946 100644 --- a/libraries/push/impl/src/main/res/values-be/translations.xml +++ b/libraries/push/impl/src/main/res/values-be/translations.xml @@ -1,17 +1,17 @@ - "Выклік" + "Пазваніць" "Праслухоўванне падзей" "Шумныя апавяшчэнні" "Ціхія апавяшчэнні" "%1$s: %2$d паведамленне" - "%1$s: %2$d паведамлення" + "%1$s: %2$d паведамленні" "%1$s: %2$d паведамленняў" "%d апавяшчэнне" - "%d апавяшчэння" + "%d апавяшчэнні" "%d апавяшчэнняў" "Апавяшчэнне" @@ -20,7 +20,7 @@ "Адхіліць" "%d запрашэнне" - "%d запрашэння" + "%d запрашэнні" "%d запрашэнняў" "Запрасіў(-ла) вас у чат" @@ -28,7 +28,7 @@ "Новыя паведамленні" "%d новае паведамленне" - "%d новых паведамлення" + "%d новыя паведамленні" "%d новых паведамленняў" "Адрэагаваў(-ла) на %1$s" @@ -41,7 +41,7 @@ "%1$s: %2$s %3$s" "%d непрачытанае апавяшчэнне" - "%d непрачытаных апавяшчэння" + "%d непрачытаныя апавяшчэнні" "%d непрачытаных апавяшчэнняў" "%1$s і %2$s" @@ -49,7 +49,7 @@ "%1$s у %2$s і %3$s" "%d пакой" - "%d пакоя" + "%d пакоі" "%d пакояў" "Фонавая сінхранізацыя" diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml index 7b9e1a44f9..d2c91db3b1 100644 --- a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -22,6 +22,7 @@ "%d 則新訊息" "回應 %1$s" + "標為已讀" "快速回覆" "邀請您加入聊天室" "我" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt new file mode 100644 index 0000000000..546d69b008 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -0,0 +1,221 @@ +/* + * 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.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.impl.test.FakeTestPush +import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +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.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushServiceTest { + @Test + fun `test push no push provider`() = runTest { + val defaultPushService = createDefaultPushService() + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push no config`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push ok`() = runTest { + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + val testPushResult = lambdaRecorder { } + val aPushProvider = FakePushProvider( + currentUserPushConfig = aConfig + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + testPush = FakeTestPush(executeResult = testPushResult), + ) + assertThat(defaultPushService.testPush()).isTrue() + testPushResult.assertions() + .isCalledOnce() + .with(value(aConfig)) + } + + @Test + fun `getCurrentPushProvider null`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isNull() + } + + @Test + fun `getCurrentPushProvider ok`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isEqualTo(aPushProvider) + } + + @Test + fun `getAvailablePushProviders empty`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).isEmpty() + } + + @Test + fun `registerWith ok`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.success(Unit) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result).isEqualTo(Result.success(Unit)) + } + + @Test + fun `registerWith fail to register`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `registerWith fail to unregister previous push provider`() = runTest { + val client = FakeMatrixClient() + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = { Result.failure(AN_EXCEPTION) }, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aCurrentPushProvider.name) + } + + @Test + fun `registerWith unregister previous push provider and register new OK`() = runTest { + val client = FakeMatrixClient() + val unregisterLambda = lambdaRecorder> { Result.success(Unit) } + val registerLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = unregisterLambda, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + registerWithResult = registerLambda, + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isSuccess).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(client)) + registerLambda.assertions() + .isCalledOnce() + .with(value(client), value(aDistributor)) + } + + @Test + fun `getAvailablePushProviders sorted`() = runTest { + val aPushProvider1 = FakePushProvider( + index = 1, + name = "aPushProvider1", + ) + val aPushProvider2 = FakePushProvider( + index = 2, + name = "aPushProvider2", + ) + val aPushProvider3 = FakePushProvider( + index = 3, + name = "aPushProvider3", + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider1, aPushProvider3, aPushProvider2), + ) + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder() + } + + private fun createDefaultPushService( + testPush: TestPush = FakeTestPush(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushProviders: Set<@JvmSuppressWildcards PushProvider> = emptySet(), + getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), + ): DefaultPushService { + return DefaultPushService( + testPush = testPush, + userPushStoreFactory = userPushStoreFactory, + pushProviders = pushProviders, + getCurrentPushProvider = getCurrentPushProvider, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt new file mode 100644 index 0000000000..dd9363de9e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -0,0 +1,193 @@ +/* + * 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.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPusherSubscriberTest { + @Test + fun `test register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test re-register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + @Test + fun `test re-register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testRegisterPusher( + currentPushKey: String?, + registerResult: Result, + ) { + val setHttpPusherResult = lambdaRecorder> { registerResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + setHttpPusherResult = setHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(registerResult) + setHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + SetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + url = "aGateway", + appDisplayName = "MyApp", + deviceDisplayName = "MyDevice", + profileTag = DEFAULT_PUSHER_FILE_TAG + "_", + lang = "en", + defaultPayload = "{\"cs\":\"$A_SECRET\"}", + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (registerResult.isSuccess) "aPushKey" else currentPushKey + ) + } + + @Test + fun `test unregister pusher OK`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.success(Unit), + ) + } + + @Test + fun `test unregister pusher error`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testUnregisterPusher( + currentPushKey: String?, + unregisterResult: Result, + ) { + val unsetHttpPusherResult = lambdaRecorder> { unregisterResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + unsetHttpPusherResult = unsetHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.unregisterPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(unregisterResult) + unsetHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + UnsetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (unregisterResult.isSuccess) null else currentPushKey + ) + } + + private fun createDefaultPusherSubscriber( + buildMeta: BuildMeta = aBuildMeta(applicationName = "MyApp"), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + ): DefaultPusherSubscriber { + return DefaultPusherSubscriber( + buildMeta = buildMeta, + pushClientSecret = pushClientSecret, + userPushStoreFactory = userPushStoreFactory, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt new file mode 100644 index 0000000000..4f715ed283 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt @@ -0,0 +1,178 @@ +/* + * 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.push.impl.notifications + +import android.app.Notification +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultActiveNotificationsProviderTest { + @Test + fun `getAllNotifications with no active notifications returns empty list`() { + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList()) + + val emptyNotifications = activeNotificationsProvider.getAllNotifications() + assertThat(emptyNotifications).isEmpty() + } + + @Test + fun `getAllNotifications with active notifications returns all`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + val result = activeNotificationsProvider.getAllNotifications() + assertThat(result).hasSize(3) + } + + @Test + fun `getNotificationsForSession returns only notifications for that session id`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID_2)).hasSize(2) + } + + @Test + fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value,), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value, + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID_2)).hasSize(1) + } + + @Test + fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty() + } + + @Test + fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).hasSize(2) + } + + @Test + fun `getSummaryNotification returns only the summary notification for that session id if it exists`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID)).isNotNull() + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull() + } + + private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk { + every { this@mockk.id } returns id + every { this@mockk.tag } returns tag + @Suppress("DEPRECATION") + every { this@mockk.notification } returns Notification.Builder(InstrumentationRegistry.getInstrumentation().targetContext).setGroup(groupId).build() + } + + private fun createActiveNotificationsProvider( + activeNotifications: List = emptyList(), + ): DefaultActiveNotificationsProvider { + val notificationManager = mockk { + every { this@mockk.activeNotifications } returns activeNotifications + } + return DefaultActiveNotificationsProvider( + notificationManager = notificationManager, + notificationIdProvider = NotificationIdProvider(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt similarity index 94% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index d0cd5a30e5..8eebbbbb5c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -59,17 +59,17 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -class NotifiableEventResolverTest { +class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { - val sut = createNotifiableEventResolver(notificationService = null) + val sut = createDefaultNotifiableEventResolver(notificationService = null) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) assertThat(result).isNull() } @Test fun `resolve event failure`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -78,7 +78,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event null`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success(null) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -87,7 +87,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message text`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -105,7 +105,7 @@ class NotifiableEventResolverTest { @Test @Config(qualifiers = "en") fun `resolve event message with mention`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -123,7 +123,7 @@ class NotifiableEventResolverTest { @Test fun `resolve HTML formatted event message text takes plain text version`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -146,7 +146,7 @@ class NotifiableEventResolverTest { @Test fun `resolve incorrectly formatted event message text uses fallback`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -169,7 +169,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message audio`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -186,7 +186,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message video`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -203,7 +203,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message voice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -220,7 +220,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message image`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -237,7 +237,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message sticker`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -254,7 +254,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message file`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -271,7 +271,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message location`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -288,7 +288,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message notice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -305,7 +305,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message emote`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -322,7 +322,7 @@ class NotifiableEventResolverTest { @Test fun `resolve poll`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.Poll( @@ -339,7 +339,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite room`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -372,7 +372,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite direct`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -405,7 +405,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent other`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -421,7 +421,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomEncrypted`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomEncrypted @@ -445,7 +445,7 @@ class NotifiableEventResolverTest { @Test fun `resolve CallInvite`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2) @@ -517,7 +517,7 @@ class NotifiableEventResolverTest { } private fun testNull(content: NotificationContent) = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = content @@ -528,10 +528,10 @@ class NotifiableEventResolverTest { assertThat(result).isNull() } - private fun createNotifiableEventResolver( + private fun createDefaultNotifiableEventResolver( notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result = Result.success(null), - ): NotifiableEventResolver { + ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationResult(notificationResult) val matrixClientProvider = FakeMatrixClientProvider(getClient = { @@ -544,7 +544,7 @@ class NotifiableEventResolverTest { val notificationMediaRepoFactory = NotificationMediaRepo.Factory { FakeNotificationMediaRepo() } - return NotifiableEventResolver( + return DefaultNotifiableEventResolver( stringProvider = AndroidStringProvider(context.resources), clock = FakeSystemClock(), matrixClientProvider = matrixClientProvider, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index b84e7b4be3..3924171d60 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -16,25 +16,36 @@ package io.element.android.libraries.push.impl.notifications +import android.app.Notification +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState -import io.element.android.tests.testutils.testCoroutineDispatchers +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -58,17 +69,29 @@ class DefaultNotificationDrawerManagerTest { @Test fun `cover all APIs`() = runTest { // For now just call all the API. Later, add more valuable tests. - val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() - defaultNotificationDrawerManager.notificationStyleChanged() - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = true) - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = false) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = true) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = false) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data") + val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( + createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification, -> + assertThat(user).isEqualTo(matrixUser) + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(existingNotification).isNull() + Notification() + } + ) + val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + roomGroupMessageCreator = mockRoomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + ) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) defaultNotificationDrawerManager.clearMembershipNotificationForSession(A_SESSION_ID) - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) // Add the same Event again (will be ignored) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) @@ -104,33 +127,93 @@ class DefaultNotificationDrawerManagerTest { defaultNotificationDrawerManager.destroy() } + @Test + fun `when MatrixClient has no cached user name a fallback one is used to render the notification`() = runTest { + val matrixClient = FakeMatrixClient(userDisplayName = null) + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + val messageCreator = FakeRoomGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + matrixClientProvider = matrixClientProvider, + roomGroupMessageCreator = messageCreator, + ) + // Gets a display name from MatrixClient.getUserProfile + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since display name is blank + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = ""))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since the result fails + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.failure(IllegalStateException("Failed to get profile"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + messageCreator.createRoomMessageResult.assertions() + .isCalledExactly(3) + .withSequence( + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()), + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()), + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()), + ) + + defaultNotificationDrawerManager.destroy() + } + + @Test + fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest { + val notificationManager = mockk { + every { cancel(any(), any()) } returns Unit + } + val summaryId = NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID) + val activeNotificationsProvider = FakeActiveNotificationsProvider( + mutableListOf( + mockk { + every { id } returns summaryId + } + ) + ) + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + notificationManager = notificationManager, + activeNotificationsProvider = activeNotificationsProvider, + ) + + // Ask to clear all existing message notifications. Since only the summary notification is left, it should be cleared + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + + // Verify we asked to cancel the notification with summaryId + verify { notificationManager.cancel(null, summaryId) } + + defaultNotificationDrawerManager.destroy() + } + private fun TestScope.createDefaultNotificationDrawerManager( + notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), - initialData: List = emptyList() + roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), + summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), + activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), ): DefaultNotificationDrawerManager { val context = RuntimeEnvironment.getApplication() return DefaultNotificationDrawerManager( - notifiableEventProcessor = NotifiableEventProcessor( - outdatedDetector = OutdatedEventDetector(), - appNavigationStateService = appNavigationStateService - ), + notificationManager = notificationManager, notificationRenderer = NotificationRenderer( notificationIdProvider = NotificationIdProvider(), - notificationDisplayer = NotificationDisplayer(context), - notificationFactory = NotificationFactory( - notificationCreator = MockkNotificationCreator().instance, - roomGroupMessageCreator = MockkRoomGroupMessageCreator().instance, - summaryGroupMessageCreator = MockkSummaryGroupMessageCreator().instance, - ) + notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)), + notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = FakeNotificationCreator(), + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ), ), - notificationEventPersistence = InMemoryNotificationEventPersistence(initialData = initialData), - filteredEventDetector = FilteredEventDetector(), + notificationIdProvider = NotificationIdProvider(), appNavigationStateService = appNavigationStateService, coroutineScope = this, - dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - buildMeta = aBuildMeta(), - matrixClientProvider = FakeMatrixClientProvider(), + matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = activeNotificationsProvider, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt deleted file mode 100644 index ce984c712f..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2023 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.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment - -@RunWith(RobolectricTestRunner::class) -class DefaultNotificationEventPersistenceTest { - @Test - fun `loadEvents should return empty NotificationEventQueue`() { - val sut = createDefaultNotificationEventPersistence() - val result = sut.loadEvents( - factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - } - ) - assertThat(result.isEmpty()).isTrue() - } - - @Test - fun `after persisting NotificationEventQueue, loadEvents should return non-empty NotificationEventQueue`() { - val sut = createDefaultNotificationEventPersistence() - val notificationEventQueue = NotificationEventQueue(mutableListOf(), seenEventIds = CircularCache.create(cacheSize = 25)) - // First persist an empty queue - sut.persistEvents(notificationEventQueue) - // Add an event - notificationEventQueue.add(aSimpleNotifiableEvent()) - // Persist - // Note: is cannot work because AndroidKeyStore is not available. But we check that the code does - // not crash. - sut.persistEvents(notificationEventQueue) - sut.loadEvents( - factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - } - ) - // assertThat(result.isEmpty()).isFalse() - } - - private fun createDefaultNotificationEventPersistence(): DefaultNotificationEventPersistence { - val context = RuntimeEnvironment.getApplication() - return DefaultNotificationEventPersistence(context) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt similarity index 69% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt index 399b0fc4b3..8a8b5efd43 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.os.Build -import coil.annotation.ExperimentalCoilApi +import androidx.core.app.NotificationCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -42,7 +42,7 @@ private const val A_USER_AVATAR_1 = "mxc://userAvatar1" private const val A_USER_AVATAR_2 = "mxc://userAvatar2" @RunWith(RobolectricTestRunner::class) -class RoomGroupMessageCreatorTest { +class DefaultRoomGroupMessageCreatorTest { @Test fun `test createRoomMessage with one Event`() = runTest { val sut = createRoomGroupMessageCreator() @@ -56,19 +56,12 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(1) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -85,19 +78,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = true, - ) - ) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -115,7 +99,6 @@ class RoomGroupMessageCreatorTest { ) } - @OptIn(ExperimentalCoilApi::class) @Test fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { `test createRoomMessage with room avatar and sender avatar`( @@ -138,7 +121,6 @@ class RoomGroupMessageCreatorTest { ) } - @OptIn(ExperimentalCoilApi::class) private fun `test createRoomMessage with room avatar and sender avatar`( api: Int, expectedCoilRequests: List, @@ -160,20 +142,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) - assertThat(fakeImageLoader.getCoilRequests()).isEqualTo(expectedCoilRequests) + assertThat(result.number).isEqualTo(1) + assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests) } @Test @@ -188,19 +160,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: 2 messages", - messageCount = 2, - latestTimestamp = A_TIMESTAMP + 10, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(2) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -218,19 +181,9 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 0, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.actions).isNull() assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -247,19 +200,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "sender-name: message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(1) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } } @@ -268,12 +212,13 @@ fun createRoomGroupMessageCreator( sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O), ): RoomGroupMessageCreator { val context = RuntimeEnvironment.getApplication() as Context - return RoomGroupMessageCreator( - notificationCreator = createNotificationCreator(), - bitmapLoader = NotificationBitmapLoader( - context = RuntimeEnvironment.getApplication(), - sdkIntProvider = sdkIntProvider, - ), + val bitmapLoader = NotificationBitmapLoader( + context = RuntimeEnvironment.getApplication(), + sdkIntProvider = sdkIntProvider, + ) + return DefaultRoomGroupMessageCreator( + notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader), + bitmapLoader = bitmapLoader, stringProvider = AndroidStringProvider(context.resources) ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt new file mode 100644 index 0000000000..6f53f2e140 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -0,0 +1,69 @@ +/* + * 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.push.impl.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.nonNull +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultSummaryGroupMessageCreatorTest { + @Test + fun `process notifications`() = runTest { + val notificationCreator = FakeNotificationCreator() + val summaryCreator = DefaultSummaryGroupMessageCreator( + stringProvider = FakeStringProvider(), + notificationCreator = notificationCreator, + ) + + val result = summaryCreator.createSummaryNotification( + currentUser = aMatrixUser(), + roomNotifications = listOf( + RoomNotification( + notification = Notification(), + roomId = A_ROOM_ID, + summaryLine = "", + messageCount = 1, + latestTimestamp = A_FAKE_TIMESTAMP + 10, + shouldBing = true, + ) + ), + invitationNotifications = emptyList(), + simpleNotifications = emptyList(), + fallbackNotifications = emptyList(), + ) + + notificationCreator.createSummaryListNotificationResult.assertions() + .isCalledOnce() + .with(any(), nonNull(), any(), any()) + + // Set from the events included + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt new file mode 100644 index 0000000000..a9ccabaa6d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -0,0 +1,31 @@ +/* + * 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.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotifiableEventResolver( + private val notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() } +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { + return notifiableEventResult(sessionId, roomId, eventId) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt similarity index 66% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt index e593f49824..4baeedc92b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeReplyMessageExtractor.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -16,9 +16,12 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import android.content.Intent -interface NotificationEventPersistence { - fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue - fun persistEvents(queuedEvents: NotificationEventQueue) +class FakeReplyMessageExtractor( + private val result: String? = null, +) : ReplyMessageExtractor { + override fun getReplyMessage(intent: Intent): String? { + return result + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt deleted file mode 100644 index a8626766e5..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.timeline.item.event.EventType -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID_2 -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_SPACE_ID -import io.element.android.libraries.matrix.test.A_THREAD_ID -import io.element.android.libraries.push.impl.notifications.fake.MockkOutdatedEventDetector -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.services.appnavstate.api.AppNavigationState -import io.element.android.services.appnavstate.api.NavigationState -import io.element.android.services.appnavstate.test.FakeAppNavigationStateService -import io.element.android.services.appnavstate.test.aNavigationState -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Test - -private val NOT_VIEWING_A_ROOM = aNavigationState() -private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) -private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) - -class NotifiableEventProcessorTest { - private val mockkOutdatedDetector = MockkOutdatedEventDetector() - - @Test - fun `given simple events when processing then keep simple events`() { - val events = listOf( - aSimpleNotifiableEvent(eventId = AN_EVENT_ID), - aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ProcessedEvent.Type.KEEP to events[1] - ) - ) - } - - @Test - fun `given redacted simple event when processing then remove redaction event`() { - val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION)) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0] - ) - ) - } - - @Test - fun `given invites are not auto accepted when processing then keep invitation events`() { - val events = listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID_2) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ProcessedEvent.Type.KEEP to events[1] - ) - ) - } - - @Test - fun `given out of date message event when processing then removes message event`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsOutOfDate(events[0]) - - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given in date message event when processing then keep message event`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) - events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) } - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given viewing the same thread timeline when processing thread message event then removes message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) - events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) } - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given events are different to rendered events when processing then removes difference`() { - val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)) - val renderedEvents = listOf>( - ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]), - ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2)) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = renderedEvents) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to renderedEvents[1].event, - ProcessedEvent.Type.KEEP to renderedEvents[0].event - ) - ) - } - - private fun listOfProcessedEvents(vararg event: Pair) = event.map { - ProcessedEvent(it.first, it.second) - } - - private fun createProcessor( - isInForeground: Boolean = false, - navigationState: NavigationState - ): NotifiableEventProcessor { - return NotifiableEventProcessor( - outdatedDetector = mockkOutdatedDetector.instance, - appNavigationStateService = FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))), - ) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt new file mode 100644 index 0000000000..7266f9fe98 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt @@ -0,0 +1,474 @@ +/* + * 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.push.impl.notifications + +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import io.element.android.features.preferences.api.store.SessionPreferencesStore +import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.timeline.FakeTimeline +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.api.notifications.NotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager +import io.element.android.services.toolbox.api.strings.StringProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class NotificationBroadcastReceiverHandlerTest { + private val actionIds = NotificationActionIds(aBuildMeta()) + + @Test + fun `When no sessionId, nothing happen`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.join, + sessionId = null + ), + ) + } + + @Test + fun `Test dismiss room without a roomId, nothing happen`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissRoom, + ), + ) + } + + @Test + fun `Test dismiss room`() = runTest { + val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMessagesForRoomLambda = clearMessagesForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.dismissRoom, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMessagesForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test dismiss summary`() = runTest { + val clearAllMessagesEventsLambda = lambdaRecorder { _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearAllMessagesEventsLambda = clearAllMessagesEventsLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.dismissSummary, + ), + ) + clearAllMessagesEventsLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + } + + @Test + fun `Test dismiss Invite without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissInvite, + ), + ) + } + + @Test + fun `Test dismiss Invite`() = runTest { + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.dismissInvite, + roomId = A_ROOM_ID, + ), + ) + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test dismiss Event without event`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.dismissEvent, + ), + ) + } + + @Test + fun `Test dismiss Event`() = runTest { + val clearEventLambda = lambdaRecorder { _, _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearEventLambda = clearEventLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.dismissEvent, + eventId = AN_EVENT_ID, + ), + ) + clearEventLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(AN_EVENT_ID)) + } + + @Test + fun `Test mark room as read without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.markRoomRead, + ), + ) + } + + @Test + fun `Test mark room as read, send public RR`() { + testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled = true, + expectedReceiptType = ReceiptType.READ + ) + } + + @Test + fun `Test mark room as read, send private RR`() { + testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled = false, + expectedReceiptType = ReceiptType.READ_PRIVATE + ) + } + + private fun testMarkRoomAsRead( + isSendPublicReadReceiptsEnabled: Boolean, + expectedReceiptType: ReceiptType, + ) = runTest { + val getLambda = lambdaRecorder { _, _ -> + InMemorySessionPreferencesStore( + isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled + ) + } + val sessionPreferencesStore = FakeSessionPreferencesStoreFactory( + getLambda = getLambda + ) + val clearMessagesForRoomLambda = lambdaRecorder { _, _ -> } + val matrixRoom = FakeMatrixRoom() + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMessagesForRoomLambda = clearMessagesForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + sessionPreferencesStore = sessionPreferencesStore, + matrixRoom = matrixRoom, + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.markRoomRead, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMessagesForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + assertThat(matrixRoom.markAsReadCalls).isEqualTo(listOf(expectedReceiptType)) + } + + @Test + fun `Test join room without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.join, + ), + ) + } + + @Test + fun `Test join room`() = runTest { + val joinRoom = lambdaRecorder> { _ -> Result.success(Unit) } + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + joinRoom = joinRoom, + notificationDrawerManager = notificationDrawerManager, + ) + sut.onReceive( + createIntent( + action = actionIds.join, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + joinRoom.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID)) + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + } + + @Test + fun `Test reject room without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.reject, + ), + ) + } + + @Test + fun `Test reject room`() = runTest { + val leaveRoom = lambdaRecorder> { Result.success(Unit) } + val matrixRoom = FakeMatrixRoom().apply { + leaveRoomLambda = leaveRoom + } + val clearMembershipNotificationForRoomLambda = lambdaRecorder { _, _ -> } + val notificationDrawerManager = FakeNotificationDrawerManager( + clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda, + ) + val sut = createNotificationBroadcastReceiverHandler( + matrixRoom = matrixRoom, + notificationDrawerManager = notificationDrawerManager + ) + sut.onReceive( + createIntent( + action = actionIds.reject, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + clearMembershipNotificationForRoomLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + leaveRoom.assertions() + .isCalledOnce() + .with() + } + + @Test + fun `Test send reply without room`() = runTest { + val sut = createNotificationBroadcastReceiverHandler() + sut.onReceive( + createIntent( + action = actionIds.smartReply, + ), + ) + } + + @Test + fun `Test send reply`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + replyMessageLambda = replyMessage + } + val matrixRoom = FakeMatrixRoom( + liveTimeline = liveTimeline + ) + val onNotifiableEventReceivedResult = lambdaRecorder { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult) + val sut = createNotificationBroadcastReceiverHandler( + matrixRoom = matrixRoom, + onNotifiableEventReceived = onNotifiableEventReceived, + replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE) + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + sendMessage.assertions() + .isCalledOnce() + .with(value(A_MESSAGE), value(null), value(emptyList())) + onNotifiableEventReceivedResult.assertions() + .isCalledOnce() + replyMessage.assertions() + .isNeverCalled() + } + + @Test + fun `Test send reply blank message`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + } + val matrixRoom = FakeMatrixRoom( + liveTimeline = liveTimeline + ) + val sut = createNotificationBroadcastReceiverHandler( + matrixRoom = matrixRoom, + replyMessageExtractor = FakeReplyMessageExtractor(" "), + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + ), + ) + runCurrent() + sendMessage.assertions() + .isNeverCalled() + } + + @Test + fun `Test send reply to thread`() = runTest { + val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) } + val replyMessage = lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) } + val liveTimeline = FakeTimeline().apply { + sendMessageLambda = sendMessage + replyMessageLambda = replyMessage + } + val matrixRoom = FakeMatrixRoom( + liveTimeline = liveTimeline + ) + val onNotifiableEventReceivedResult = lambdaRecorder { _ -> } + val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult) + val sut = createNotificationBroadcastReceiverHandler( + matrixRoom = matrixRoom, + onNotifiableEventReceived = onNotifiableEventReceived, + replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE) + ) + sut.onReceive( + createIntent( + action = actionIds.smartReply, + roomId = A_ROOM_ID, + threadId = A_THREAD_ID, + ), + ) + runCurrent() + sendMessage.assertions() + .isNeverCalled() + onNotifiableEventReceivedResult.assertions() + .isCalledOnce() + replyMessage.assertions() + .isCalledOnce() + .with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList()), value(true)) + } + + private fun createIntent( + action: String, + sessionId: SessionId? = A_SESSION_ID, + roomId: RoomId? = null, + eventId: EventId? = null, + threadId: ThreadId? = null, + ) = Intent(action).apply { + putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId?.value) + putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId?.value) + putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId?.value) + putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId?.value) + } + + private fun TestScope.createNotificationBroadcastReceiverHandler( + matrixRoom: FakeMatrixRoom? = FakeMatrixRoom(), + joinRoom: (RoomId) -> Result = { lambdaError() }, + matrixClient: MatrixClient? = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, matrixRoom) + joinRoomLambda = joinRoom + }, + sessionPreferencesStore: SessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(), + notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager(), + systemClock: SystemClock = FakeSystemClock(), + onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(), + stringProvider: StringProvider = FakeStringProvider(), + replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(), + ): NotificationBroadcastReceiverHandler { + return NotificationBroadcastReceiverHandler( + appCoroutineScope = this, + matrixClientProvider = FakeMatrixClientProvider { + if (matrixClient == null) { + Result.failure(Exception("No matrix client")) + } else { + Result.success(matrixClient) + } + }, + sessionPreferencesStore = sessionPreferencesStore, + notificationDrawerManager = notificationDrawerManager, + actionIds = actionIds, + systemClock = systemClock, + onNotifiableEventReceived = onNotifiableEventReceived, + stringProvider = stringProvider, + replyMessageExtractor = replyMessageExtractor, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt new file mode 100644 index 0000000000..aff5de7d4b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2021 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.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +@RunWith(RobolectricTestRunner::class) +class NotificationDataFactoryTest { + private val notificationCreator = FakeNotificationCreator() + private val fakeRoomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val fakeSummaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val activeNotificationsProvider = FakeActiveNotificationsProvider() + + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = fakeRoomGroupMessageCreator, + summaryGroupMessageCreator = fakeSummaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ) + + @Test + fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) + val roomInvitation = listOf(AN_INVITATION_EVENT) + + val result = toNotifications(roomInvitation) + + assertThat(result).isEqualTo( + listOf( + OneShotNotification( + notification = expectedNotification, + key = A_ROOM_ID.value, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + } + + @Test + fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) + val roomInvitation = listOf(A_SIMPLE_EVENT) + + val result = toNotifications(roomInvitation) + + assertThat(result).isEqualTo( + listOf( + OneShotNotification( + notification = expectedNotification, + key = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationDataFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + events, + A_ROOM_ID, + FakeImageLoader().getImageLoader(), + null, + ), + roomId = A_ROOM_ID, + summaryLine = "room-name: sender-name message-body", + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy } + ) + val roomWithMessage = listOf(A_MESSAGE_EVENT) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = roomWithMessage, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) { + val redactedRoom = listOf(A_MESSAGE_EVENT.copy(isRedacted = true)) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = redactedRoom, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result).isEmpty() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( + notificationDataFactory + ) { + val roomWithRedactedMessage = listOf( + A_MESSAGE_EVENT.copy(isRedacted = true), + A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")), + ) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + withRedactedRemoved, + A_ROOM_ID, + FakeImageLoader().getImageLoader(), + null, + ), + roomId = A_ROOM_ID, + summaryLine = "room-name: sender-name message-body", + messageCount = withRedactedRemoved.size, + latestTimestamp = withRedactedRemoved.maxOf { it.timestamp }, + shouldBing = withRedactedRemoved.any { it.noisy } + ) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = roomWithRedactedMessage, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } +} + +fun testWith(receiver: T, block: suspend T.() -> Unit) { + runTest { + receiver.block() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt deleted file mode 100644 index 7cc2687207..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import org.junit.Test - -class NotificationEventQueueTest { - private val seenIdsCache = CircularCache.create(5) - - @Test - fun `given events when redacting some then marks matching event ids as redacted`() { - val queue = givenQueue( - listOf( - aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1")), - aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2")), - anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3")), - aSimpleNotifiableEvent(eventId = EventId("\$kept-id")), - ) - ) - - queue.markRedacted(listOf(EventId("\$redacted-id-1"), EventId("\$redacted-id-2"), EventId("\$redacted-id-3"))) - - assertThat(queue.rawEvents()).isEqualTo( - listOf( - aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1"), isRedacted = true), - aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2"), isRedacted = true), - anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3"), isRedacted = true), - aSimpleNotifiableEvent(eventId = EventId("\$kept-id"), isRedacted = false), - ) - ) - } - - @Test - fun `given invite event when leaving invited room and syncing then removes event`() { - val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - val roomsLeft = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given invite event when joining invited room and syncing then removes event`() { - val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - val joinedRooms = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given message event when leaving message room and syncing then removes event`() { - val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) - val roomsLeft = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given events when syncing without rooms left or joined ids then does not change the events`() { - val queue = givenQueue( - listOf( - aNotifiableMessageEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID) - ) - ) - - queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEqualTo( - listOf( - aNotifiableMessageEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID) - ) - ) - } - - @Test - fun `given events then is not empty`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - assertThat(queue.isEmpty()).isFalse() - } - - @Test - fun `given no events then is empty`() { - val queue = givenQueue(emptyList()) - - assertThat(queue.isEmpty()).isTrue() - } - - @Test - fun `given events when clearing and adding then removes previous events and adds only new events`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - queue.clearAndAdd(listOf(anInviteNotifiableEvent())) - - assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent())) - } - - @Test - fun `when clearing then is empty`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - queue.clear() - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given no events when adding then adds event`() { - val queue = givenQueue(listOf()) - - queue.add(aSimpleNotifiableEvent()) - - assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent())) - } - - @Test - fun `given no events when adding already seen event then ignores event`() { - val queue = givenQueue(listOf()) - val notifiableEvent = aSimpleNotifiableEvent() - seenIdsCache.put(notifiableEvent.eventId) - - queue.add(notifiableEvent) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given replaceable event when adding event with same id then updates existing event`() { - val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true) - val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(replaceableEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `given non replaceable event when adding event with same id then ignores event`() { - val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false) - val updatedEvent = nonReplaceableEvent.copy(title = "updated title") - val queue = givenQueue(listOf(nonReplaceableEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent)) - } - - @Test - fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() { - val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$id-to-edit")) - val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(editedEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() { - val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$0"), editedEventId = EventId("\$id-to-edit")) - val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(editedEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `when clearing membership notification then removes invite events with matching room id`() { - val queue = givenQueue( - listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - aNotifiableMessageEvent(roomId = A_ROOM_ID) - ) - ) - - queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) - - assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) - } - - @Test - fun `when clearing messages for room then removes message events with matching room id`() { - val queue = givenQueue( - listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - aNotifiableMessageEvent(roomId = A_ROOM_ID) - ) - ) - - queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) - - assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - } - - private fun givenQueue(events: List) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache) -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt deleted file mode 100644 index 6a211fc446..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -private val MY_AVATAR_URL: String? = null -private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) -private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) -private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) - -@RunWith(RobolectricTestRunner::class) -class NotificationFactoryTest { - private val mockkNotificationCreator = MockkNotificationCreator() - private val mockkRoomGroupMessageCreator = MockkRoomGroupMessageCreator() - private val mockkSummaryGroupMessageCreator = MockkSummaryGroupMessageCreator() - - private val notificationFactory = NotificationFactory( - notificationCreator = mockkNotificationCreator.instance, - roomGroupMessageCreator = mockkRoomGroupMessageCreator.instance, - summaryGroupMessageCreator = mockkSummaryGroupMessageCreator.instance - ) - - @Test - fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { - val expectedNotification = mockkNotificationCreator.givenCreateRoomInvitationNotificationFor(AN_INVITATION_EVENT) - val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT)) - - val result = roomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Append( - notification = expectedNotification, - meta = OneShotNotification.Append.Meta( - key = A_ROOM_ID.value, - summaryLine = AN_INVITATION_EVENT.description, - isNoisy = AN_INVITATION_EVENT.noisy, - timestamp = AN_INVITATION_EVENT.timestamp - ) - ) - ) - ) - } - - @Test - fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { - val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT)) - - val result = missingEventRoomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Removed( - key = A_ROOM_ID.value - ) - ) - ) - } - - @Test - fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { - val expectedNotification = mockkNotificationCreator.givenCreateSimpleInvitationNotificationFor(A_SIMPLE_EVENT) - val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT)) - - val result = roomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Append( - notification = expectedNotification, - meta = OneShotNotification.Append.Meta( - key = AN_EVENT_ID.value, - summaryLine = A_SIMPLE_EVENT.description, - isNoisy = A_SIMPLE_EVENT.noisy, - timestamp = AN_INVITATION_EVENT.timestamp - ) - ) - ) - ) - } - - @Test - fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { - val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT)) - - val result = missingEventRoomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Removed( - key = AN_EVENT_ID.value - ) - ) - ) - } - - @Test - fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { - val events = listOf(A_MESSAGE_EVENT) - val expectedNotification = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor( - MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - events, - A_ROOM_ID - ) - val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT))) - - val fakeImageLoader = FakeImageLoader() - val result = roomWithMessage.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo(listOf(expectedNotification)) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { - val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT)) - val emptyRoom = mapOf(A_ROOM_ID to events) - - val fakeImageLoader = FakeImageLoader() - val result = emptyRoom.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo( - listOf( - RoomNotification.Removed( - roomId = A_ROOM_ID - ) - ) - ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { - val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) - - val fakeImageLoader = FakeImageLoader() - val result = redactedRoom.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo( - listOf( - RoomNotification.Removed( - roomId = A_ROOM_ID - ) - ) - ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( - notificationFactory - ) { - val roomWithRedactedMessage = mapOf( - A_ROOM_ID to listOf( - ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), - ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) - ) - ) - val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) - val expectedNotification = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor( - MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - withRedactedRemoved, - A_ROOM_ID, - ) - - val fakeImageLoader = FakeImageLoader() - val result = roomWithRedactedMessage.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo(listOf(expectedNotification)) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } -} - -fun testWith(receiver: T, block: suspend T.() -> Unit) { - runTest { - receiver.block() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 21fc1b4fca..0a5b216ea0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -16,16 +16,24 @@ package io.element.android.libraries.push.impl.notifications -import android.app.Notification import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.mockk.mockk +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -35,201 +43,81 @@ private const val MY_USER_DISPLAY_NAME = "display-name" private const val MY_USER_AVATAR_URL = "avatar-url" private const val USE_COMPLETE_NOTIFICATION_FORMAT = true -private val AN_EVENT_LIST = listOf>() -private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList()) -private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) -private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed -private val A_NOTIFICATION = mockk() -private val MESSAGE_META = RoomNotification.Message.Meta( - summaryLine = "ignored", - messageCount = 1, - latestTimestamp = -1, - roomId = A_ROOM_ID, - shouldBing = false -) -private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) +private val ONE_SHOT_NOTIFICATION = + OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) @RunWith(RobolectricTestRunner::class) class NotificationRendererTest { - private val mockkNotificationDisplayer = MockkNotificationDisplayer() - private val mockkNotificationFactory = MockkNotificationFactory() + private val notificationDisplayer = FakeNotificationDisplayer() + + private val notificationCreator = FakeNotificationCreator() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = FakeActiveNotificationsProvider(), + stringProvider = FakeStringProvider(), + ) private val notificationIdProvider = NotificationIdProvider() private val notificationRenderer = NotificationRenderer( notificationIdProvider = notificationIdProvider, - notificationDisplayer = mockkNotificationDisplayer.instance, - notificationFactory = mockkNotificationFactory.instance, + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, ) @Test fun `given no notifications when rendering then cancels summary notification`() = runTest { - givenNoNotifications() - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifySummaryCancelled() - mockkNotificationDisplayer.verifyNoOtherInteractions() - } - - @Test - fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() = runTest { - givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() = runTest { - givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) + renderEventsAsNotifications(emptyList()) - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } + notificationDisplayer.verifySummaryCancelled() } @Test fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { - givenNotifications( - roomNotifications = listOf( - RoomNotification.Message( - A_NOTIFICATION, - MESSAGE_META - ) - ) - ) - - renderEventsAsNotifications() + roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } - } - - @Test - fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() = runTest { - givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() = runTest { - givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value))) + renderEventsAsNotifications(listOf(aNotifiableMessageEvent())) - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } - } - - @Test - fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = AN_EVENT_ID.value) - ) - ) + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } } @Test - fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() = runTest { - givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() = runTest { - givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value))) + fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { + notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } - renderEventsAsNotifications() + renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))) - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) + ) } @Test fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = A_ROOM_ID.value) - ) - ) - ) + notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } - renderEventsAsNotifications() + renderEventsAsNotifications(listOf(anInviteNotifiableEvent())) - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) + ) } - private suspend fun renderEventsAsNotifications() { + private suspend fun renderEventsAsNotifications(events: List) { notificationRenderer.render( MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, - eventsToProcess = AN_EVENT_LIST, + eventsToProcess = events, imageLoader = FakeImageLoader().getImageLoader(), ) } - - private fun givenNoNotifications() { - givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) - } - - private fun givenNotifications( - roomNotifications: List = emptyList(), - invitationNotifications: List = emptyList(), - simpleNotifications: List = emptyList(), - fallbackNotifications: List = emptyList(), - useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, - summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION - ) { - mockkNotificationFactory.givenNotificationsFor( - groupedEvents = A_PROCESSED_EVENTS, - matrixUser = MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), - useCompleteNotificationFormat = useCompleteNotificationFormat, - roomNotifications = roomNotifications, - invitationNotifications = invitationNotifications, - simpleNotifications = simpleNotifications, - fallbackNotifications = fallbackNotifications, - summaryNotification = summaryNotification - ) - } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt similarity index 84% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index edf87fcd1a..de0c22d1a6 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications.factories import android.app.Notification import android.content.Context +import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.app.Person +import androidx.core.app.NotificationManagerCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -29,23 +30,29 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory +import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) -class NotificationCreatorTest { +class DefaultNotificationCreatorTest { @Test fun `test createDiagnosticNotification`() { val sut = createNotificationCreator() @@ -185,7 +192,6 @@ class NotificationCreatorTest { val matrixUser = aMatrixUser() val result = sut.createSummaryListNotification( currentUser = matrixUser, - style = null, compatSummary = "compatSummary", noisy = false, lastMessageTimestamp = 123_456L, @@ -201,7 +207,6 @@ class NotificationCreatorTest { val matrixUser = aMatrixUser() val result = sut.createSummaryListNotification( currentUser = matrixUser, - style = null, compatSummary = "compatSummary", noisy = true, lastMessageTimestamp = 123_456L, @@ -212,15 +217,10 @@ class NotificationCreatorTest { } @Test - fun `test createMessagesListNotification`() { + fun `test createMessagesListNotification`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -235,20 +235,19 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @Test - fun `test createMessagesListNotification should bing and thread`() { + fun `test createMessagesListNotification should bing and thread`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -263,6 +262,10 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @@ -280,9 +283,10 @@ class NotificationCreatorTest { fun createNotificationCreator( context: Context = RuntimeEnvironment.getApplication(), buildMeta: BuildMeta = aBuildMeta(), - notificationChannels: NotificationChannels = createNotificationChannels() + notificationChannels: NotificationChannels = createNotificationChannels(), + bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)), ): NotificationCreator { - return NotificationCreator( + return DefaultNotificationCreator( context = context, notificationChannels = notificationChannels, stringProvider = FakeStringProvider("test"), @@ -305,10 +309,23 @@ fun createNotificationCreator( stringProvider = FakeStringProvider("QuickReplyActionFactory"), clock = FakeSystemClock(), ), + bitmapLoader = bitmapLoader, + acceptInvitationActionFactory = AcceptInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("AcceptInvitationActionFactory"), + clock = FakeSystemClock(), + ), + rejectInvitationActionFactory = RejectInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("RejectInvitationActionFactory"), + clock = FakeSystemClock(), + ), ) } fun createNotificationChannels(): NotificationChannels { val context = RuntimeEnvironment.getApplication() - return NotificationChannels(context, FakeStringProvider("")) + return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider("")) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt new file mode 100644 index 0000000000..680688d3dc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt @@ -0,0 +1,54 @@ +/* + * 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.push.impl.notifications.fake + +import android.service.notification.StatusBarNotification +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider + +class FakeActiveNotificationsProvider( + var activeNotifications: MutableList = mutableListOf(), +) : ActiveNotificationsProvider { + override fun getAllNotifications(): List { + return activeNotifications + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + return activeNotifications.firstOrNull() + } + + override fun count(sessionId: SessionId): Int { + return activeNotifications.size + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt new file mode 100644 index 0000000000..988ec3083f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -0,0 +1,87 @@ +/* + * 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.push.impl.notifications.fake + +import android.app.Notification +import android.graphics.Bitmap +import coil.ImageLoader +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.tests.testutils.lambda.LambdaFourParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.lambdaAnyRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationCreator( + var createMessagesListNotificationResult: LambdaListAnyParamsRecorder = lambdaAnyRecorder { A_NOTIFICATION }, + var createRoomInvitationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSimpleNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSummaryListNotificationResult: LambdaFourParamsRecorder = + lambdaRecorder { _, _, _, _ -> A_NOTIFICATION }, + var createDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> A_NOTIFICATION }, +) : NotificationCreator { + override suspend fun createMessagesListNotification( + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List + ): Notification { + return createMessagesListNotificationResult( + listOf(roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, currentUser, existingNotification, imageLoader, events) + ) + } + + override fun createRoomInvitationNotification(inviteNotifiableEvent: InviteNotifiableEvent): Notification { + return createRoomInvitationNotificationResult(inviteNotifiableEvent) + } + + override fun createSimpleEventNotification(simpleNotifiableEvent: SimpleNotifiableEvent): Notification { + return createSimpleNotificationResult(simpleNotifiableEvent) + } + + override fun createFallbackNotification(fallbackNotifiableEvent: FallbackNotifiableEvent): Notification { + return createFallbackNotificationResult(fallbackNotifiableEvent) + } + + override fun createSummaryListNotification( + currentUser: MatrixUser, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification { + return createSummaryListNotificationResult(currentUser, compatSummary, noisy, lastMessageTimestamp) + } + + override fun createDiagnosticNotification(): Notification { + return createDiagnosticNotificationResult() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt new file mode 100644 index 0000000000..221d3d0878 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2021 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.push.impl.notifications.fake + +import coil.ImageLoader +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.NotificationDataFactory +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryNotification +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationDataFactory( + var messageEventToNotificationsResult: LambdaThreeParamsRecorder, MatrixUser, ImageLoader, List> = + lambdaRecorder { _, _, _ -> emptyList() }, + var summaryToNotificationsResult: LambdaFiveParamsRecorder< + MatrixUser, + List, + List, + List, + List, + SummaryNotification + > = lambdaRecorder { _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, + var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var simpleEventToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var fallbackEventToNotificationsResult: LambdaOneParamRecorder, List> = + lambdaRecorder { _ -> emptyList() }, +) : NotificationDataFactory { + override suspend fun toNotifications(messages: List, currentUser: MatrixUser, imageLoader: ImageLoader): List { + return messageEventToNotificationsResult(messages, currentUser, imageLoader) + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(invites: List): List { + return inviteToNotificationsResult(invites) + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(simpleEvents: List): List { + return simpleEventToNotificationsResult(simpleEvents) + } + + override fun toNotifications(fallback: List): List { + return fallbackEventToNotificationsResult(fallback) + } + + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): SummaryNotification { + return summaryToNotificationsResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..c8c041720c --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021 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.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.NotificationIdProvider +import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value + +class FakeNotificationDisplayer( + var showNotificationMessageResult: LambdaThreeParamsRecorder = lambdaRecorder { _, _, _ -> true }, + var cancelNotificationMessageResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, + var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, + var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> }, +) : NotificationDisplayer { + override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { + return showNotificationMessageResult(tag, id, notification) + } + + override fun cancelNotificationMessage(tag: String?, id: Int) { + return cancelNotificationMessageResult(tag, id) + } + + override fun displayDiagnosticNotification(notification: Notification): Boolean { + return displayDiagnosticNotificationResult(notification) + } + + override fun dismissDiagnosticNotification() { + return dismissDiagnosticNotificationResult() + } + + fun verifySummaryCancelled(times: Int = 1) { + cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence( + listOf(value(null), value(NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID))) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt similarity index 55% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt index 389a4f441d..c0e1692775 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -16,31 +16,27 @@ package io.element.android.libraries.push.impl.notifications.fake +import android.app.Notification +import coil.ImageLoader import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.mockk.coEvery -import io.mockk.mockk +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder -class MockkRoomGroupMessageCreator { - val instance = mockk() - - fun givenCreatesRoomMessageFor( - matrixUser: MatrixUser, +class FakeRoomGroupMessageCreator( + var createRoomMessageResult: LambdaFiveParamsRecorder, RoomId, ImageLoader, Notification?, Notification> = + lambdaRecorder { _, _, _, _, _, -> A_NOTIFICATION } +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + currentUser: MatrixUser, events: List, roomId: RoomId, - ): RoomNotification.Message { - val mockMessage = mockk() - coEvery { - instance.createRoomMessage( - currentUser = matrixUser, - events = events, - roomId = roomId, - imageLoader = any(), - ) - } returns mockMessage - return mockMessage + imageLoader: ImageLoader, + existingNotification: Notification? + ): Notification { + return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..07f142b43f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2021 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.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeSummaryGroupMessageCreator( + var createSummaryNotificationResult: LambdaFiveParamsRecorder< + MatrixUser, List, List, List, List, Notification + > = + lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + ): Notification { + return createSummaryNotificationResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt deleted file mode 100644 index 205ba058e6..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications.fake - -import android.app.Notification -import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.mockk.every -import io.mockk.mockk - -class MockkNotificationCreator { - val instance = mockk() - - fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createRoomInvitationNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createSimpleEventNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateDiagnosticNotification(): Notification { - val mockNotification = mockk() - every { instance.createDiagnosticNotification() } returns mockNotification - return mockNotification - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt deleted file mode 100644 index dc55cecfac..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications.fake - -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.NotificationDisplayer -import io.element.android.libraries.push.impl.notifications.NotificationIdProvider -import io.mockk.confirmVerified -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifyOrder - -class MockkNotificationDisplayer { - val instance = mockk(relaxed = true) - - fun givenDisplayDiagnosticNotificationResult(result: Boolean) { - every { instance.displayDiagnosticNotification(any()) } returns result - } - - fun verifySummaryCancelled() { - verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } - } - - fun verifyNoOtherInteractions() { - confirmVerified(instance) - } - - fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { - verifyOrder { verifyBlock(instance) } - verifyNoOtherInteractions() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt deleted file mode 100644 index 6a8410d2cb..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications.fake - -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents -import io.element.android.libraries.push.impl.notifications.NotificationFactory -import io.element.android.libraries.push.impl.notifications.OneShotNotification -import io.element.android.libraries.push.impl.notifications.RoomNotification -import io.element.android.libraries.push.impl.notifications.SummaryNotification -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk - -class MockkNotificationFactory { - val instance = mockk() - - fun givenNotificationsFor( - groupedEvents: GroupedNotificationEvents, - matrixUser: MatrixUser, - useCompleteNotificationFormat: Boolean, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - summaryNotification: SummaryNotification - ) { - with(instance) { - coEvery { groupedEvents.roomEvents.toNotifications(matrixUser, any()) } returns roomNotifications - every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications - every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications - every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications - - every { - createSummaryNotification( - matrixUser, - roomNotifications, - invitationNotifications, - simpleNotifications, - fallbackNotifications, - useCompleteNotificationFormat - ) - } returns summaryNotification - } - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt deleted file mode 100644 index 414f7ae652..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2021 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.push.impl.notifications.fake - -import io.element.android.libraries.push.impl.notifications.OutdatedEventDetector -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.mockk.every -import io.mockk.mockk - -class MockkOutdatedEventDetector { - val instance = mockk() - - fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns true - } - - fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns false - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt new file mode 100644 index 0000000000..797665ea78 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt @@ -0,0 +1,21 @@ +/* + * 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.push.impl.notifications.fixtures + +import android.app.Notification + +val A_NOTIFICATION = Notification() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt new file mode 100644 index 0000000000..7739efbd1d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -0,0 +1,268 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.push.impl.push + +import app.cash.turbine.test +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.test.DefaultTestPush +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushHandlerTest { + @Test + fun `when classical PushData is received, the notification drawer is informed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when classical PushData is received, but notifications are disabled, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStore = FakeUserPushStore().apply { + setNotificationEnabledForDevice(false) + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when PushData is received, but client secret is not known, fallback the latest session`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { A_USER_ID } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when PushData is received, but client secret is not known, and there is no latest session, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { null } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when classical PushData is received, but not able to resolve the event, nothing happen`() = + runTest { + val notifiableEventResult = + lambdaRecorder { _, _, _ -> null } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + buildMeta = aBuildMeta( + // Also test `lowPrivacyLoggingEnabled = false` here + lowPrivacyLoggingEnabled = false + ), + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when diagnostic PushData is received, the diagnostic push handler is informed `() = + runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val diagnosticPushHandler = DiagnosticPushHandler() + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { } + ) + diagnosticPushHandler.state.test { + defaultPushHandler.handle(aPushData) + awaitItem() + } + } + + private fun createDefaultPushHandler( + onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() }, + notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() }, + incrementPushCounterResult: () -> Unit = { lambdaError() }, + userPushStore: UserPushStore = FakeUserPushStore(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + buildMeta: BuildMeta = aBuildMeta(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), + ): DefaultPushHandler { + return DefaultPushHandler( + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived), + notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult), + incrementPushDataStore = object : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + incrementPushCounterResult() + } + }, + userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, + pushClientSecret = pushClientSecret, + buildMeta = buildMeta, + matrixAuthenticationService = matrixAuthenticationService, + diagnosticPushHandler = diagnosticPushHandler, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt similarity index 54% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt index 09f907f50b..d7fb3ce048 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * 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. @@ -14,20 +14,15 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications +package io.element.android.libraries.push.impl.push import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError -class InMemoryNotificationEventPersistence( - initialData: List = emptyList() -) : NotificationEventPersistence { - private var data: List = initialData - - override fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - return factory(data) - } - - override fun persistEvents(queuedEvents: NotificationEventQueue) { - data = queuedEvents.rawEvents() +class FakeOnNotifiableEventReceived( + private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit = { lambdaError() }, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + onNotifiableEventReceivedResult(notifiableEvent) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt new file mode 100644 index 0000000000..ba432b4c7a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt @@ -0,0 +1,102 @@ +/* + * 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.push.impl.pushgateway + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.impl.test.DefaultTestPush +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultPushGatewayNotifyRequestTest { + @Test + fun `notify success`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify success, url is stripped`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl" + PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH, + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify with rejected push key should throw expected Exception`() { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = listOf("aPushKey") + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(PushGatewayFailure.PusherRejected::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt new file mode 100644 index 0000000000..0b9730843e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt @@ -0,0 +1,37 @@ +/* + * 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.push.impl.pushgateway + +class FakePushGatewayApiFactory( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): PushGatewayAPI { + baseUrlParameter = baseUrl + return FakePushGatewayAPI(notifyResponse) + } +} + +class FakePushGatewayAPI( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayAPI { + override suspend fun notify(body: PushGatewayNotifyBody): PushGatewayNotifyResponse { + return notifyResponse() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt new file mode 100644 index 0000000000..0ccd08df82 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -0,0 +1,55 @@ +/* + * 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.push.impl.test + +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultTestPushTest { + @Test + fun `test DefaultTestPush`() = runTest { + val executeResult = lambdaRecorder { } + val defaultTestPush = DefaultTestPush( + pushGatewayNotifyRequest = FakePushGatewayNotifyRequest( + executeResult = executeResult, + ) + ) + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + defaultTestPush.execute(aConfig) + executeResult.assertions() + .isCalledOnce() + .with( + value( + PushGatewayNotifyRequest.Params( + url = aConfig.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = aConfig.pushKey, + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..d0fa5a546f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt @@ -0,0 +1,28 @@ +/* + * 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.push.impl.test + +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushGatewayNotifyRequest( + private val executeResult: (PushGatewayNotifyRequest.Params) -> Unit = { lambdaError() } +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + executeResult(params) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt new file mode 100644 index 0000000000..d7bf8c8c42 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt @@ -0,0 +1,28 @@ +/* + * 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.push.impl.test + +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeTestPush( + private val executeResult: (CurrentUserPushConfig) -> Unit = { lambdaError() } +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + executeResult(config) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt index 1351117527..1f5b1c43db 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt @@ -18,27 +18,27 @@ package io.element.android.libraries.push.impl.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Test class NotificationTestTest { - private val mockkNotificationCreator = MockkNotificationCreator().apply { - givenCreateDiagnosticNotification() - } - private val mockkNotificationDisplayer = MockkNotificationDisplayer().apply { - givenDisplayDiagnosticNotificationResult(true) - } + private val notificationCreator = FakeNotificationCreator() + private val fakeNotificationDisplayer = FakeNotificationDisplayer( + displayDiagnosticNotificationResult = lambdaRecorder { _ -> true }, + dismissDiagnosticNotificationResult = lambdaRecorder { -> } + ) private val notificationClickHandler = NotificationClickHandler() @Test fun `test NotificationTest notification cannot be displayed`() = runTest { - mockkNotificationDisplayer.givenDisplayDiagnosticNotificationResult(false) + fakeNotificationDisplayer.displayDiagnosticNotificationResult = lambdaRecorder { _ -> false } val sut = createNotificationTest() launch { sut.run(this) @@ -81,8 +81,8 @@ class NotificationTestTest { private fun createNotificationTest(): NotificationTest { return NotificationTest( - notificationCreator = mockkNotificationCreator.instance, - notificationDisplayer = mockkNotificationDisplayer.instance, + notificationCreator = notificationCreator, + notificationDisplayer = fakeNotificationDisplayer, notificationClickHandler = notificationClickHandler, stringProvider = FakeStringProvider(), ) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index a148e5f6a2..5e7d9e7ff1 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -29,23 +29,27 @@ class FakePushService( Result.success(Unit) }, ) : PushService { - override fun notificationStyleChanged() { - } - override suspend fun getCurrentPushProvider(): PushProvider? { - return availablePushProviders.firstOrNull() + return registeredPushProvider ?: availablePushProviders.firstOrNull() } override fun getAvailablePushProviders(): List { return availablePushProviders } + private var registeredPushProvider: PushProvider? = null + override suspend fun registerWith( matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor, ): Result = simulateLongTask { return registerWithLambda(matrixClient, pushProvider, distributor) + .also { + if (it.isSuccess) { + registeredPushProvider = pushProvider + } + } } override suspend fun testPush(): Boolean = simulateLongTask { diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt new file mode 100644 index 0000000000..8338bb1e4c --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt @@ -0,0 +1,34 @@ +/* + * 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.push.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePusherSubscriber( + private val registerPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, + private val unregisterPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : PusherSubscriber { + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return registerPusherResult(matrixClient, pushKey, gateway) + } + + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return unregisterPusherResult(matrixClient, pushKey, gateway) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt index 702e46c3ae..b42f05aa5f 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt @@ -16,33 +16,36 @@ package io.element.android.libraries.push.test.notifications +import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.notifications.NotificationDrawerManager - -class FakeNotificationDrawerManager : NotificationDrawerManager { - private val clearMemberShipNotificationForSessionCallsCount = mutableMapOf() - private val clearMemberShipNotificationForRoomCallsCount = mutableMapOf() - - override fun clearMembershipNotificationForSession(sessionId: SessionId) { - clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value } +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationDrawerManager( + private val clearAllMessagesEventsLambda: (SessionId) -> Unit = { lambdaError() }, + private val clearMessagesForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }, + private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() }, + private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() }, + private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() } +) : NotificationDrawerManager { + override fun clearAllMessagesEvents(sessionId: SessionId) { + clearAllMessagesEventsLambda(sessionId) } - override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { - val key = getMembershipNotificationKey(sessionId, roomId) - clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value } + override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + clearMessagesForRoomLambda(sessionId, roomId) } - fun getClearMembershipNotificationForSessionCount(sessionId: SessionId): Int { - return clearMemberShipNotificationForRoomCallsCount[sessionId.value] ?: 0 + override fun clearEvent(sessionId: SessionId, eventId: EventId) { + clearEventLambda(sessionId, eventId) } - fun getClearMembershipNotificationForRoomCount(sessionId: SessionId, roomId: RoomId): Int { - val key = getMembershipNotificationKey(sessionId, roomId) - return clearMemberShipNotificationForRoomCallsCount[key] ?: 0 + override fun clearMembershipNotificationForSession(sessionId: SessionId) { + clearMembershipNotificationForSessionLambda(sessionId) } - private fun getMembershipNotificationKey(sessionId: SessionId, roomId: RoomId): String { - return "$sessionId-$roomId" + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + clearMembershipNotificationForRoomLambda(sessionId, roomId) } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt new file mode 100644 index 0000000000..c370250bd0 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt @@ -0,0 +1,29 @@ +/* + * 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.push.test.test + +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHandler( + private val handleResult: (PushData) -> Unit = { lambdaError() } +) : PushHandler { + override suspend fun handle(pushData: PushData) { + handleResult(pushData) + } +} diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index ca1489b463..1bca3a0623 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -58,7 +58,11 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt index e61ea4bc91..baddab1a0d 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -16,8 +16,10 @@ package io.element.android.libraries.pushproviders.firebase +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -32,14 +34,19 @@ private val loggerTag = LoggerTag("FirebaseNewTokenHandler", LoggerTag.PushLogge /** * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. */ -class FirebaseNewTokenHandler @Inject constructor( +interface FirebaseNewTokenHandler { + suspend fun handle(firebaseToken: String) +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseNewTokenHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val sessionStore: SessionStore, private val userPushStoreFactory: UserPushStoreFactory, private val matrixAuthenticationService: MatrixAuthenticationService, private val firebaseStore: FirebaseStore, -) { - suspend fun handle(firebaseToken: String) { +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { firebaseStore.storeFcmToken(firebaseToken) // Register the pusher for all the sessions sessionStore.getAllSessions().toUserList() @@ -53,14 +60,15 @@ class FirebaseNewTokenHandler @Inject constructor( 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") + pusherSubscriber + .registerPusher( + matrixClient = client, + pushKey = firebaseToken, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to register pusher for session $sessionId") + } } } else { Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt index b88d70b157..0228f8f74b 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -64,14 +64,13 @@ class FirebasePushProvider @Inject constructor( override suspend fun getCurrentDistributor(matrixClient: MatrixClient) = firebaseDistributor override suspend fun unregister(matrixClient: MatrixClient): Result { - val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( - IllegalStateException( - "Unable to unregister pusher, Firebase token is not known." - ) - ).also { + val pushKey = firebaseStore.getFcmToken() + return if (pushKey == null) { Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + Result.success(Unit) + } else { + pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } - return pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt index 0614e2065c..1c8b6c5ed8 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt @@ -20,7 +20,6 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.DefaultPreferences import javax.inject.Inject /** @@ -32,15 +31,15 @@ interface FirebaseStore { } @ContributesBinding(AppScope::class) -class DefaultFirebaseStore @Inject constructor( - @DefaultPreferences private val sharedPrefs: SharedPreferences, +class SharedPreferencesFirebaseStore @Inject constructor( + private val sharedPreferences: SharedPreferences, ) : FirebaseStore { override fun getFcmToken(): String? { - return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) + return sharedPreferences.getString(PREFS_KEY_FCM_TOKEN, null) } override fun storeFcmToken(token: String?) { - sharedPrefs.edit { + sharedPreferences.edit { putString(PREFS_KEY_FCM_TOKEN, token) } } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt index 3d251f6e64..a8bc069893 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -22,7 +22,6 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,8 +32,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler @Inject lateinit var pushParser: FirebasePushParser @Inject lateinit var pushHandler: PushHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var coroutineScope: CoroutineScope override fun onCreate() { super.onCreate() diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt new file mode 100644 index 0000000000..585a5e2a08 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt @@ -0,0 +1,186 @@ +/* + * 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.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFirebaseNewTokenHandlerTest { + @Test + fun `when a new token is received it is stored in the firebase store`() = runTest { + val firebaseStore = InMemoryFirebaseStore() + assertThat(firebaseStore.getFcmToken()).isNull() + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + firebaseStore = firebaseStore, + ) + firebaseNewTokenHandler.handle("aToken") + assertThat(firebaseStore.getFcmToken()).isEqualTo("aToken") + } + + @Test + fun `when a new token is received, the handler registers the pusher again to all sessions using Firebase`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val aMatrixClient2 = FakeMatrixClient(A_USER_ID_2) + val aMatrixClient3 = FakeMatrixClient(A_USER_ID_3) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + storeData(aSessionData(A_USER_ID_2)) + storeData(aSessionData(A_USER_ID_3)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(aMatrixClient1) + A_USER_ID_2 -> Result.success(aMatrixClient2) + A_USER_ID_3 -> Result.success(aMatrixClient3) + else -> Result.failure(IllegalStateException()) + } + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { sessionId -> + when (sessionId) { + A_USER_ID -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + A_USER_ID_2 -> FakeUserPushStore(pushProviderName = "Other") + A_USER_ID_3 -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + else -> error("Unexpected sessionId: $sessionId") + } + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isCalledExactly(2) + .withSequence( + listOf(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + listOf(value(aMatrixClient3), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + ) + } + + @Test + fun `when a new token is received, if the session cannot be restore, nothing happen`() = runTest { + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.failure(IllegalStateException()) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isNeverCalled() + } + + @Test + fun `when a new token is received, error when registering the pusher is ignored`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.success(aMatrixClient1) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + registerPusherResult.assertions() + .isCalledOnce() + .with(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + private fun createDefaultFirebaseNewTokenHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + sessionStore: SessionStore = InMemorySessionStore(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + ): FirebaseNewTokenHandler { + return DefaultFirebaseNewTokenHandler( + pusherSubscriber = pusherSubscriber, + sessionStore = sessionStore, + userPushStoreFactory = userPushStoreFactory, + matrixAuthenticationService = matrixAuthenticationService, + firebaseStore = firebaseStore + ) + } + + private fun aSessionData( + sessionId: SessionId, + ): SessionData { + return SessionData( + userId = sessionId.value, + deviceId = "aDeviceId", + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + homeserverUrl = "aHomeserverUrl", + oidcData = null, + slidingSyncProxy = null, + loginTimestamp = null, + isTokenValid = true, + loginType = LoginType.UNKNOWN, + passphrase = null, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt similarity index 59% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt rename to libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt index 2e91ca3467..aa66f0288c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * 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. @@ -14,18 +14,14 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications +package io.element.android.libraries.pushproviders.firebase -data class ProcessedEvent( - val type: Type, - val event: T -) { - enum class Type { - KEEP, - REMOVE - } -} +import io.element.android.tests.testutils.lambda.lambdaError -fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> - processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +class FakeFirebaseNewTokenHandler( + private val handleResult: (String) -> Unit = { lambdaError() } +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { + handleResult(firebaseToken) + } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt new file mode 100644 index 0000000000..6994e6140e --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt @@ -0,0 +1,23 @@ +/* + * 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.firebase + +class FakeIsPlayServiceAvailable( + private val isAvailable: Boolean, +) : IsPlayServiceAvailable { + override fun isAvailable() = isAvailable +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt new file mode 100644 index 0000000000..880be2f053 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt @@ -0,0 +1,194 @@ +/* + * 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.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebasePushProviderTest { + @Test + fun `test index and name`() { + val firebasePushProvider = createFirebasePushProvider() + assertThat(firebasePushProvider.name).isEqualTo(FirebaseConfig.NAME) + assertThat(firebasePushProvider.index).isEqualTo(FirebaseConfig.INDEX) + } + + @Test + fun `getDistributors return the unique distributor`() { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("Firebase", "Firebase")) + } + + @Test + fun `getCurrentDistributor always return the unique distributor`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getCurrentDistributor(FakeMatrixClient()) + assertThat(result).isEqualTo(Distributor("Firebase", "Firebase")) + } + + @Test + fun `isAvailable true`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true) + ) + assertThat(firebasePushProvider.isAvailable()).isTrue() + } + + @Test + fun `isAvailable false`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false) + ) + assertThat(firebasePushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val matrixClient = FakeMatrixClient() + val registerPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = registerPusherResultLambda + ) + ) + val result = firebasePushProvider.registerWith(matrixClient, Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + registerPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `register ko no token`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.success(Unit) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `register ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val unregisterPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = unregisterPusherResultLambda + ) + ) + val result = firebasePushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + unregisterPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `unregister no token - in this case, the error is ignored`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `unregister ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `getCurrentUserPushConfig no push ket`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ) + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig(FirebaseConfig.PUSHER_HTTP_URL, "aToken")) + } + + private fun createFirebasePushProvider( + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + isPlayServiceAvailable: IsPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + ): FirebasePushProvider { + return FirebasePushProvider( + firebaseStore = firebaseStore, + pusherSubscriber = pusherSubscriber, + isPlayServiceAvailable = isPlayServiceAvailable, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt new file mode 100644 index 0000000000..9dfb453919 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -0,0 +1,95 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.firebase + +import android.os.Bundle +import com.google.firebase.messaging.RemoteMessage +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorFirebaseMessagingServiceTest { + @Test + fun `test receiving invalid data`() = runTest { + val lambda = lambdaRecorder(ensureNeverCalled = true) { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived(RemoteMessage(Bundle())) + } + + @Test + fun `test receiving valid data`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived( + message = RemoteMessage( + Bundle().apply { + putString("event_id", AN_EVENT_ID.value) + putString("room_id", A_ROOM_ID.value) + putString("cs", A_SECRET) + }, + ) + ) + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET))) + } + + @Test + fun `test new token is forwarded to the handler`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + firebaseNewTokenHandler = FakeFirebaseNewTokenHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onNewToken("aToken") + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value("aToken")) + } + + private fun TestScope.createVectorFirebaseMessagingService( + firebaseNewTokenHandler: FirebaseNewTokenHandler = FakeFirebaseNewTokenHandler(), + pushHandler: PushHandler = FakePushHandler(), + ): VectorFirebaseMessagingService { + return VectorFirebaseMessagingService().apply { + this.firebaseNewTokenHandler = firebaseNewTokenHandler + this.pushParser = FirebasePushParser() + this.pushHandler = pushHandler + this.coroutineScope = this@createVectorFirebaseMessagingService + } + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt index 6f1a3da7cb..fae5ba9f12 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt @@ -18,8 +18,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FakeIsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -29,11 +31,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest success`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return true - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(true), stringProvider = FakeStringProvider(), ) launch { @@ -50,11 +48,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest failure`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return false - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), stringProvider = FakeStringProvider(), ) launch { @@ -67,4 +61,14 @@ class FirebaseAvailabilityTestTest { assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) } } + + @Test + fun `test FirebaseAvailabilityTest isRelevant`() { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt index 2d8de62ad9..245a6095d4 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt @@ -19,8 +19,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.firebase.FakeFirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.pushproviders.firebase.InMemoryFirebaseStore import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -75,6 +77,17 @@ class FirebaseTokenTestTest { } } + @Test + fun `test FirebaseTokenTest isRelevant`() { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(null), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } + companion object { private const val FAKE_TOKEN = "abcdefghijk" } diff --git a/libraries/pushproviders/test/build.gradle.kts b/libraries/pushproviders/test/build.gradle.kts index ddb68ed43f..9a0d2c139c 100644 --- a/libraries/pushproviders/test/build.gradle.kts +++ b/libraries/pushproviders/test/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.pushproviders.api) + implementation(projects.tests.testutils) } diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt index 44aa1ca18f..7b37d0d296 100644 --- a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -20,19 +20,23 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.lambda.lambdaError class FakePushProvider( override val index: Int = 0, override val name: String = "aFakePushProvider", private val isAvailable: Boolean = true, private val distributors: List = listOf(Distributor("aDistributorValue", "aDistributorName")), + private val currentUserPushConfig: CurrentUserPushConfig? = null, + private val registerWithResult: (MatrixClient, Distributor) -> Result = { _, _ -> lambdaError() }, + private val unregisterWithResult: (MatrixClient) -> Result = { lambdaError() }, ) : PushProvider { override fun isAvailable(): Boolean = isAvailable override fun getDistributors(): List = distributors override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { - return Result.success(Unit) + return registerWithResult(matrixClient, distributor) } override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { @@ -40,10 +44,10 @@ class FakePushProvider( } override suspend fun unregister(matrixClient: MatrixClient): Result { - return Result.success(Unit) + return unregisterWithResult(matrixClient) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { - return null + return currentUserPushConfig } } diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index 9d1463aa81..4a2ffba138 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -57,9 +57,13 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) + testImplementation(projects.services.appnavstate.test) } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index 24f93b88dc..6670f18ea2 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -17,43 +17,41 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject import kotlin.time.Duration.Companion.seconds -class RegisterUnifiedPushUseCase @Inject constructor( +interface RegisterUnifiedPushUseCase { + suspend fun execute(distributor: Distributor, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultRegisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, private val endpointRegistrationHandler: EndpointRegistrationHandler, - private val coroutineScope: CoroutineScope, -) { - suspend fun execute(distributor: Distributor, clientSecret: String): Result { +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { UnifiedPush.saveDistributor(context, distributor.value) - val completable = CompletableDeferred>() - val job = coroutineScope.launch { - val result = endpointRegistrationHandler.state - .filter { it.clientSecret == clientSecret } - .first() - .result - completable.complete(result) - } // This will trigger the callback // VectorUnifiedPushMessagingReceiver.onNewEndpoint UnifiedPush.registerApp(context = context, instance = clientSecret) // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed - return withTimeout(30.seconds) { - completable.await() - } - .onFailure { - job.cancel() + return runCatching { + withTimeout(30.seconds) { + val result = endpointRegistrationHandler.state + .filter { it.clientSecret == clientSecret } + .first() + .result + result.getOrThrow() } + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt new file mode 100644 index 0000000000..84a923df44 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt @@ -0,0 +1,37 @@ +/* + * 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 + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import javax.inject.Inject + +interface UnifiedPushApiFactory { + fun create(baseUrl: String): UnifiedPushApi +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : UnifiedPushApiFactory { + override fun create(baseUrl: String): UnifiedPushApi { + return retrofitFactory.create(baseUrl) + .create(UnifiedPushApi::class.java) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt index 54b80a5110..c39c7ec066 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt @@ -16,29 +16,33 @@ package io.element.android.libraries.pushproviders.unifiedpush +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.network.RetrofitFactory -import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import io.element.android.libraries.di.AppScope import kotlinx.coroutines.withContext import timber.log.Timber import java.net.URL import javax.inject.Inject -class UnifiedPushGatewayResolver @Inject constructor( - private val retrofitFactory: RetrofitFactory, +interface UnifiedPushGatewayResolver { + suspend fun getGateway(endpoint: String): String +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushGatewayResolver @Inject constructor( + private val unifiedPushApiFactory: UnifiedPushApiFactory, private val coroutineDispatchers: CoroutineDispatchers, -) { - suspend fun getGateway(endpoint: String): String? { +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { val gateway = UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL - val url = URL(endpoint) - val port = if (url.port != -1) ":${url.port}" else "" - val customBase = "${url.protocol}://${url.host}$port" - val customUrl = "$customBase/_matrix/push/v1/notify" - Timber.i("Testing $customUrl") try { + val url = URL(endpoint) + val port = if (url.port != -1) ":${url.port}" else "" + val customBase = "${url.protocol}://${url.host}$port" + val customUrl = "$customBase/_matrix/push/v1/notify" + Timber.i("Testing $customUrl") return withContext(coroutineDispatchers.io) { - val api = retrofitFactory.create(customBase) - .create(UnifiedPushApi::class.java) + val api = unifiedPushApiFactory.create(customBase) try { val discoveryResponse = api.discover() if (discoveryResponse.unifiedpush.gateway == "matrix") { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt index 7d793a0f44..56ec48d880 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -17,8 +17,10 @@ package io.element.android.libraries.pushproviders.unifiedpush import chat.schildi.lib.preferences.ScAppStateStore +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory @@ -26,19 +28,24 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultUnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) /** * Handle new endpoint received from UnifiedPush. Will update the session matching the client secret. */ -class UnifiedPushNewGatewayHandler @Inject constructor( +interface UnifiedPushNewGatewayHandler { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushNewGatewayHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val matrixAuthenticationService: MatrixAuthenticationService, private val scAppStateStore: ScAppStateStore, -) { - suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { // Register the pusher for the session with this client secret, if is it using UnifiedPush. val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( IllegalStateException("Unable to retrieve session") diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt index f68cd8542b..46c7c0f9bb 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.pushproviders.unifiedpush import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.pushproviders.api.PushData -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import javax.inject.Inject diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt index 7b6ee5ef37..12ba32e620 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -19,33 +19,44 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.matrix.api.core.UserId import javax.inject.Inject -class UnifiedPushStore @Inject constructor( +interface UnifiedPushStore { + fun getEndpoint(clientSecret: String): String? + fun storeUpEndpoint(clientSecret: String, endpoint: String?) + fun getPushGateway(clientSecret: String): String? + fun storePushGateway(clientSecret: String, gateway: String?) + fun getDistributorValue(userId: UserId): String? + fun setDistributorValue(userId: UserId, value: String) +} + +@ContributesBinding(AppScope::class) +class SharedPreferencesUnifiedPushStore @Inject constructor( @ApplicationContext val context: Context, - @DefaultPreferences private val defaultPrefs: SharedPreferences, -) { + private val sharedPreferences: SharedPreferences, +) : UnifiedPushStore { /** * Retrieves the UnifiedPush Endpoint. * * @param clientSecret the client secret, to identify the session * @return the UnifiedPush Endpoint or null if not received */ - fun getEndpoint(clientSecret: String): String? { - return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) + override fun getEndpoint(clientSecret: String): String? { + return sharedPreferences.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) } /** * Store UnifiedPush Endpoint to the SharedPrefs. * - * @param endpoint the endpoint to store * @param clientSecret the client secret, to identify the session + * @param endpoint the endpoint to store */ - fun storeUpEndpoint(endpoint: String?, clientSecret: String) { - defaultPrefs.edit { + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + sharedPreferences.edit { putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint) } } @@ -56,28 +67,28 @@ class UnifiedPushStore @Inject constructor( * @param clientSecret the client secret, to identify the session * @return the Push Gateway or null if not defined */ - fun getPushGateway(clientSecret: String): String? { - return defaultPrefs.getString(PREFS_PUSH_GATEWAY + clientSecret, null) + override fun getPushGateway(clientSecret: String): String? { + return sharedPreferences.getString(PREFS_PUSH_GATEWAY + clientSecret, null) } /** * Store Push Gateway to the SharedPrefs. * - * @param gateway the push gateway to store * @param clientSecret the client secret, to identify the session + * @param gateway the push gateway to store */ - fun storePushGateway(gateway: String?, clientSecret: String) { - defaultPrefs.edit { + override fun storePushGateway(clientSecret: String, gateway: String?) { + sharedPreferences.edit { putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) } } - fun getDistributorValue(userId: UserId): String? { - return defaultPrefs.getString(PREFS_DISTRIBUTOR + userId, null) + override fun getDistributorValue(userId: UserId): String? { + return sharedPreferences.getString(PREFS_DISTRIBUTOR + userId, null) } - fun setDistributorValue(userId: UserId, value: String) { - defaultPrefs.edit { + override fun setDistributorValue(userId: UserId, value: String) { + sharedPreferences.edit { putString(PREFS_DISTRIBUTOR + userId, value) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index b2dd33c252..769f6507d5 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -17,27 +17,39 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.PusherSubscriber import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber import javax.inject.Inject -class UnregisterUnifiedPushUseCase @Inject constructor( +interface UnregisterUnifiedPushUseCase { + suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnregisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, private val unifiedPushStore: UnifiedPushStore, private val pusherSubscriber: PusherSubscriber, -) { - suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { val endpoint = unifiedPushStore.getEndpoint(clientSecret) val gateway = unifiedPushStore.getPushGateway(clientSecret) if (endpoint == null || gateway == null) { - return Result.failure(IllegalStateException("No endpoint or gateway found for client secret")) + Timber.w("No endpoint or gateway found for client secret") + // Ensure we don't have any remaining data, but ignore this error + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + return Result.success(Unit) } return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) .onSuccess { - unifiedPushStore.storeUpEndpoint(null, clientSecret) - unifiedPushStore.storePushGateway(null, clientSecret) + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) UnifiedPush.unregisterApp(context) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index c47aabae37..a52b1b0e6e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -24,7 +24,6 @@ import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver import timber.log.Timber @@ -40,8 +39,7 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var coroutineScope: CoroutineScope override fun onReceive(context: Context, intent: Intent) { context.applicationContext.bindings().inject(this) @@ -75,30 +73,20 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint") coroutineScope.launch { val gateway = unifiedPushGatewayResolver.getGateway(endpoint) - unifiedPushStore.storePushGateway(gateway, instance) - if (gateway == null) { - Timber.tag(loggerTag.value).w("No gateway found for endpoint $endpoint") - endpointRegistrationHandler.registrationDone( - RegistrationResult( - clientSecret = instance, - result = Result.failure(IllegalStateException("No gateway found for endpoint $endpoint")), - ) - ) - } else { - val result = newGatewayHandler.handle(endpoint, gateway, instance) - .onFailure { - Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") - } - .onSuccess { - unifiedPushStore.storeUpEndpoint(endpoint, instance) - } - endpointRegistrationHandler.registrationDone( - RegistrationResult( - clientSecret = instance, - result = result, - ) + unifiedPushStore.storePushGateway(instance, gateway) + val result = newGatewayHandler.handle(endpoint, gateway, instance) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") + } + .onSuccess { + unifiedPushStore.storeUpEndpoint(instance, endpoint) + } + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = result, ) - } + ) } guardServiceStarter.stop() } diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml index 5fcab0a3f3..4fd2fef492 100644 --- a/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-be/translations.xml @@ -4,7 +4,7 @@ "Размеркавальнікі не знойдзены." "%1$d знойдзены размеркавальнік: %2$s." - "%1$d знойдзена размеркавальніка: %2$s." + "%1$d знойдзены размеркавальнікі: %2$s." "%1$d знойдзена размеркавальнікаў: %2$s." "Праверыць UnifiedPush" diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000000..cda9516b65 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,86 @@ +/* + * 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 + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultRegisterUnifiedPushUseCaseTest { + @Test + fun `test registration successful`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.success(Unit))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test registration error`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.failure(AN_EXCEPTION))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + @Test + fun `test registration timeout`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + private fun TestScope.createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler: EndpointRegistrationHandler + ): DefaultRegisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultRegisterUnifiedPushUseCase( + context = context, + endpointRegistrationHandler = endpointRegistrationHandler, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt new file mode 100644 index 0000000000..e180e36c38 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt @@ -0,0 +1,144 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryUnifiedPush +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushGatewayResolverTest { + private val matrixDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "matrix" + ) + ) + } + + private val invalidDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "" + ) + ) + } + + @Test + fun `when a custom url provide a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo("https://custom.url/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port and path provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with http scheme provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url:123") + assertThat(result).isEqualTo("http://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url is not reachable, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { throw AN_EXCEPTION } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url is invalid, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("invalid") + assertThat(unifiedPushApiFactory.baseUrlParameter).isNull() + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url provides a invalid matrix gateway, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = invalidDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + private fun TestScope.createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory: UnifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { DiscoveryResponse() } + ) + ) = DefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory, + coroutineDispatchers = testCoroutineDispatchers() + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt new file mode 100644 index 0000000000..09637fcd21 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt @@ -0,0 +1,143 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushNewGatewayHandlerTest { + @Test + fun `error when fail to retrieve the session`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("Unable to retrieve session") + } + + @Test + fun `error when the session is not using UnifiedPush`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = "other") } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("This session is not using UnifiedPush pusher") + } + + @Test + fun `error when the registration fails`() = runTest { + val aMatrixClient = FakeMatrixClient() + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(IllegalStateException("an error")) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("an error") + } + + @Test + fun `happy path`() = runTest { + val aMatrixClient = FakeMatrixClient() + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> + Result.success(Unit) + } + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = lambda + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result).isEqualTo(Result.success(Unit)) + lambda.assertions() + .isCalledOnce() + .with(value(aMatrixClient), value("aEndpoint"), value("aPushGateway")) + } + + private fun createDefaultUnifiedPushNewGatewayHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService() + ): DefaultUnifiedPushNewGatewayHandler { + return DefaultUnifiedPushNewGatewayHandler( + pusherSubscriber = pusherSubscriber, + userPushStoreFactory = userPushStoreFactory, + pushClientSecret = pushClientSecret, + matrixAuthenticationService = matrixAuthenticationService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000000..dfa03707cc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,139 @@ +/* + * 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 + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultUnregisterUnifiedPushUseCaseTest { + @Test + fun `test un registration successful`() = runTest { + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> Result.success(Unit) } + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = lambda + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aEndpoint"), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no endpoint - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { null }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no gateway - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { null }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error`() = runTest { + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isFailure).isTrue() + } + + private fun createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber() + ): DefaultUnregisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultUnregisterUnifiedPushUseCase( + context = context, + unifiedPushStore = unifiedPushStore, + pusherSubscriber = pusherSubscriber + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..1800903dea --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRegisterUnifiedPushUseCase( + private val result: (Distributor, String) -> Result = { _, _ -> lambdaError() } +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + return result(distributor, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt new file mode 100644 index 0000000000..e0d7808505 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt @@ -0,0 +1,40 @@ +/* + * 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 + +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi + +class FakeUnifiedPushApiFactory( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): UnifiedPushApi { + baseUrlParameter = baseUrl + return FakeUnifiedPushApi(discoveryResponse) + } +} + +class FakeUnifiedPushApi( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApi { + override suspend fun discover(): DiscoveryResponse { + return discoveryResponse() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt new file mode 100644 index 0000000000..0bc52fbae8 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushGatewayResolver( + private val getGatewayResult: (String) -> String = { lambdaError() }, +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { + return getGatewayResult(endpoint) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000000..b8d70baada --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt @@ -0,0 +1,27 @@ +/* + * 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 + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushNewGatewayHandler( + private val handleResult: suspend (String, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { + return handleResult(endpoint, pushGateway, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt new file mode 100644 index 0000000000..aa381d9535 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt @@ -0,0 +1,53 @@ +/* + * 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 + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushStore( + private val getEndpointResult: (String) -> String? = { lambdaError() }, + private val storeUpEndpointResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getPushGatewayResult: (String) -> String? = { lambdaError() }, + private val storePushGatewayResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getDistributorValueResult: (UserId) -> String? = { lambdaError() }, + private val setDistributorValueResult: (UserId, String) -> Unit = { _, _ -> lambdaError() }, +) : UnifiedPushStore { + override fun getEndpoint(clientSecret: String): String? { + return getEndpointResult(clientSecret) + } + + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + storeUpEndpointResult(clientSecret, endpoint) + } + + override fun getPushGateway(clientSecret: String): String? { + return getPushGatewayResult(clientSecret) + } + + override fun storePushGateway(clientSecret: String, gateway: String?) { + storePushGatewayResult(clientSecret, gateway) + } + + override fun getDistributorValue(userId: UserId): String? { + return getDistributorValueResult(userId) + } + + override fun setDistributorValue(userId: UserId, value: String) { + setDistributorValueResult(userId, value) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..9f3293420a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnregisterUnifiedPushUseCase( + private val result: (MatrixClient, String) -> Result = { _, _ -> lambdaError() } +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + return result(matrixClient, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt index bbccc92581..da710037c4 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt @@ -82,9 +82,8 @@ class UnifiedPushParserTest { } companion object { - private val UNIFIED_PUSH_DATA = + val UNIFIED_PUSH_DATA = "{\"notification\":{\"event_id\":\"$AN_EVENT_ID\",\"room_id\":\"$A_ROOM_ID\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" - // TODO Check client secret format? } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt new file mode 100644 index 0000000000..826f08a1b0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -0,0 +1,322 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.troubleshoot.FakeUnifiedPushDistributorProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushProviderTest { + @Test + fun `test index and name`() { + val unifiedPushProvider = createUnifiedPushProvider() + assertThat(unifiedPushProvider.name).isEqualTo(UnifiedPushConfig.NAME) + assertThat(unifiedPushProvider.index).isEqualTo(UnifiedPushConfig.INDEX) + } + + @Test + fun `getDistributors return the available distributors`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("value", "Name")) + assertThat(unifiedPushProvider.isAvailable()).isTrue() + } + + @Test + fun `getDistributors return empty`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).isEmpty() + assertThat(unifiedPushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val setDistributorValueResultLambda = lambdaRecorder { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + setDistributorValueResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value("value")) + } + + @Test + fun `register ko`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val setDistributorValueResultLambda = lambdaRecorder(ensureNeverCalled = true) { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `unregister ko`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `getCurrentDistributor ok`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value2", "Name2"), + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isEqualTo(distributor) + } + + @Test + fun `getCurrentDistributor not know`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { "unknown" } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentDistributor not found`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no session`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider() + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push gateway`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push key`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { "aEndpoint" } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig("aPushGateway", "aEndpoint")) + } + + private fun createUnifiedPushProvider( + unifiedPushDistributorProvider: UnifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + registerUnifiedPushUseCase: RegisterUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase(), + unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + ): UnifiedPushProvider { + return UnifiedPushProvider( + unifiedPushDistributorProvider = unifiedPushDistributorProvider, + registerUnifiedPushUseCase = registerUnifiedPushUseCase, + unRegisterUnifiedPushUseCase = unRegisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + unifiedPushStore = unifiedPushStore, + appNavigationStateService = appNavigationStateService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt new file mode 100644 index 0000000000..e2054caacb --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -0,0 +1,196 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +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 io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorUnifiedPushMessagingReceiverTest { + @Test + fun `onUnregistered does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET) + } + + @Test + fun `onRegistrationFailed does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, A_SECRET) + } + + @Test + fun `onMessage valid invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, UnifiedPushParserTest.UNIFIED_PUSH_DATA.toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isCalledOnce() + .with( + value( + PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = A_SECRET + ) + ) + ) + } + + @Test + fun `onMessage invalid does not invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, "".toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isNeverCalled() + } + + @Test + fun `onNewEndpoint run the expected tasks`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.success(Unit) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("anEndpoint")) + } + + @Test + fun `onNewEndpoint, if registration fails, the endpoint should not be stored`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.failure(AN_EXCEPTION) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isNeverCalled() + } + + private fun TestScope.createVectorUnifiedPushMessagingReceiver( + pushHandler: PushHandler = FakePushHandler(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + unifiedPushGatewayResolver: UnifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver(), + unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), + endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), + ): VectorUnifiedPushMessagingReceiver { + return VectorUnifiedPushMessagingReceiver().apply { + this.pushParser = UnifiedPushParser() + this.pushHandler = pushHandler + this.guardServiceStarter = NoopGuardServiceStarter() + this.unifiedPushStore = unifiedPushStore + this.unifiedPushGatewayResolver = unifiedPushGatewayResolver + this.newGatewayHandler = unifiedPushNewGatewayHandler + this.endpointRegistrationHandler = endpointRegistrationHandler + this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt index 117e8b7457..9f79f7363b 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -81,4 +83,15 @@ class UnifiedPushTestTest { assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) } } + + @Test + fun `test isRelevant`() { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = UnifiedPushConfig.NAME))).isTrue() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "other"))).isFalse() + } } diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 28e53e011c..69f0f21e55 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.services.appnavstate.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.sessionStorage.test) androidTestImplementation(libs.coroutines.test) diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt similarity index 97% rename from libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt index 92ba2bfe1e..414e868cd4 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DataStorePushClientSecretStore.kt @@ -33,7 +33,7 @@ import javax.inject.Inject private val Context.dataStore: DataStore by preferencesDataStore(name = "push_client_secret_store") @ContributesBinding(AppScope::class) -class PushClientSecretStoreDataStore @Inject constructor( +class DataStorePushClientSecretStore @Inject constructor( @ApplicationContext private val context: Context, ) : PushClientSecretStore { override suspend fun storeSecret(userId: SessionId, clientSecret: String) { diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt similarity index 98% rename from libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt index 5f0c83b6d7..eeebd4a999 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecret.kt @@ -29,7 +29,7 @@ import javax.inject.Inject @SingleIn(AppScope::class) @ContributesBinding(AppScope::class, boundType = PushClientSecret::class) -class PushClientSecretImpl @Inject constructor( +class DefaultPushClientSecret @Inject constructor( private val pushClientSecretFactory: PushClientSecretFactory, private val pushClientSecretStore: PushClientSecretStore, private val sessionObserver: SessionObserver, diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt similarity index 92% rename from libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt index 4e6e718a60..9e4b06e918 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretFactory.kt @@ -23,7 +23,7 @@ import java.util.UUID import javax.inject.Inject @ContributesBinding(AppScope::class) -class PushClientSecretFactoryImpl @Inject constructor() : PushClientSecretFactory { +class DefaultPushClientSecretFactory @Inject constructor() : PushClientSecretFactory { override fun create(): String { return UUID.randomUUID().toString() } diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt similarity index 92% rename from libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt rename to libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt index dc0e5b3651..0031c07021 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/DefaultPushClientSecretTest.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.pushstore.impl.clientsecret import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import kotlinx.coroutines.test.runTest import org.junit.Test @@ -27,12 +28,12 @@ private val A_USER_ID_1 = SessionId("@A_USER_ID_1:domain") private const val A_UNKNOWN_SECRET = "A_UNKNOWN_SECRET" -internal class PushClientSecretImplTest { +internal class DefaultPushClientSecretTest { @Test fun test() = runTest { val factory = FakePushClientSecretFactory() val store = InMemoryPushClientSecretStore() - val sut = PushClientSecretImpl(factory, store, NoOpSessionObserver()) + val sut = DefaultPushClientSecret(factory, store, NoOpSessionObserver()) val secret0 = factory.getSecretForUser(0) val secret1 = factory.getSecretForUser(1) diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt similarity index 94% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt index 3428fb5d3e..112a752368 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt @@ -14,14 +14,15 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.pushstore.api.UserPushStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -class FakeUserPushStore : UserPushStore { +class FakeUserPushStore( private var pushProviderName: String? = null +) : UserPushStore { private var currentRegisteredPushKey: String? = null private val notificationEnabledForDevice = MutableStateFlow(true) override suspend fun getPushProviderName(): String? { diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt similarity index 78% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt index 2f4f524cc2..14fd4ce3a6 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt @@ -14,14 +14,16 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStoreFactory -class FakeUserPushStoreFactory : UserPushStoreFactory { +class FakeUserPushStoreFactory( + val userPushStore: (SessionId) -> UserPushStore = { FakeUserPushStore() } +) : UserPushStoreFactory { override fun getOrCreate(userId: SessionId): UserPushStore { - return FakeUserPushStore() + return userPushStore(userId) } } diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt new file mode 100644 index 0000000000..25759ecc45 --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt @@ -0,0 +1,34 @@ +/* + * 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.pushstore.test.userpushstore.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushClientSecret( + private val getSecretForUserResult: (SessionId) -> String = { lambdaError() }, + private val getUserIdFromSecretResult: (String) -> SessionId? = { lambdaError() } +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + return getSecretForUserResult(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return getUserIdFromSecretResult(clientSecret) + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt similarity index 94% rename from libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt index 8c9b577967..632014109e 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.pushstore.impl.clientsecret +package io.element.android.libraries.pushstore.test.userpushstore.clientsecret import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore diff --git a/libraries/qrcode/build.gradle.kts b/libraries/qrcode/build.gradle.kts new file mode 100644 index 0000000000..65aa597bf1 --- /dev/null +++ b/libraries/qrcode/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.qrcode" +} + +dependencies { + implementation(projects.libraries.designsystem) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.androidx.camera.camera2) + implementation(libs.zxing.cpp) +} diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt new file mode 100644 index 0000000000..be83581642 --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt @@ -0,0 +1,50 @@ +/* + * 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.qrcode + +import android.graphics.ImageFormat +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import timber.log.Timber +import zxingcpp.BarcodeReader + +internal class QRCodeAnalyzer( + private val onScanQrCode: (result: ByteArray?) -> Unit +) : ImageAnalysis.Analyzer { + private val reader by lazy { BarcodeReader() } + + override fun analyze(image: ImageProxy) { + if (image.format in SUPPORTED_IMAGE_FORMATS) { + try { + val bytes = reader.read(image).firstNotNullOfOrNull { it.bytes } + bytes?.let { onScanQrCode(it) } + } catch (e: Exception) { + Timber.w(e, "Error decoding QR code") + } finally { + image.close() + } + } + } + + companion object { + private val SUPPORTED_IMAGE_FORMATS = listOf( + ImageFormat.YUV_420_888, + ImageFormat.YUV_422_888, + ImageFormat.YUV_444_888, + ) + } +} diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt new file mode 100644 index 0000000000..428ff0e3d4 --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt @@ -0,0 +1,172 @@ +/* + * 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.qrcode + +import android.content.Context +import android.graphics.Bitmap +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Composable +fun QrCodeCameraView( + onScanQrCode: (ByteArray) -> Unit, + modifier: Modifier = Modifier, + renderPreview: Boolean = true, +) { + if (LocalInspectionMode.current) { + Box( + modifier = modifier + .background(color = ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center, + ) { + Text("CameraView") + } + } else { + val coroutineScope = rememberCoroutineScope() + val localContext = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var cameraProvider by remember { mutableStateOf(null) } + val previewUseCase = remember { Preview.Builder().build() } + var lastFrame by remember { mutableStateOf(null) } + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + LaunchedEffect(Unit) { + cameraProvider = localContext.getCameraProvider() + } + + suspend fun startQRCodeAnalysis(cameraProvider: ProcessCameraProvider, previewView: PreviewView, attempt: Int = 1) { + lastFrame = null + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(previewView.context), + QRCodeAnalyzer { result -> + result?.let { + Timber.d("QR code scanned!") + onScanQrCode(it) + } + } + ) + try { + // Make sure we unbind all use cases before binding them again + cameraProvider.unbindAll() + + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageAnalysis + ) + lastFrame = null + } catch (e: Exception) { + val maxAttempts = 3 + if (attempt > maxAttempts) { + Timber.e(e, "Use case binding failed after $maxAttempts attempts. Giving up.") + } else { + Timber.e(e, "Use case binding failed (attempt #$attempt). Retrying after a delay...") + delay(100) + startQRCodeAnalysis(cameraProvider, previewView, attempt + 1) + } + } + } + + fun stopQRCodeAnalysis(previewView: PreviewView) { + // Stop analyzer + imageAnalysis.clearAnalyzer() + + // Save last frame to display it as the 'frozen' preview + if (lastFrame == null) { + lastFrame = previewView.bitmap + Timber.d("Saving last frame for frozen preview.") + } + + // Unbind preview use case + cameraProvider?.unbindAll() + } + + Box(modifier.clipToBounds()) { + AndroidView( + factory = { context -> + val previewView = PreviewView(context) + previewUseCase.setSurfaceProvider(previewView.surfaceProvider) + previewView.previewStreamState.observe(lifecycleOwner) { state -> + previewView.alpha = if (state == PreviewView.StreamState.STREAMING) 1f else 0f + } + previewView + }, + update = { previewView -> + if (renderPreview) { + cameraProvider?.let { provider -> + coroutineScope.launch { startQRCodeAnalysis(provider, previewView) } + } + } else { + stopQRCodeAnalysis(previewView) + } + }, + onRelease = { + cameraProvider?.unbindAll() + cameraProvider = null + }, + ) + lastFrame?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = null) + } + } + } +} + +@Suppress("BlockingMethodInNonBlockingContext") +private suspend fun Context.getCameraProvider(): ProcessCameraProvider = + suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(this).also { cameraProvider -> + cameraProvider.addListener({ + continuation.resume(cameraProvider.get()) + }, ContextCompat.getMainExecutor(this)) + } + } diff --git a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt index d3f63e366c..bff273aa9f 100644 --- a/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt +++ b/libraries/roomselect/api/src/main/kotlin/io/element/android/libraries/roomselect/api/RoomSelectMode.kt @@ -18,4 +18,5 @@ package io.element.android.libraries.roomselect.api enum class RoomSelectMode { Forward, + Share, } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt index 77eb7b9845..e56d4dce09 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenter.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.roomselect.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -28,17 +29,14 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.theme.components.SearchBarResultState -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.roomselect.api.RoomSelectMode -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList +import kotlinx.collections.immutable.toImmutableList class RoomSelectPresenter @AssistedInject constructor( @Assisted private val mode: RoomSelectMode, - private val client: MatrixClient, + private val dataSource: RoomSelectSearchDataSource, ) : Presenter { @AssistedFactory interface Factory { @@ -48,22 +46,26 @@ class RoomSelectPresenter @AssistedInject constructor( @Composable override fun present(): RoomSelectState { var selectedRooms by remember { mutableStateOf(persistentListOf()) } - var query by remember { mutableStateOf("") } + var searchQuery by remember { mutableStateOf("") } var isSearchActive by remember { mutableStateOf(false) } - var results: SearchBarResultState> by remember { mutableStateOf(SearchBarResultState.Initial()) } - val summaries by client.roomListService.allRooms.summaries.collectAsState(initial = emptyList()) + LaunchedEffect(Unit) { + dataSource.load() + } + + LaunchedEffect(searchQuery) { + dataSource.setSearchQuery(searchQuery) + } - LaunchedEffect(query, summaries) { - val filteredSummaries = summaries.filterIsInstance() - .map { it.details } - .filter { it.name.orEmpty().contains(query, ignoreCase = true) } - .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received - .toPersistentList() - results = if (filteredSummaries.isNotEmpty()) { - SearchBarResultState.Results(filteredSummaries) - } else { - SearchBarResultState.NoResultsFound() + val roomSummaryDetailsList by dataSource.roomSummaries.collectAsState(initial = persistentListOf()) + + val searchResults by remember { + derivedStateOf { + when { + roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList()) + isSearchActive -> SearchBarResultState.NoResultsFound() + else -> SearchBarResultState.Initial() + } } } @@ -80,15 +82,15 @@ class RoomSelectPresenter @AssistedInject constructor( // } } RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() - is RoomSelectEvents.UpdateQuery -> query = event.query + is RoomSelectEvents.UpdateQuery -> searchQuery = event.query RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive } } return RoomSelectState( mode = mode, - resultState = results, - query = query, + resultState = searchResults, + query = searchQuery, isSearchActive = isSearchActive, selectedRooms = selectedRooms, eventSink = { handleEvents(it) } diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt new file mode 100644 index 0000000000..021c597e1e --- /dev/null +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectSearchDataSource.kt @@ -0,0 +1,74 @@ +/* + * 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.roomselect.impl + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.roomlist.RoomList +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails +import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +private const val PAGE_SIZE = 30 + +/** + * DataSource for RoomSummaryDetails that can be filtered by a search query, + * and which only includes rooms the user has joined. + */ +class RoomSelectSearchDataSource @Inject constructor( + roomListService: RoomListService, + coroutineDispatchers: CoroutineDispatchers, +) { + private val roomList = roomListService.createRoomList( + pageSize = PAGE_SIZE, + initialFilter = RoomListFilter.all(), + source = RoomList.Source.All, + ) + + val roomSummaries: Flow> = roomList.filteredSummaries + .map { roomSummaries -> + roomSummaries + .filterIsInstance() + .map { it.details } + .filter { it.currentUserMembership == CurrentUserMembership.JOINED } + .distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received + .toPersistentList() + } + .flowOn(coroutineDispatchers.computation) + + suspend fun load() = coroutineScope { + roomList.loadAllIncrementally(this) + } + + suspend fun setSearchQuery(searchQuery: String) = coroutineScope { + val filter = if (searchQuery.isBlank()) { + RoomListFilter.all() + } else { + RoomListFilter.NormalizedMatchRoomName(searchQuery) + } + roomList.updateFilter(filter) + } +} diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt index 7bfddeb280..2821efd36a 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectStateProvider.kt @@ -31,29 +31,33 @@ open class RoomSelectStateProvider : PreviewParameterProvider { get() = sequenceOf( aRoomSelectState(), aRoomSelectState(query = "Test", isSearchActive = true), - aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())), + aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())), aRoomSelectState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), query = "Test", isSearchActive = true, ), aRoomSelectState( - resultState = SearchBarResultState.Results(aForwardMessagesRoomList()), + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), query = "Test", isSearchActive = true, selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain"))) ), - // Add other states here + aRoomSelectState( + mode = RoomSelectMode.Share, + resultState = SearchBarResultState.Results(aRoomSelectRoomList()), + ), ) } private fun aRoomSelectState( + mode: RoomSelectMode = RoomSelectMode.Forward, resultState: SearchBarResultState> = SearchBarResultState.Initial(), query: String = "", isSearchActive: Boolean = false, selectedRooms: ImmutableList = persistentListOf(), ) = RoomSelectState( - mode = RoomSelectMode.Forward, + mode = mode, resultState = resultState, query = query, isSearchActive = isSearchActive, @@ -61,7 +65,7 @@ private fun aRoomSelectState( eventSink = {} ) -private fun aForwardMessagesRoomList() = persistentListOf( +private fun aRoomSelectRoomList() = persistentListOf( aRoomSummaryDetails(), aRoomSummaryDetails( roomId = RoomId("!room2:domain"), diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 77df71c268..fa8525bdc1 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -82,7 +82,7 @@ fun RoomSelectView( if (isForwarding) return SelectedRooms( selectedRooms = selectedRooms, - onRoomRemoved = ::onRoomRemoved, + onRemoveRoom = ::onRoomRemoved, modifier = Modifier.padding(vertical = 16.dp) ) } @@ -105,6 +105,7 @@ fun RoomSelectView( Text( text = when (state.mode) { RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message) + RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to) }, style = ElementTheme.typography.aliasScreenTitle ) @@ -192,7 +193,7 @@ fun RoomSelectView( @Composable private fun SelectedRooms( selectedRooms: ImmutableList, - onRoomRemoved: (RoomSummaryDetails) -> Unit, + onRemoveRoom: (RoomSummaryDetails) -> Unit, modifier: Modifier = Modifier, ) { LazyRow( @@ -201,7 +202,7 @@ private fun SelectedRooms( horizontalArrangement = Arrangement.spacedBy(32.dp) ) { items(selectedRooms, key = { it.roomId.value }) { roomSummary -> - SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) + SelectedRoom(roomSummary = roomSummary, onRemoveRoom = onRemoveRoom) } } } diff --git a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt similarity index 73% rename from libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt rename to libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt index 868c00d42e..b4117d3d40 100644 --- a/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTests.kt +++ b/libraries/roomselect/impl/src/test/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectPresenterTest.kt @@ -21,24 +21,27 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.matrix.api.roomlist.RoomListFilter +import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomSummary -import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class RoomSelectPresenterTests { +class RoomSelectPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @Test fun `present - initial state`() = runTest { - val presenter = aPresenter() + val presenter = createRoomSelectPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -46,24 +49,18 @@ class RoomSelectPresenterTests { assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.isSearchActive).isFalse() - // Search is run automatically - val searchState = awaitItem() - assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) } } @Test fun `present - toggle search active`() = runTest { - val presenter = aPresenter() + val presenter = createRoomSelectPresenter() moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - skipItems(1) - initialState.eventSink(RoomSelectEvents.ToggleSearchActive) assertThat(awaitItem().isSearchActive).isTrue() - initialState.eventSink(RoomSelectEvents.ToggleSearchActive) assertThat(awaitItem().isSearchActive).isFalse() } @@ -74,43 +71,59 @@ class RoomSelectPresenterTests { val roomListService = FakeRoomListService().apply { postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails()))) } - val client = FakeMatrixClient(roomListService = roomListService) - val presenter = aPresenter(client = client) + val presenter = createRoomSelectPresenter( + roomListService = roomListService + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetails()))) - + initialState.eventSink(RoomSelectEvents.ToggleSearchActive) + skipItems(1) initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) + assertThat( + roomListService.allRooms.currentFilter.value + ).isEqualTo( + RoomListFilter.NormalizedMatchRoomName("string not contained") + ) assertThat(awaitItem().query).isEqualTo("string not contained") + roomListService.postAllRooms( + emptyList() + ) assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) } } @Test fun `present - select and remove a room`() = runTest { - val presenter = aPresenter() + val roomListService = FakeRoomListService().apply { + postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails()))) + } + val presenter = createRoomSelectPresenter( + roomListService = roomListService, + ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - skipItems(1) val summary = aRoomSummaryDetails() - initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary)) assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) - initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) assertThat(awaitItem().selectedRooms).isEmpty() + cancel() } } - private fun aPresenter( + private fun TestScope.createRoomSelectPresenter( mode: RoomSelectMode = RoomSelectMode.Forward, - client: FakeMatrixClient = FakeMatrixClient(), + roomListService: RoomListService = FakeRoomListService(), ) = RoomSelectPresenter( mode = mode, - client = client, + dataSource = RoomSelectSearchDataSource( + roomListService = roomListService, + coroutineDispatchers = testCoroutineDispatchers(), + ), ) } diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt new file mode 100644 index 0000000000..5331d5755b --- /dev/null +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 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.sessionstorage.impl.memory + +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow + +class InMemoryMultiSessionsStore : SessionStore { + private val sessions = mutableListOf() + + override fun isLoggedIn(): Flow = error("Not implemented") + + override fun sessionsFlow(): Flow> = error("Not implemented") + + override suspend fun storeData(sessionData: SessionData) { + sessions.add(sessionData) + } + + override suspend fun updateData(sessionData: SessionData) = error("Not implemented") + + override suspend fun getSession(sessionId: String): SessionData? = error("Not implemented") + + override suspend fun getAllSessions(): List = sessions + + override suspend fun getLatestSession(): SessionData = error("Not implemented") + + override suspend fun removeSession(sessionId: String) = error("Not implemented") +} diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt similarity index 99% rename from libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt rename to libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index 46e90f6d52..8df46f708d 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test -class DatabaseSessionStoreTests { +class DatabaseSessionStoreTest { private lateinit var database: SessionDatabase private lateinit var databaseSessionStore: DatabaseSessionStore diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 3ff2520c35..81a019a43a 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -106,8 +106,8 @@ fun TextComposer( onDeleteVoiceMessage: () -> Unit, onError: (Throwable) -> Unit, onTyping: (Boolean) -> Unit, - onSuggestionReceived: (Suggestion?) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + onReceiveSuggestion: (Suggestion?) -> Unit, + onSelectRichContent: ((Uri) -> Unit)?, modifier: Modifier = Modifier, showTextFormatting: Boolean = false, subcomposing: Boolean = false, @@ -116,15 +116,15 @@ fun TextComposer( is TextEditorState.Markdown -> state.state.text.value() is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown } - val onSendClicked = { + val onSendClick = { onSendMessage() } - val onPlayVoiceMessageClicked = { + val onPlayVoiceMessageClick = { onVoicePlayerEvent(VoiceMessagePlayerEvent.Play) } - val onPauseVoiceMessageClicked = { + val onPauseVoiceMessageClick = { onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause) } @@ -169,7 +169,7 @@ fun TextComposer( resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, onError = onError, onTyping = onTyping, - onRichContentSelected = onRichContentSelected, + onSelectRichContent = onSelectRichContent, ) } } @@ -188,8 +188,9 @@ fun TextComposer( state = state.state, subcomposing = subcomposing, onTyping = onTyping, - onSuggestionReceived = onSuggestionReceived, + onReceiveSuggestion = onReceiveSuggestion, richTextEditorStyle = style, + onSelectRichContent = onSelectRichContent, ) } } @@ -200,7 +201,7 @@ fun TextComposer( val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, - onClick = onSendClicked, + onClick = onSendClick, composerMode = composerMode, ) } @@ -250,8 +251,8 @@ fun TextComposer( waveform = voiceMessageState.waveform, playbackProgress = voiceMessageState.playbackProgress, time = voiceMessageState.time, - onPlayClick = onPlayVoiceMessageClicked, - onPauseClick = onPauseVoiceMessageClicked, + onPlayClick = onPlayVoiceMessageClick, + onPauseClick = onPauseVoiceMessageClick, onSeek = onSeekVoiceMessage, ) is VoiceMessageState.Recording -> @@ -301,15 +302,15 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } } - val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived) + val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) if (state is TextEditorState.Rich) { val menuAction = state.richTextEditorState.menuAction LaunchedEffect(menuAction) { if (menuAction is MenuAction.Suggestion) { val suggestion = Suggestion(menuAction.suggestionPattern) - latestOnSuggestionReceived(suggestion) + latestOnReceiveSuggestion(suggestion) } else { - latestOnSuggestionReceived(null) + latestOnReceiveSuggestion(null) } } } @@ -480,7 +481,7 @@ private fun TextInput( resolveMentionDisplay: (text: String, url: String) -> TextDisplay, onError: (Throwable) -> Unit, onTyping: (Boolean) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + onSelectRichContent: ((Uri) -> Unit)?, ) { TextInputBox( composerMode = composerMode, @@ -501,7 +502,7 @@ private fun TextInput( resolveMentionDisplay = resolveMentionDisplay, resolveRoomMentionDisplay = resolveRoomMentionDisplay, onError = onError, - onRichContentSelected = onRichContentSelected, + onRichContentSelected = onSelectRichContent, onTyping = onTyping, ) } @@ -841,8 +842,8 @@ private fun ATextComposer( onDeleteVoiceMessage = {}, onError = {}, onTyping = {}, - onSuggestionReceived = {}, - onRichContentSelected = null, + onReceiveSuggestion = {}, + onSelectRichContent = null, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt index b0078a40eb..3f026ed4fa 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt @@ -104,14 +104,14 @@ private fun CreateLinkWithTextDialog( TextFieldListItem( placeholder = stringResource(id = CommonStrings.common_text), text = linkText, - onTextChanged = { linkText = it }, + onTextChange = { linkText = it }, ) } item { TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } } @@ -142,7 +142,7 @@ private fun CreateLinkWithoutTextDialog( TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } } @@ -167,7 +167,7 @@ private fun EditLinkDialog( onDismissRequest() } - fun onRemoveClicked() { + fun onRemoveClick() { onRemoveLinkRequest() onDismissRequest() } @@ -182,7 +182,7 @@ private fun EditLinkDialog( TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } item { @@ -193,7 +193,7 @@ private fun EditLinkDialog( color = ElementTheme.colors.textCriticalPrimary ) }, - onClick = ::onRemoveClicked, + onClick = ::onRemoveClick, ) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 451eaca53f..8016fa637e 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -16,9 +16,13 @@ package io.element.android.libraries.textcomposer.components.markdown +import android.content.ClipData import android.graphics.Color +import android.net.Uri import android.text.Editable +import android.text.InputType import android.text.Selection +import android.view.View import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -26,6 +30,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.text.getSpans +import androidx.core.view.ContentInfoCompat +import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat import androidx.core.view.setPadding import androidx.core.widget.addTextChangedListener import io.element.android.libraries.designsystem.preview.ElementPreview @@ -45,10 +52,35 @@ fun MarkdownTextInput( state: MarkdownTextEditorState, subcomposing: Boolean, onTyping: (Boolean) -> Unit, - onSuggestionReceived: (Suggestion?) -> Unit, + onReceiveSuggestion: (Suggestion?) -> Unit, richTextEditorStyle: RichTextEditorStyle, + onSelectRichContent: ((Uri) -> Unit)?, ) { val canUpdateState = !subcomposing + + // Copied from io.element.android.wysiwyg.internal.utils.UriContentListener + class ReceiveUriContentListener( + private val onContent: (uri: Uri) -> Unit, + ) : OnReceiveContentListener { + override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { + val split = payload.partition { item -> item.uri != null } + val uriContent = split.first + val remaining = split.second + + if (uriContent != null) { + val clip: ClipData = uriContent.clip + for (i in 0 until clip.itemCount) { + val uri = clip.getItemAt(i).uri + // ... app-specific logic to handle the URI ... + onContent(uri) + } + } + // Return anything that we didn't handle ourselves. This preserves the default platform + // behavior for text and anything else for which we are not implementing custom handling. + return remaining + } + } + AndroidView( modifier = Modifier .padding(top = 6.dp, bottom = 6.dp) @@ -58,9 +90,15 @@ fun MarkdownTextInput( tag = TestTags.plainTextEditor.value // Needed for UI tests setPadding(0) setBackgroundColor(Color.TRANSPARENT) - setText(state.text.value()) + val text = state.text.value() + setText(text) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT if (canUpdateState) { - setSelection(state.selection.first, state.selection.last) + val textRange = 0..text.length + setSelection(state.selection.first.coerceIn(textRange), state.selection.last.coerceIn(textRange)) setOnFocusChangeListener { _, hasFocus -> state.hasFocus = hasFocus } @@ -70,12 +108,19 @@ fun MarkdownTextInput( state.lineCount = lineCount state.currentMentionSuggestion = editable?.checkSuggestionNeeded() - onSuggestionReceived(state.currentMentionSuggestion) + onReceiveSuggestion(state.currentMentionSuggestion) } onSelectionChangeListener = { selStart, selEnd -> state.selection = selStart..selEnd state.currentMentionSuggestion = editableText.checkSuggestionNeeded() - onSuggestionReceived(state.currentMentionSuggestion) + onReceiveSuggestion(state.currentMentionSuggestion) + } + if (onSelectRichContent != null) { + ViewCompat.setOnReceiveContentListener( + this, + arrayOf("image/*"), + ReceiveUriContentListener { onSelectRichContent(it) } + ) } state.requestFocusAction = { this.requestFocus() } } @@ -145,8 +190,9 @@ internal fun MarkdownTextInputPreview() { state = aMarkdownTextEditorState(), subcomposing = false, onTyping = {}, - onSuggestionReceived = {}, + onReceiveSuggestion = {}, richTextEditorStyle = style, + onSelectRichContent = {}, ) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index 565d047fe6..e02124b638 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -165,7 +165,7 @@ internal fun MentionSpanPreview() { eventId = null, viaParameters = persistentListOf(), ) - else -> TODO() + else -> throw AssertionError("Unexpected value $uriString") } } }, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt index 7cda8f421c..273aefa57b 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -16,14 +16,19 @@ package io.element.android.libraries.textcomposer.model +import android.os.Parcelable import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.Spanned +import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.text.getSpans import io.element.android.libraries.matrix.api.core.UserId @@ -33,6 +38,7 @@ import io.element.android.libraries.textcomposer.components.markdown.StableCharS import io.element.android.libraries.textcomposer.mentions.MentionSpan import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import kotlinx.parcelize.Parcelize @Stable class MarkdownTextEditorState( @@ -119,4 +125,39 @@ class MarkdownTextEditorState( } } } + + @Parcelize + data class SavedState( + val text: CharSequence, + val selectionStart: Int, + val selectionEnd: Int, + ) : Parcelable +} + +object MarkdownTextEditorStateSaver : Saver { + override fun restore(value: MarkdownTextEditorState.SavedState): MarkdownTextEditorState { + return MarkdownTextEditorState( + initialText = "", + initialFocus = false, + ).apply { + text.update(value.text, true) + selection = value.selectionStart..value.selectionEnd + } + } + + override fun SaverScope.save(value: MarkdownTextEditorState): MarkdownTextEditorState.SavedState { + return MarkdownTextEditorState.SavedState( + text = value.text.value(), + selectionStart = value.selection.first, + selectionEnd = value.selection.last, + ) + } +} + +@Composable +fun rememberMarkdownTextEditorState( + initialText: String? = null, + initialFocus: Boolean = false, +): MarkdownTextEditorState { + return rememberSaveable(saver = MarkdownTextEditorStateSaver) { MarkdownTextEditorState(initialText, initialFocus) } } diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index 8967d4cf6a..dc2d197db8 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -189,8 +189,9 @@ class MarkdownTextInputTest { state = state, subcomposing = subcomposing, onTyping = onTyping, - onSuggestionReceived = onSuggestionReceived, + onReceiveSuggestion = onSuggestionReceived, richTextEditorStyle = style, + onSelectRichContent = null, ) } } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt index 96404d6d8c..351baff498 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt @@ -49,7 +49,7 @@ class TroubleshootNotificationsNode @AssistedInject constructor( val state = presenter.present() TroubleshootNotificationsView( state = state, - onBackPressed = ::onDone, + onBackClick = ::onDone, modifier = modifier, ) } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt index 8b2ba843be..26232f8d69 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt @@ -44,7 +44,7 @@ import io.element.android.libraries.troubleshoot.api.test.NotificationTroublesho @Composable fun TroubleshootNotificationsView( state: TroubleshootNotificationsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -60,7 +60,7 @@ fun TroubleshootNotificationsView( PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = R.string.troubleshoot_notifications_screen_title), ) { TroubleshootNotificationsContent(state) @@ -70,7 +70,7 @@ fun TroubleshootNotificationsView( @Composable private fun TroubleshootTestView( testState: NotificationTroubleshootTestState, - onQuickFixClicked: () -> Unit, + onQuickFixClick: () -> Unit, ) { if ((testState.status as? Status.Idle)?.visible == false) return ListItem( @@ -119,7 +119,7 @@ private fun TroubleshootTestView( trailingContent = ListItemContent.Custom { Button( text = stringResource(id = R.string.troubleshoot_notifications_screen_quick_fix_action), - onClick = onQuickFixClicked + onClick = onQuickFixClick ) } ) @@ -135,7 +135,7 @@ private fun TroubleshootNotificationsContent(state: TroubleshootNotificationsSta is AsyncAction.Failure -> { TestSuiteView( testSuiteState = state.testSuiteState, - onQuickFixClicked = { + onQuickFixClick = { state.eventSink(TroubleshootNotificationsEvents.QuickFix(it)) } ) @@ -199,13 +199,13 @@ private fun RunTestButton(state: TroubleshootNotificationsState) { @Composable private fun TestSuiteView( testSuiteState: TroubleshootTestSuiteState, - onQuickFixClicked: (Int) -> Unit, + onQuickFixClick: (Int) -> Unit, ) { testSuiteState.tests.forEachIndexed { index, testState -> TroubleshootTestView( testState = testState, - onQuickFixClicked = { - onQuickFixClicked(index) + onQuickFixClick = { + onQuickFixClick(index) }, ) } @@ -218,6 +218,6 @@ internal fun TroubleshootNotificationsViewPreview( ) = ElementPreview { TroubleshootNotificationsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTests.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt similarity index 99% rename from libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTests.kt rename to libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt index 25082b63c4..30f3d6c614 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTests.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.runTest import org.junit.Test -class TroubleshootNotificationsPresenterTests { +class TroubleshootNotificationsPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createTroubleshootNotificationsPresenter() diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt index 5acd0e5635..683fea637d 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt @@ -45,7 +45,7 @@ class TroubleshootNotificationsViewTest { state = aTroubleshootNotificationsState( eventSink = eventsRecorder ), - onBackPressed = it, + onBackClick = it, ) rule.pressBack() } @@ -112,12 +112,12 @@ class TroubleshootNotificationsViewTest { private fun AndroidComposeTestRule.setTroubleshootNotificationsView( state: TroubleshootNotificationsState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { TroubleshootNotificationsView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml index f5680c2b7f..d5fe6ede80 100644 --- a/libraries/ui-strings/src/main/res/values-be/translations.xml +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -36,7 +36,7 @@ "Прыняць" "Дадаць у хроніку" "Назад" - "Выклік" + "Званок" "Скасаваць" "Выбраць фота" "Ачысціць" @@ -63,7 +63,7 @@ "Пераслаць" "Вярнуцца" "Запрасіць" - "Запрасіць карыстальникаў" + "Запрасіць карыстальнікаў" "Запрасіць карыстальнікаў у %1$s" "Запрасіць карыстальнікаў у %1$s" "Запрашэнні" @@ -117,11 +117,11 @@ "Пашыраныя налады" "Аналітыка" "Знешні выгляд" - "Аўдыё" + "Аўдыя" "Заблакіраваныя карыстальнікі" "Бурбалкі" - "Ідзе выклік (не падтрымліваецца)" - "Выклік пачаўся" + "Ідзе званок (не падтрымліваецца)" + "Званок пачаўся" "Рэзервовае капіраванне чатаў" "Аўтарскае права" "Стварэнне пакоя…" @@ -144,7 +144,7 @@ "Файл захаваны ў папку Спампоўкі" "Перасылка паведамлення" "GIF" - "Выява" + "Відарыс" "У адказ на %1$s" "Усталяваць APK" "Гэты Matrix ID не знойдзены, таму запрашэнне можа быць не атрымана." @@ -154,7 +154,7 @@ "Загрузка…" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Паведамленне" diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 52b673e535..2a344bff38 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -121,6 +121,7 @@ "Blokovaní uživatelé" "Bubliny" "Probíhá hovor (nepodporováno)" + "Hovor zahájen" "Záloha chatu" "Autorská práva" "Vytváření místnosti…" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 484d4835ae..d4c1b3126c 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -34,8 +34,9 @@ "Accepter" "Ajouter à la discussion" "Retour" + "Appel" "Annuler" - "Choisissez une photo" + "Choisir une photo" "Effacer" "Fermer" "Terminer la vérification" @@ -72,6 +73,7 @@ "Voir plus" "Gérer le compte" "Gérez les sessions" + "Message" "Suivant" "Non" "Pas maintenant" @@ -117,6 +119,7 @@ "Utilisateurs bloqués" "Bulles" "Appel en cours (non supporté)" + "Appel démarré" "Sauvegarde des discussions" "Droits d’auteur" "Création du salon…" diff --git a/libraries/ui-strings/src/main/res/values-pt/translations.xml b/libraries/ui-strings/src/main/res/values-pt/translations.xml index 072602a5da..f1ee60abe5 100644 --- a/libraries/ui-strings/src/main/res/values-pt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt/translations.xml @@ -119,6 +119,7 @@ "Utilizadores bloqueados" "Bolhas" "Chamada em curso (não suportada)" + "Chamada iniciada" "Cópia de segurança das conversas" "Direitos de autor" "A criar sala…" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 1932b987ae..5544635732 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -75,6 +75,7 @@ "Загрузить еще" "Настройки аккаунта" "Управление устройствами" + "Сообщение" "Далее" "Нет" "Не сейчас" @@ -120,6 +121,7 @@ "Заблокированные пользователи" "Пузыри" "Выполняется звонок (не поддерживается)" + "Звонок начат" "Резервная копия чатов" "Авторское право" "Создание комнаты…" diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml index c311a0fc2e..41b5d717e4 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -111,6 +111,7 @@ "Analysdata" "Utseende" "Ljud" + "Blockerade användare" "Bubblor" "Chattsäkerhetskopia" "Upphovsrätt" @@ -127,7 +128,9 @@ "Ange din PIN-kod" "Fel" "Alla" + "Misslyckades" "Favorit" + "Favoriter" "Fil" "Fil sparad i Download" "Vidarebefordra meddelande" @@ -152,6 +155,7 @@ "Tysta" "Inga resultat" "Frånkopplad" + "eller" "Lösenord" "Personer" "Permalänk" @@ -177,6 +181,7 @@ "Rum" "Rumsnamn" "t.ex. ditt projektnamn" + "Sparar" "Skärmlås" "Sök efter någon" "Sökresultat" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 8d46b6e5b1..a106be8b5a 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -197,6 +197,7 @@ "Search results" "Security" "Seen by" + "Send to" "Sending…" "Sending failed" "Sent" diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt similarity index 99% rename from libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt rename to libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt index 3c4d7dd15f..9ebeda3460 100644 --- a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorder.kt @@ -51,7 +51,7 @@ import kotlin.time.TimeSource @SingleIn(RoomScope::class) @ContributesBinding(RoomScope::class) -class VoiceRecorderImpl @Inject constructor( +class DefaultVoiceRecorder @Inject constructor( private val dispatchers: CoroutineDispatchers, private val timeSource: TimeSource, private val audioReaderFactory: AudioReader.Factory, diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt similarity index 92% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt index 9bb1ace7b1..be12aa04b1 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/DefaultVoiceRecorderTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory +import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory import io.element.android.libraries.voicerecorder.test.FakeEncoder import io.element.android.libraries.voicerecorder.test.FakeFileSystem import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager @@ -44,13 +44,13 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TestTimeSource -class VoiceRecorderImplTest { +class DefaultVoiceRecorderTest { private val fakeFileSystem = FakeFileSystem() private val timeSource = TestTimeSource() @Test fun `it emits the initial state`() = runTest { - val voiceRecorder = createVoiceRecorder() + val voiceRecorder = createDefaultVoiceRecorder() voiceRecorder.state.test { assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) } @@ -58,7 +58,7 @@ class VoiceRecorderImplTest { @Test fun `when recording, it emits the recording state`() = runTest { - val voiceRecorder = createVoiceRecorder() + val voiceRecorder = createDefaultVoiceRecorder() voiceRecorder.state.test { assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) @@ -73,7 +73,7 @@ class VoiceRecorderImplTest { @Test fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest { - val voiceRecorder = createVoiceRecorder() + val voiceRecorder = createDefaultVoiceRecorder() voiceRecorder.state.test { assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) @@ -96,7 +96,7 @@ class VoiceRecorderImplTest { @Test fun `when stopped, it provides a file and duration`() = runTest { - val voiceRecorder = createVoiceRecorder() + val voiceRecorder = createDefaultVoiceRecorder() voiceRecorder.state.test { assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) @@ -119,7 +119,7 @@ class VoiceRecorderImplTest { @Test fun `when cancelled, it deletes the file`() = runTest { - val voiceRecorder = createVoiceRecorder() + val voiceRecorder = createDefaultVoiceRecorder() voiceRecorder.state.test { assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) @@ -131,12 +131,12 @@ class VoiceRecorderImplTest { } } - private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl { + private fun TestScope.createDefaultVoiceRecorder(): DefaultVoiceRecorder { val fileConfig = VoiceRecorderModule.provideVoiceFileConfig() - return VoiceRecorderImpl( + return DefaultVoiceRecorder( dispatchers = testCoroutineDispatchers(), timeSource = timeSource, - audioReaderFactory = FakeAudioRecorderFactory( + audioReaderFactory = FakeAudioReaderFactory( audio = AUDIO, ), encoder = FakeEncoder(fakeFileSystem), diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt similarity index 97% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt index 02d8b4742c..657b8d6ee9 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.AudioReader -class FakeAudioRecorderFactory( +class FakeAudioReaderFactory( private val audio: List