diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b8833efbb..7a90fc62b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,7 +33,8 @@ plugins { id("com.google.firebase.appdistribution") version "4.0.0" id("org.jetbrains.kotlinx.knit") version "0.4.0" id("kotlin-parcelize") - id("com.google.gms.google-services") + // To be able to update the firebase.xml files, uncomment and build the project + // id("com.google.gms.google-services") } android { @@ -205,6 +206,7 @@ dependencies { allLibrariesImpl() allServicesImpl() allFeaturesImpl(rootDir) + implementation(projects.libraries.deeplink) implementation(projects.tests.uitests) implementation(projects.anvilannotations) implementation(projects.appnav) @@ -225,9 +227,6 @@ dependencies { implementation(platform(libs.network.okhttp.bom)) implementation("com.squareup.okhttp3:logging-interceptor") - implementation(platform(libs.google.firebase.bom)) - implementation("com.google.firebase:firebase-messaging-ktx") - implementation(libs.dagger) kapt(libs.dagger.compiler) diff --git a/app/src/debug/google-services.json b/app/src/debug/google-services.json deleted file mode 100644 index d9aa72f7ba..0000000000 --- a/app/src/debug/google-services.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "project_info": { - "project_number": "912726360885", - "firebase_url": "https://vector-alpha.firebaseio.com", - "project_id": "vector-alpha", - "storage_bucket": "vector-alpha.appspot.com" - }, - - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:912726360885:android:def0a4e454042e9b00427c", - "android_client_info": { - "package_name": "io.element.android.x.debug" - } - }, - "oauth_client": [ - { - "client_id": "912726360885-hvgoj23p6plt7hikhtdrakihojghaftv.apps.googleusercontent.com", - "client_type": 1, - "android_info": { - "package_name": "io.element.android.x.debug", - "certificate_hash": "41bd63b3b612a15d9ba36a5245c393f2a9b992d1" - } - }, - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ] - } - } - } - ], - "configuration_version": "1" -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 828788ed80..342e05532c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,14 @@ + + { + override fun init(node: MainNode) { + mainNode = node + mainNode.handleIntent(intent) + } + } + ) + ) } } } @@ -63,6 +79,8 @@ class MainActivity : NodeComponentActivity() { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) Timber.w("onNewIntent") + intent ?: return + mainNode.handleIntent(intent) } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/kotlin/io/element/android/x/MainNode.kt b/app/src/main/kotlin/io/element/android/x/MainNode.kt index 6b7dee92b8..fb551f326d 100644 --- a/app/src/main/kotlin/io/element/android/x/MainNode.kt +++ b/app/src/main/kotlin/io/element/android/x/MainNode.kt @@ -16,14 +16,17 @@ package io.element.android.x +import android.content.Intent import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.lifecycleScope 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 io.element.android.appnav.LoggedInFlowNode import io.element.android.appnav.RoomFlowNode import io.element.android.appnav.RootFlowNode @@ -35,11 +38,13 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.x.di.MainDaggerComponentsOwner import io.element.android.x.di.RoomComponent import io.element.android.x.di.SessionComponent +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize class MainNode( buildContext: BuildContext, private val mainDaggerComponentOwner: MainDaggerComponentsOwner, + plugins: List, ) : ParentNode( navModel = PermanentNavModel( @@ -47,6 +52,7 @@ class MainNode( savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, + plugins = plugins, ), DaggerComponentOwner by mainDaggerComponentOwner { @@ -73,7 +79,13 @@ class MainNode( } override fun resolve(navTarget: RootNavTarget, buildContext: BuildContext): Node { - return createNode(buildContext, plugins = listOf(loggedInFlowNodeCallback, roomFlowNodeCallback)) + return createNode( + context = buildContext, + plugins = listOf( + loggedInFlowNodeCallback, + roomFlowNodeCallback, + ) + ) } @Composable @@ -81,6 +93,12 @@ class MainNode( Children(navModel = navModel) } + fun handleIntent(intent: Intent) { + lifecycleScope.launch { + waitForChildAttached().handleIntent(intent) + } + } + @Parcelize object RootNavTarget : Parcelable } diff --git a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt index b3c7aa98e0..e777b08906 100644 --- a/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt +++ b/app/src/main/kotlin/io/element/android/x/intent/IntentProviderImpl.kt @@ -18,7 +18,9 @@ package io.element.android.x.intent import android.content.Context import android.content.Intent +import androidx.core.net.toUri import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.deeplink.DeepLinkCreator import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.core.RoomId @@ -28,17 +30,19 @@ import io.element.android.libraries.push.impl.intent.IntentProvider import io.element.android.x.MainActivity import javax.inject.Inject -// TODO EAx change to deep-link. @ContributesBinding(AppScope::class) class IntentProviderImpl @Inject constructor( @ApplicationContext private val context: Context, + private val deepLinkCreator: DeepLinkCreator, ) : IntentProvider { - override fun getMainIntent(): Intent { - return Intent(context, MainActivity::class.java) - } - - override fun getIntent(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): Intent { - // TODO Handle deeplink or pass parameters - return Intent(context, MainActivity::class.java) + override fun getViewIntent( + sessionId: SessionId, + roomId: RoomId?, + threadId: ThreadId?, + ): Intent { + return Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = deepLinkCreator.create(sessionId, roomId, threadId).toUri() + } } } diff --git a/app/src/nightly/google-services.json b/app/src/nightly/google-services.json deleted file mode 100644 index 31b022b3f2..0000000000 --- a/app/src/nightly/google-services.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "project_info": { - "project_number": "912726360885", - "firebase_url": "https://vector-alpha.firebaseio.com", - "project_id": "vector-alpha", - "storage_bucket": "vector-alpha.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:912726360885:android:e17435e0beb0303000427c", - "android_client_info": { - "package_name": "io.element.android.x.nightly" - } - }, - "oauth_client": [ - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ] - } - } - } - ], - "configuration_version": "1" -} diff --git a/app/src/release/google-services.json b/app/src/release/google-services.json deleted file mode 100644 index 16fd1e855c..0000000000 --- a/app/src/release/google-services.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "project_info": { - "project_number": "912726360885", - "firebase_url": "https://vector-alpha.firebaseio.com", - "project_id": "vector-alpha", - "storage_bucket": "vector-alpha.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:912726360885:android:d097de99a4c23d2700427c", - "android_client_info": { - "package_name": "io.element.android.x" - } - }, - "oauth_client": [ - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com", - "client_type": 3 - } - ] - } - } - } - ], - "configuration_version": "1" -} diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index ae05e104b6..71c382a6b2 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -43,8 +43,10 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) + implementation(projects.libraries.deeplink) implementation(projects.libraries.matrix.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) implementation(projects.libraries.designsystem) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) 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 9f3a211228..f3eac99a02 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -33,6 +33,7 @@ import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.replace +import com.bumble.appyx.navmodel.backstack.operation.singleTop import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode @@ -214,6 +215,19 @@ class LoggedInFlowNode @AssistedInject constructor( } } + suspend fun attachRoot(): Node { + return attachChild { + backstack.singleTop(NavTarget.RoomList) + } + } + + suspend fun attachRoom(roomId: RoomId): RoomFlowNode { + return attachChild { + backstack.singleTop(NavTarget.RoomList) + backstack.push(NavTarget.Room(roomId)) + } + } + @Composable override fun View(modifier: Modifier) { Box(modifier = modifier) { 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 8251584308..519c4e734b 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -17,6 +17,7 @@ package io.element.android.appnav import android.app.Activity +import android.content.Intent import android.os.Parcelable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -45,6 +46,8 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.deeplink.DeeplinkData +import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService @@ -65,6 +68,7 @@ class RootFlowNode @AssistedInject constructor( private val matrixClientsHolder: MatrixClientsHolder, private val presenter: RootPresenter, private val bugReportEntryPoint: BugReportEntryPoint, + private val deeplinkParser: DeeplinkParser, ) : BackstackNode( backstack = BackStack( @@ -207,4 +211,30 @@ class RootFlowNode @AssistedInject constructor( CircularProgressIndicator() } } + + suspend fun handleIntent(intent: Intent) { + deeplinkParser.getFromIntent(intent) + ?.let { navigateTo(it) } + } + + private suspend fun navigateTo(deeplinkData: DeeplinkData) { + Timber.d("Navigating to $deeplinkData") + attachSession(deeplinkData.sessionId) + .apply { + val roomId = deeplinkData.roomId + if (roomId == null) { + // In case room is not provided, ensure the app navigate back to the room list + attachRoot() + } else { + attachRoom(roomId) + // TODO .attachThread(deeplinkData.threadId) + } + } + } + + private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { + return attachChild { + backstack.newRoot(NavTarget.LoggedInFlow(sessionId)) + } + } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index b628f19bd1..50bc09d0a0 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -46,7 +46,10 @@ class LoggedInPresenter @Inject constructor( override fun present(): LoggedInState { LaunchedEffect(Unit) { // Ensure pusher is registered - pushService.registerFirebasePusher(matrixClient) + // TODO Manually select push provider for now + val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect + val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, pushProvider, distributor) } val permissionsState = postNotificationPermissionsPresenter.present() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt index 5db19ccae7..3f415c3dc1 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt @@ -38,7 +38,7 @@ fun LoggedInView( state = state.permissionsState, modifier = modifier, openSystemSettings = { - activity?.let { openAppSettingsPage(it, "") } + activity?.let { openAppSettingsPage(it) } } ) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt index 64767eaafc..71d303150b 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt @@ -27,6 +27,8 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PushProvider import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test @@ -55,7 +57,11 @@ class LoggedInPresenterTest { override fun notificationStyleChanged() { } - override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { + override fun getAvailablePushProviders(): List { + return emptyList() + } + + override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { } override suspend fun testPush() { diff --git a/build.gradle.kts b/build.gradle.kts index d7e3a12fcc..66c842630c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -230,7 +230,6 @@ koverMerged { target = kotlinx.kover.api.VerificationTarget.CLASS overrideClassFilter { includes += "*Presenter" - excludes += "*TemplatePresenter" excludes += "*Fake*Presenter" excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" } diff --git a/changelog.d/110.feature b/changelog.d/110.feature new file mode 100644 index 0000000000..d28935b65f --- /dev/null +++ b/changelog.d/110.feature @@ -0,0 +1 @@ +[Create and join rooms] Create a room screen (UI) diff --git a/changelog.d/300.feature b/changelog.d/300.feature new file mode 100644 index 0000000000..fba672e002 --- /dev/null +++ b/changelog.d/300.feature @@ -0,0 +1 @@ +Implement room member details screen diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md index 5b43b00922..575c5ca3e8 100644 --- a/docs/_developer_onboarding.md +++ b/docs/_developer_onboarding.md @@ -136,7 +136,7 @@ git clone git@github.com:matrix-org/matrix-rust-sdk.git git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git ``` -Then you can launch the build script with the following params: +Then you can launch the build script from the matrix-rust-components-kotlin repository with the following params: - `-p` Local path to the rust-sdk repository - `-o` Optional output path with the expected name of the aar file. By default the aar will be located in the corresponding build/outputs/aar directory. @@ -150,12 +150,12 @@ So for example to build the sdk against aarch64-linux-android target and copy th ./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar ``` -Finally let the `matrix/impl` module use this aar by switching those lines in the gradle file : +Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`: ```groovy dependencies { - api(projects.libraries.rustsdk) // <- comment this line - // api(libs.matrix.sdk) // <- uncomment this line + api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line. + //implementation(libs.matrix.sdk) // <- use the released version. Comment this line. } ``` diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 6f2544822c..edb35428a0 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -49,12 +49,14 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.features.userlist.api) api(projects.features.createroom.api) + implementation(libs.coil.compose) // FIXME temp 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(projects.libraries.matrix.test) testImplementation(projects.features.userlist.impl) testImplementation(projects.features.userlist.test) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt index 6bbdfb5e93..87f465ecbb 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/AllMatrixUsersDataSource.kt @@ -16,13 +16,13 @@ package io.element.android.features.createroom.impl -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser import javax.inject.Inject // TODO this is empty as we currently don't have an endpoint to perform user search -class AllMatrixUsersDataSource @Inject constructor() : MatrixUserDataSource { +class AllMatrixUsersDataSource @Inject constructor() : UserListDataSource { override suspend fun search(query: String): List { return emptyList() } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt new file mode 100644 index 0000000000..a01f0747f5 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/ConfigureRoomFlowNode.kt @@ -0,0 +1,95 @@ +/* + * 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.features.createroom.impl + +import android.os.Parcelable +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.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.impl.addpeople.AddPeopleNode +import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode +import io.element.android.features.createroom.impl.di.CreateRoomComponent +import io.element.android.libraries.architecture.BackstackNode +import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler +import io.element.android.libraries.architecture.bindings +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.DaggerComponentOwner +import io.element.android.libraries.di.SessionScope +import kotlinx.parcelize.Parcelize + +@ContributesNode(SessionScope::class) +class ConfigureRoomFlowNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : DaggerComponentOwner, + BackstackNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins + ) { + + private val component by lazy { + parent!!.bindings().createRoomComponentBuilder().build() + } + + override val daggerComponent: Any + get() = component + + sealed interface NavTarget : Parcelable { + @Parcelize + object Root : NavTarget + + @Parcelize + object ConfigureRoom : NavTarget + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : AddPeopleNode.Callback { + override fun onContinue() { + backstack.push(NavTarget.ConfigureRoom) + } + } + createNode(context = buildContext, plugins = listOf(callback)) + } + NavTarget.ConfigureRoom -> { + createNode(context = buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler() + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt new file mode 100644 index 0000000000..0aedf12d0f --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt @@ -0,0 +1,30 @@ +/* + * 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.features.createroom.impl + +import io.element.android.features.createroom.impl.configureroom.RoomPrivacy +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class CreateRoomConfig( + val roomName: String? = null, + val topic: String? = null, + val avatarUrl: String? = null, + val invites: ImmutableList = persistentListOf(), + val privacy: RoomPrivacy? = null, +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt new file mode 100644 index 0000000000..e55fca755d --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt @@ -0,0 +1,58 @@ +/* + * 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.features.createroom.impl + +import io.element.android.features.createroom.impl.configureroom.RoomPrivacy +import io.element.android.features.createroom.impl.di.CreateRoomScope +import io.element.android.features.userlist.api.UserListDataStore +import io.element.android.libraries.di.SingleIn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +@SingleIn(CreateRoomScope::class) +class CreateRoomDataStore @Inject constructor( + val selectedUserListDataStore: UserListDataStore, +) { + + private val createRoomConfigFlow: MutableStateFlow = MutableStateFlow(CreateRoomConfig()) + + fun getCreateRoomConfig(): Flow = combine( + selectedUserListDataStore.selectedUsers(), + createRoomConfigFlow, + ) { selectedUsers, config -> + config.copy(invites = selectedUsers.toImmutableList()) + } + + fun setRoomName(roomName: String?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(roomName = roomName?.takeIf { it.isNotEmpty() })) + } + + fun setTopic(topic: String?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) + } + + fun setAvatarUrl(avatarUrl: String?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUrl = avatarUrl)) + } + + fun setPrivacy(privacy: RoomPrivacy?) { + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt index 22851904c4..6b9edc68d2 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -30,7 +30,6 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.createroom.api.CreateRoomEntryPoint -import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.root.CreateRoomRootNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -72,9 +71,11 @@ class CreateRoomFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenRoom(roomId) } } } - createNode(buildContext, plugins = listOf(callback)) + createNode(context = buildContext, plugins = listOf(callback)) + } + NavTarget.NewRoom -> { + createNode(context = buildContext, plugins = emptyList()) } - NavTarget.NewRoom -> createNode(buildContext) } } 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 5393075d18..1b4bd9ac8d 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 @@ -21,26 +21,35 @@ 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.libraries.di.SessionScope +import io.element.android.features.createroom.impl.di.CreateRoomScope -@ContributesNode(SessionScope::class) +@ContributesNode(CreateRoomScope::class) class AddPeopleNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: AddPeoplePresenter, ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onContinue() + } + + private fun onContinue() { + plugins().forEach { it.onContinue() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() AddPeopleView( state = state, modifier = modifier, - onBackPressed = { navigateUp() }, - onNextPressed = { }, + onBackPressed = this::navigateUp, + onNextPressed = this::onContinue, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt index da51a36335..6b2774a36e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeoplePresenter.kt @@ -17,38 +17,33 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.runtime.Composable +import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.userlist.api.SelectionMode -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs +import io.element.android.features.userlist.api.UserListState import io.element.android.libraries.architecture.Presenter import javax.inject.Inject import javax.inject.Named class AddPeoplePresenter @Inject constructor( private val userListPresenterFactory: UserListPresenter.Factory, - @Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource, -) : Presenter { + @Named("AllUsers") private val userListDataSource: UserListDataSource, + private val dataStore: CreateRoomDataStore, +) : Presenter { private val userListPresenter by lazy { userListPresenterFactory.create( UserListPresenterArgs(selectionMode = SelectionMode.Multiple), - matrixUserDataSource, + userListDataSource, + dataStore.selectedUserListDataStore, ) } @Composable - override fun present(): AddPeopleState { - val userListState = userListPresenter.present() - - fun handleEvents(event: AddPeopleEvents) { - // do nothing for now - } - - return AddPeopleState( - userListState = userListState, - eventSink = ::handleEvents, - ) + override fun present(): UserListState { + return userListPresenter.present() } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt similarity index 53% rename from features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt rename to features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt index cfbf7941ce..4a4581539b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleUserListStateProvider.kt @@ -18,30 +18,27 @@ package io.element.android.features.createroom.impl.addpeople import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.UserListState import io.element.android.features.userlist.api.aListOfSelectedUsers import io.element.android.features.userlist.api.aUserListState +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +import kotlinx.collections.immutable.toImmutableList -open class AddPeopleStateProvider : PreviewParameterProvider { - override val values: Sequence +open class AddPeopleUserListStateProvider : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - aAddPeopleState(), - aAddPeopleState().copy( - userListState = aUserListState().copy( - selectedUsers = aListOfSelectedUsers(), - selectionMode = SelectionMode.Multiple, - ) + aUserListState(), + aUserListState().copy( + searchResults = aMatrixUserList().toImmutableList(), + selectedUsers = aListOfSelectedUsers(), + isSearchActive = false, + selectionMode = SelectionMode.Multiple, ), - aAddPeopleState().copy( - userListState = aUserListState().copy( - selectedUsers = aListOfSelectedUsers(), - isSearchActive = true, - selectionMode = SelectionMode.Multiple, - ) + aUserListState().copy( + searchResults = aMatrixUserList().toImmutableList(), + selectedUsers = aListOfSelectedUsers(), + isSearchActive = true, + selectionMode = SelectionMode.Multiple, ) ) } - -fun aAddPeopleState() = AddPeopleState( - userListState = aUserListState(), - eventSink = {} -) 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 56a16b24f9..ebf3e9d508 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 @@ -29,8 +29,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import io.element.android.features.userlist.api.UserListView import io.element.android.features.createroom.impl.R +import io.element.android.features.userlist.api.UserListState +import io.element.android.features.userlist.api.components.UserListView import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight @@ -43,18 +44,16 @@ import io.element.android.libraries.ui.strings.R as StringR @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddPeopleView( - state: AddPeopleState, + state: UserListState, modifier: Modifier = Modifier, onBackPressed: () -> Unit = {}, onNextPressed: () -> Unit = {}, ) { - val eventSink = state.eventSink - Scaffold( topBar = { - if (!state.userListState.isSearchActive) { + if (!state.isSearchActive) { AddPeopleViewTopBar( - hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(), + hasSelectedUsers = state.selectedUsers.isNotEmpty(), onBackPressed = onBackPressed, onNextPressed = onNextPressed, ) @@ -68,7 +67,7 @@ fun AddPeopleView( ) { UserListView( modifier = Modifier.fillMaxWidth(), - state = state.userListState, + state = state, ) } } @@ -109,15 +108,15 @@ fun AddPeopleViewTopBar( @Preview @Composable -internal fun AddPeopleViewLightPreview(@PreviewParameter(AddPeopleStateProvider::class) state: AddPeopleState) = +internal fun AddPeopleViewLightPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreviewLight { ContentToPreview(state) } @Preview @Composable -internal fun AddPeopleViewDarkPreview(@PreviewParameter(AddPeopleStateProvider::class) state: AddPeopleState) = +internal fun AddPeopleViewDarkPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreviewDark { ContentToPreview(state) } @Composable -private fun ContentToPreview(state: AddPeopleState) { +private fun ContentToPreview(state: UserListState) { AddPeopleView(state = state) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/Avatar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/Avatar.kt new file mode 100644 index 0000000000..bbaf5c46e5 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/Avatar.kt @@ -0,0 +1,97 @@ +/* + * 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.features.createroom.impl.components + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddAPhoto +import androidx.compose.material3.MaterialTheme +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.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground +import io.element.android.libraries.designsystem.theme.LocalColors +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun Avatar( + avatarUri: Uri?, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + val commonModifier = modifier + .size(70.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + + if (avatarUri != null) { + val context = LocalContext.current + val model = ImageRequest.Builder(context) + .data(avatarUri) + .build() + AsyncImage( + modifier = commonModifier, + model = model, + placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + } else { + Box(modifier = commonModifier.background(LocalColors.current.quinary)) { + Icon( + imageVector = Icons.Outlined.AddAPhoto, + contentDescription = "", + modifier = Modifier + .align(Alignment.Center) + .size(40.dp), + tint = MaterialTheme.colorScheme.secondary, + ) + } + } +} + +@Preview +@Composable +fun AvatarLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun AvatarDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Row { + Avatar(null) + Avatar(Uri.EMPTY) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/LabelledTextField.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/LabelledTextField.kt new file mode 100644 index 0000000000..382e4b8de2 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/LabelledTextField.kt @@ -0,0 +1,84 @@ +/* + * 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.features.createroom.impl.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.createroom.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextField + +@Composable +fun LabelledTextField( + label: String, + value: String, + modifier: Modifier = Modifier, + placeholder: String = "", + maxLines: Int = 1, + onValueChange: (String) -> Unit = {}, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label + ) + + TextField( + modifier = Modifier.fillMaxWidth(), + value = value, + placeholder = { Text(placeholder) }, + onValueChange = onValueChange, + maxLines = maxLines, + ) + } +} + +@Preview +@Composable +fun LabelledTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + LabelledTextField( + label = stringResource(R.string.screen_create_room_room_name_label), + value = "", + placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), + ) + LabelledTextField( + label = stringResource(R.string.screen_create_room_room_name_label), + value = "a room name", + placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), + ) + } +} 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 new file mode 100644 index 0000000000..8da6d43fcc --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt @@ -0,0 +1,116 @@ +/* + * 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.features.createroom.impl.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.features.createroom.impl.configureroom.RoomPrivacyItem +import io.element.android.features.createroom.impl.configureroom.roomPrivacyItems +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.RadioButton +import io.element.android.libraries.designsystem.theme.components.Text + +@Composable +fun RoomPrivacyOption( + roomPrivacyItem: RoomPrivacyItem, + modifier: Modifier = Modifier, + isSelected: Boolean = false, + onOptionSelected: (RoomPrivacyItem) -> Unit = {}, +) { + Row( + modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + onClick = { onOptionSelected(roomPrivacyItem) }, + role = Role.RadioButton, + ) + .padding(8.dp), + ) { + Icon( + modifier = Modifier.padding(horizontal = 8.dp), + imageVector = roomPrivacyItem.icon, + contentDescription = "", + tint = MaterialTheme.colorScheme.secondary, + ) + + Column( + Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + Text( + text = roomPrivacyItem.title, + fontSize = 16.sp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.size(3.dp)) + Text( + text = roomPrivacyItem.description, + fontSize = 12.sp, + lineHeight = 17.sp, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + RadioButton( + modifier = Modifier + .align(Alignment.CenterVertically) + .size(48.dp), + selected = isSelected, + onClick = null // null recommended for accessibility with screenreaders + ) + } +} + +@Preview +@Composable +fun RoomPrivacyOptionLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun RoomPrivacyOptionDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + val aRoomPrivacyItem = roomPrivacyItems().first() + Column { + RoomPrivacyOption( + roomPrivacyItem = aRoomPrivacyItem, + isSelected = true, + ) + RoomPrivacyOption( + roomPrivacyItem = aRoomPrivacyItem, + isSelected = false, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt new file mode 100644 index 0000000000..f10f673d78 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt @@ -0,0 +1,29 @@ +/* + * 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.features.createroom.impl.configureroom + +import android.net.Uri +import io.element.android.libraries.matrix.ui.model.MatrixUser + +sealed interface ConfigureRoomEvents { + data class RoomNameChanged(val name: String) : ConfigureRoomEvents + data class TopicChanged(val topic: String) : ConfigureRoomEvents + data class AvatarUriChanged(val uri: Uri?) : ConfigureRoomEvents + data class RoomPrivacyChanged(val privacy: RoomPrivacy?) : ConfigureRoomEvents + data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents + object CreateRoom : ConfigureRoomEvents +} 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 new file mode 100644 index 0000000000..08a4f5f64b --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -0,0 +1,45 @@ +/* + * 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.features.createroom.impl.configureroom + +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 dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.createroom.impl.di.CreateRoomScope + +@ContributesNode(CreateRoomScope::class) +class ConfigureRoomNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ConfigureRoomPresenter, +) : Node(buildContext, plugins = plugins) { + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ConfigureRoomView( + state = state, + modifier = modifier, + onBackPressed = { navigateUp() } // TODO we should keep in memory the current view state + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt new file mode 100644 index 0000000000..0046b83058 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -0,0 +1,59 @@ +/* + * 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.features.createroom.impl.configureroom + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.libraries.architecture.Presenter +import javax.inject.Inject + +class ConfigureRoomPresenter @Inject constructor( + private val dataStore: CreateRoomDataStore, +) : Presenter { + + @Composable + override fun present(): ConfigureRoomState { + val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) + val isCreateButtonEnabled by remember(createRoomConfig.value.roomName, createRoomConfig.value.privacy) { + derivedStateOf { + createRoomConfig.value.roomName.isNullOrEmpty().not() && createRoomConfig.value.privacy != null + } + } + + fun handleEvents(event: ConfigureRoomEvents) { + when (event) { + is ConfigureRoomEvents.AvatarUriChanged -> dataStore.setAvatarUrl(event.uri?.toString()) + is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) + is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) + is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) + is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) + ConfigureRoomEvents.CreateRoom -> Unit // TODO + } + } + + return ConfigureRoomState( + createRoomConfig.value, + isCreateButtonEnabled = isCreateButtonEnabled, + eventSink = ::handleEvents, + ) + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt similarity index 72% rename from features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt rename to features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt index 8605e1aba6..8969a2a5fa 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterArgs.kt @@ -14,11 +14,10 @@ * limitations under the License. */ -package io.element.android.features.createroom.impl.addpeople +package io.element.android.features.createroom.impl.configureroom -import io.element.android.features.userlist.api.UserListState +import io.element.android.libraries.matrix.ui.model.MatrixUser -data class AddPeopleState( - val userListState: UserListState, - val eventSink: (AddPeopleEvents) -> Unit, +data class ConfigureRoomPresenterArgs( + val selectedUsers: List, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt new file mode 100644 index 0000000000..47be0b1f17 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -0,0 +1,25 @@ +/* + * 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.features.createroom.impl.configureroom + +import io.element.android.features.createroom.impl.CreateRoomConfig + +data class ConfigureRoomState( + val config: CreateRoomConfig, + val isCreateButtonEnabled: Boolean, + val eventSink: (ConfigureRoomEvents) -> Unit +) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt new file mode 100644 index 0000000000..32744178dd --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -0,0 +1,43 @@ +/* + * 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.features.createroom.impl.configureroom + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.userlist.api.aListOfSelectedUsers + +open class ConfigureRoomStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aConfigureRoomState(), + aConfigureRoomState().copy( + config = CreateRoomConfig( + roomName = "Room 101", + topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines", + invites = aListOfSelectedUsers(), + privacy = RoomPrivacy.Private, + ), + isCreateButtonEnabled = true, + ), + ) +} + +fun aConfigureRoomState() = ConfigureRoomState( + config = CreateRoomConfig(), + isCreateButtonEnabled = false, + eventSink = {} +) 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 new file mode 100644 index 0000000000..11b417278a --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -0,0 +1,215 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.ExperimentalMaterial3Api +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import io.element.android.features.createroom.impl.R +import io.element.android.features.createroom.impl.components.Avatar +import io.element.android.features.createroom.impl.components.LabelledTextField +import io.element.android.features.createroom.impl.components.RoomPrivacyOption +import io.element.android.features.userlist.api.components.SelectedUsersList +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar +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.TextButton +import io.element.android.libraries.ui.strings.R as StringR + +@Composable +fun ConfigureRoomView( + state: ConfigureRoomState, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, +) { + val context = LocalContext.current + Scaffold( + modifier = modifier, + topBar = { + ConfigureRoomToolbar( + isNextActionEnabled = state.isCreateButtonEnabled, + onBackPressed = onBackPressed, + onNextPressed = { + // state.eventSink(ConfigureRoomEvents.CreateRoom) + Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() + }, + ) + } + ) { padding -> + Column( + modifier = Modifier.padding(padding), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + RoomNameWithAvatar( + modifier = Modifier.padding(horizontal = 16.dp), + avatarUri = state.config.avatarUrl?.toUri(), + roomName = state.config.roomName.orEmpty(), + onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() }, + onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, + ) + RoomTopic( + modifier = Modifier.padding(horizontal = 16.dp), + topic = state.config.topic.orEmpty(), + onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, + ) + SelectedUsersList( + contentPadding = PaddingValues(horizontal = 24.dp), + selectedUsers = state.config.invites, + onUserRemoved = { state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) }, + ) + Spacer(Modifier.weight(1f)) + RoomPrivacyOptions( + modifier = Modifier.padding(bottom = 40.dp), + selected = state.config.privacy, + onOptionSelected = { state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) }, + ) + } + } +} + +@Composable +fun ConfigureRoomToolbar( + isNextActionEnabled: Boolean, + modifier: Modifier = Modifier, + onBackPressed: () -> Unit = {}, + onNextPressed: () -> Unit = {}, +) { + CenterAlignedTopAppBar( + modifier = modifier, + title = { + Text( + text = stringResource(R.string.screen_create_room_title), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + }, + navigationIcon = { BackButton(onClick = onBackPressed) }, + actions = { + TextButton( + modifier = Modifier.padding(horizontal = 8.dp), + enabled = isNextActionEnabled, + onClick = onNextPressed, + ) { + Text( + text = stringResource(StringR.string.action_create), + fontSize = 16.sp, + ) + } + } + ) +} + +@Composable +fun RoomNameWithAvatar( + avatarUri: Uri?, + roomName: String, + modifier: Modifier = Modifier, + onAvatarClick: () -> Unit = {}, + onRoomNameChanged: (String) -> Unit = {}, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + avatarUri = avatarUri, + onClick = onAvatarClick, + ) + + LabelledTextField( + label = stringResource(R.string.screen_create_room_room_name_label), + value = roomName, + placeholder = stringResource(R.string.screen_create_room_room_name_placeholder), + onValueChange = onRoomNameChanged + ) + } +} + +@Composable +fun RoomTopic( + topic: String, + modifier: Modifier = Modifier, + onTopicChanged: (String) -> Unit = {}, +) { + LabelledTextField( + modifier = modifier, + label = stringResource(R.string.screen_create_room_topic_label), + value = topic, + placeholder = stringResource(R.string.screen_create_room_topic_placeholder), + onValueChange = onTopicChanged, + maxLines = 3, + ) +} + +@Composable +fun RoomPrivacyOptions( + selected: RoomPrivacy?, + modifier: Modifier = Modifier, + onOptionSelected: (RoomPrivacyItem) -> Unit = {}, +) { + val items = roomPrivacyItems() + Column(modifier = modifier.selectableGroup()) { + items.forEach { item -> + RoomPrivacyOption( + roomPrivacyItem = item, + isSelected = selected == item.privacy, + onOptionSelected = onOptionSelected, + ) + } + } +} + +@Preview +@Composable +fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun ConfigureRoomViewDarkPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: ConfigureRoomState) { + ConfigureRoomView( + state = state, + ) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt new file mode 100644 index 0000000000..5cb0cf25b4 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacy.kt @@ -0,0 +1,22 @@ +/* + * 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.features.createroom.impl.configureroom + +enum class RoomPrivacy { + Private, + Public, +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt new file mode 100644 index 0000000000..462dedba00 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomPrivacyItem.kt @@ -0,0 +1,56 @@ +/* + * 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.features.createroom.impl.configureroom + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Public +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import io.element.android.features.createroom.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class RoomPrivacyItem( + val privacy: RoomPrivacy, + val icon: ImageVector, + val title: String, + val description: String, +) + +@Composable +fun roomPrivacyItems(): ImmutableList { + return RoomPrivacy.values() + .map { + when (it) { + RoomPrivacy.Private -> RoomPrivacyItem( + privacy = it, + icon = Icons.Outlined.Lock, + title = stringResource(R.string.screen_create_room_private_option_title), + description = stringResource(R.string.screen_create_room_private_option_description), + ) + RoomPrivacy.Public -> RoomPrivacyItem( + privacy = it, + icon = Icons.Outlined.Public, + title = stringResource(R.string.screen_create_room_public_option_title), + description = stringResource(R.string.screen_create_room_public_option_description), + ) + } + } + .toImmutableList() +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt new file mode 100644 index 0000000000..f6f50f67bf --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomComponent.kt @@ -0,0 +1,39 @@ +/* + * 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.features.createroom.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.SessionScope +import io.element.android.libraries.di.SingleIn + +@SingleIn(CreateRoomScope::class) +@MergeSubcomponent(CreateRoomScope::class) +interface CreateRoomComponent : NodeFactoriesBindings { + + @Subcomponent.Builder + interface Builder { + fun build(): CreateRoomComponent + } + + @ContributesTo(SessionScope::class) + interface ParentBindings { + fun createRoomComponentBuilder(): Builder + } +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt index c5f2d0ca06..46c471bf1b 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomModule.kt @@ -20,7 +20,7 @@ import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module import io.element.android.features.createroom.impl.AllMatrixUsersDataSource -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.di.AppScope import javax.inject.Named @@ -30,6 +30,6 @@ interface CreateRoomModule { @Binds @Named("AllUsers") - fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): MatrixUserDataSource + fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt similarity index 84% rename from features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt rename to features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt index 5d246be501..c869536c56 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/di/CreateRoomScope.kt @@ -14,6 +14,6 @@ * limitations under the License. */ -package io.element.android.features.createroom.impl.addpeople +package io.element.android.features.createroom.impl.di -sealed interface AddPeopleEvents +abstract class CreateRoomScope private constructor() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt index 89932b0cdb..2ef30908e1 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootPresenter.kt @@ -21,8 +21,9 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import io.element.android.features.userlist.api.MatrixUserDataSource import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.libraries.architecture.Async @@ -38,14 +39,16 @@ import javax.inject.Named class CreateRoomRootPresenter @Inject constructor( private val presenterFactory: UserListPresenter.Factory, - @Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource, + @Named("AllUsers") private val userListDataSource: UserListDataSource, + private val userListDataStore: UserListDataStore, private val matrixClient: MatrixClient, ) : Presenter { private val presenter by lazy { presenterFactory.create( UserListPresenterArgs(selectionMode = SelectionMode.Single), - matrixUserDataSource, + userListDataSource, + userListDataStore, ) } 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 e488645b78..a94b3bf28b 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 @@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.element.android.features.createroom.impl.R -import io.element.android.features.userlist.api.UserListView +import io.element.android.features.userlist.api.components.UserListView import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog @@ -110,7 +110,7 @@ fun CreateRoomRootView( } is Async.Failure -> { RetryDialog( - content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat), + content = stringResource(id = R.string.screen_start_chat_error_starting_chat), onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, onRetry = { state.userListState.selectedUsers.firstOrNull() @@ -156,7 +156,7 @@ fun CreateRoomActionButtonsList( Column(modifier = modifier) { CreateRoomActionButton( iconRes = DrawableR.drawable.ic_groups, - text = stringResource(id = R.string.screen_create_room_action_create_room), + text = stringResource(id = StringR.string.action_create_a_room), onClick = onNewRoomClicked, ) CreateRoomActionButton( diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml index f6248df74e..bb3d6fa0b8 100644 --- a/features/createroom/impl/src/main/res/values-es/translations.xml +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -3,4 +3,6 @@ "Nueva sala" "Invitar gente" "Añadir personas" + "Se ha producido un error al intentar iniciar un chat" + "No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación." \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml index ea0c0b10e1..1d6ce99b5f 100644 --- a/features/createroom/impl/src/main/res/values-it/translations.xml +++ b/features/createroom/impl/src/main/res/values-it/translations.xml @@ -3,4 +3,6 @@ "Nuova stanza" "Invita persone" "Aggiungi persone" + "Si è verificato un errore durante il tentativo di avviare una chat" + "Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto." \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values-ro/translations.xml b/features/createroom/impl/src/main/res/values-ro/translations.xml index 98839a883e..af6e3db1fa 100644 --- a/features/createroom/impl/src/main/res/values-ro/translations.xml +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -3,4 +3,6 @@ "Cameră nouă" "Invitați persoane" "Adaugați persoane" + "A apărut o eroare la încercarea începerii conversației" + "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită." \ No newline at end of file diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml index 48f055e082..177d588f7e 100644 --- a/features/createroom/impl/src/main/res/values/localazy.xml +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -12,4 +12,6 @@ "Create a room" "Topic (optional)" "What is this room about?" + "An error occurred when trying to start a chat" + "We can’t validate this user’s Matrix ID. The invite might not be received." \ No newline at end of file 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/AddPeoplePresenterTests.kt index 086b5edf30..7ca2f0147f 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/AddPeoplePresenterTests.kt @@ -22,7 +22,9 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.userlist.test.FakeMatrixUserDataSource +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.userlist.api.UserListDataStore +import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.userlist.test.FakeUserListPresenterFactory import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -35,7 +37,7 @@ class AddPeoplePresenterTests { @Before fun setup() { - presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeMatrixUserDataSource()) + presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeUserListDataSource(), CreateRoomDataStore(UserListDataStore())) } @Test 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/ConfigureRoomPresenterTests.kt new file mode 100644 index 0000000000..1f27baa511 --- /dev/null +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt @@ -0,0 +1,153 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.createroom.impl.configureroom + +import android.net.Uri +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.userlist.api.UserListDataStore +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_MESSAGE +import io.element.android.libraries.matrix.test.A_ROOM_NAME +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConfigureRoomPresenterTests { + + private lateinit var presenter: ConfigureRoomPresenter + private lateinit var userListDataStore: UserListDataStore + + @Before + fun setup() { + userListDataStore = UserListDataStore() + presenter = ConfigureRoomPresenter(CreateRoomDataStore(userListDataStore)) + } + + @Test + fun `present - initial state`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.config).isEqualTo(CreateRoomConfig()) + assertThat(initialState.config.roomName).isNull() + assertThat(initialState.config.topic).isNull() + assertThat(initialState.config.invites).isEmpty() + assertThat(initialState.config.avatarUrl).isNull() + assertThat(initialState.config.privacy).isNull() + } + } + + @Test + fun `present - create room button is enabled only if the required fields are completed`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + var config = initialState.config + assertThat(initialState.isCreateButtonEnabled).isFalse() + + // Room name not empty + initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) + var newState: ConfigureRoomState = awaitItem() + config = config.copy(roomName = A_ROOM_NAME) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isCreateButtonEnabled).isFalse() + + // Select privacy + newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Private)) + newState = awaitItem() + config = config.copy(privacy = RoomPrivacy.Private) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isCreateButtonEnabled).isTrue() + + // Clear room name + newState.eventSink(ConfigureRoomEvents.RoomNameChanged("")) + newState = awaitItem() + config = config.copy(roomName = null) + assertThat(newState.config).isEqualTo(config) + assertThat(newState.isCreateButtonEnabled).isFalse() + } + } + + @Test + fun `present - state is updated when fields are changed`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + var expectedConfig = CreateRoomConfig() + assertThat(initialState.config).isEqualTo(expectedConfig) + + // Select User + val selectedUser1 = aMatrixUser() + val selectedUser2 = aMatrixUser("@id_of_bob:server.org", "Bob") + userListDataStore.selectUser(selectedUser1) + skipItems(1) + userListDataStore.selectUser(selectedUser2) + var newState = awaitItem() + expectedConfig = expectedConfig.copy(invites = persistentListOf(selectedUser1, selectedUser2)) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room name + initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(roomName = A_ROOM_NAME) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room topic + newState.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(topic = A_MESSAGE) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room avatar + val anUri = Uri.parse(AN_AVATAR_URL) + newState.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUrl = anUri.toString()) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Room privacy + newState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(privacy = RoomPrivacy.Public) + assertThat(newState.config).isEqualTo(expectedConfig) + + // Remove user + newState.eventSink(ConfigureRoomEvents.RemoveFromSelection(selectedUser1)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(invites = expectedConfig.invites.minus(selectedUser1).toImmutableList()) + assertThat(newState.config).isEqualTo(expectedConfig) + } + } +} + 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/CreateRoomRootPresenterTests.kt index cf399fdbd3..4d26e68bcb 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/CreateRoomRootPresenterTests.kt @@ -22,8 +22,9 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.aUserListState -import io.element.android.features.userlist.test.FakeMatrixUserDataSource +import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.userlist.test.FakeUserListPresenter import io.element.android.features.userlist.test.FakeUserListPresenterFactory import io.element.android.libraries.architecture.Async @@ -41,7 +42,7 @@ import org.junit.Test class CreateRoomRootPresenterTests { - private lateinit var userListDataSource: FakeMatrixUserDataSource + private lateinit var userListDataSource: FakeUserListDataSource private lateinit var presenter: CreateRoomRootPresenter private lateinit var fakeUserListPresenter: FakeUserListPresenter private lateinit var fakeMatrixClient: FakeMatrixClient @@ -50,8 +51,8 @@ class CreateRoomRootPresenterTests { fun setup() { fakeUserListPresenter = FakeUserListPresenter() fakeMatrixClient = FakeMatrixClient() - userListDataSource = FakeMatrixUserDataSource() - presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, fakeMatrixClient) + userListDataSource = FakeUserListDataSource() + presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, UserListDataStore(), fakeMatrixClient) } @Test 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 a65449546e..67038001e9 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 @@ -29,10 +29,12 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.RoomMember import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -54,6 +56,9 @@ class RoomDetailsFlowNode @AssistedInject constructor( @Parcelize object RoomMemberList : NavTarget + + @Parcelize + data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -66,7 +71,18 @@ class RoomDetailsFlowNode @AssistedInject constructor( } createNode(buildContext, listOf(callback)) } - NavTarget.RoomMemberList -> createNode(buildContext) + NavTarget.RoomMemberList -> { + val callback = object : RoomMemberListNode.Callback { + override fun openRoomMemberDetails(roomMember: RoomMember) { + backstack.push(NavTarget.RoomMemberDetails(roomMember)) + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.RoomMemberDetails -> { + val inputs = RoomMemberDetailsNode.Inputs(navTarget.roomMember) + createNode(buildContext, listOf(inputs)) + } } } 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 f234f835ca..f20a8e9509 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 @@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.system.startSharePlainTextInten import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.ui.strings.R as StringR @ContributesNode(RoomScope::class) class RoomDetailsNode @AssistedInject constructor( @@ -61,7 +60,6 @@ class RoomDetailsNode @AssistedInject constructor( activityResultLauncher = null, chooserTitle = context.getString(R.string.screen_room_details_share_room_title), text = permalink, - noActivityFoundMessage = context.getString(StringR.string.error_no_compatible_app_found) ) } } 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 d5b2a4a9e4..a4e7c8f77e 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 @@ -87,7 +87,7 @@ fun RoomDetailsView( roomAlias = state.roomAlias ) - ShareSection(onShareRoom = onShareRoom) + ShareSection(onShareUser = onShareRoom) if (state.roomTopic != null) { TopicSection(roomTopic = state.roomTopic) @@ -127,12 +127,12 @@ fun RoomDetailsView( } @Composable -internal fun ShareSection(onShareRoom: () -> Unit, modifier: Modifier = Modifier) { +internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) { PreferenceCategory(modifier = modifier) { PreferenceText( title = stringResource(R.string.screen_room_details_share_room_title), icon = Icons.Outlined.Share, - onClick = onShareRoom, + onClick = onShareUser, ) } } @@ -172,7 +172,6 @@ internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) { color = MaterialTheme.colorScheme.tertiary ) } - } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt new file mode 100644 index 0000000000..b2fb9e04ae --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberBindsModule.kt @@ -0,0 +1,53 @@ +/* + * 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.features.roomdetails.impl.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.element.android.features.roomdetails.impl.members.RoomUserListDataSource +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember +import javax.inject.Named + +@Module +@ContributesTo(RoomScope::class) +interface RoomMemberBindsModule { + + @Binds + @Named("RoomMembers") + fun bindRoomMemberUserListDataSource(dataSource: RoomUserListDataSource): UserListDataSource +} + +@Module +@ContributesTo(RoomScope::class) +object RoomMemberProvidesModule { + @Provides + fun provideRoomMemberDetailsPresenterFactory( + room: MatrixRoom, + ): RoomMemberDetailsPresenter.Factory { + return object : RoomMemberDetailsPresenter.Factory { + override fun create(roomMember: RoomMember): RoomMemberDetailsPresenter { + return RoomMemberDetailsPresenter(room, roomMember) + } + } + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt index 0f65b4657b..59f13115f0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListNode.kt @@ -21,10 +21,14 @@ 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.roomdetails.impl.RoomDetailsFlowNode import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.model.MatrixUser import timber.log.Timber @@ -32,11 +36,23 @@ import timber.log.Timber class RoomMemberListNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val room: MatrixRoom, private val presenter: RoomMemberListPresenter, ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun openRoomMemberDetails(roomMember: RoomMember) + } + + private val callback = plugins().first() + private fun onUserSelected(matrixUser: MatrixUser) { - Timber.d("TODO: implement user selection. User: $matrixUser") + val member = room.getMember(matrixUser.id) + if (member != null) { + callback.openRoomMemberDetails(member) + } else { + Timber.e("Could find room member ${matrixUser.id} in room ${room.roomId}") + } } @Composable diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 8c3a873ade..8841bd9d5e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -21,7 +21,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import io.element.android.features.userlist.api.SelectionMode -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.libraries.architecture.Async @@ -36,13 +37,15 @@ import javax.inject.Named class RoomMemberListPresenter @Inject constructor( private val userListPresenterFactory: UserListPresenter.Factory, - @Named("RoomMembers") private val matrixUserDataSource: MatrixUserDataSource, + @Named("RoomMembers") private val userListDataSource: UserListDataSource, + private val userListDataStore: UserListDataStore, ) : Presenter { private val userListPresenter by lazy { userListPresenterFactory.create( UserListPresenterArgs(selectionMode = SelectionMode.Single), - matrixUserDataSource, + userListDataSource, + userListDataStore, ) } @@ -52,7 +55,7 @@ class RoomMemberListPresenter @Inject constructor( val allUsers = remember { mutableStateOf>>(Async.Loading()) } LaunchedEffect(Unit) { withContext(Dispatchers.IO) { - allUsers.value = Async.Success(matrixUserDataSource.search("").toImmutableList()) + allUsers.value = Async.Success(userListDataSource.search("").toImmutableList()) } } return RoomMemberListState( 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 e2c41e34b3..f356e203f2 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 @@ -39,8 +39,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.element.android.features.roomdetails.impl.R -import io.element.android.features.userlist.api.SearchSingleUserResultItem -import io.element.android.features.userlist.api.UserListView +import io.element.android.features.userlist.api.components.SearchSingleUserResultItem +import io.element.android.features.userlist.api.components.UserListView import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.isLoading import io.element.android.libraries.designsystem.ElementTextStyles diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt similarity index 92% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt rename to features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt index b97dcd62e5..9f22c41666 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMatrixUserDataSource.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomUserListDataSource.kt @@ -16,7 +16,7 @@ package io.element.android.features.roomdetails.impl.members -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId @@ -25,9 +25,9 @@ import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.ui.model.MatrixUser import javax.inject.Inject -class RoomMatrixUserDataSource @Inject constructor( +class RoomUserListDataSource @Inject constructor( private val room: MatrixRoom -) : MatrixUserDataSource { +) : UserListDataSource { override suspend fun search(query: String): List { return room.members().filter { member -> diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt new file mode 100644 index 0000000000..2ce6688925 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsEvents.kt @@ -0,0 +1,22 @@ +/* + * 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.features.roomdetails.impl.members.details + +// TODO Add your events or remove the file completely if no events +sealed interface RoomMemberDetailsEvents { + object MyEvent : RoomMemberDetailsEvents +} 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 new file mode 100644 index 0000000000..cf1266099a --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -0,0 +1,81 @@ +/* + * 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.features.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +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.roomdetails.impl.R +import io.element.android.libraries.androidutils.system.startSharePlainTextIntent +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.RoomMember +import timber.log.Timber +import io.element.android.libraries.androidutils.R as AndroidUtilsR + +@ContributesNode(RoomScope::class) +class RoomMemberDetailsNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: RoomMemberDetailsPresenter.Factory, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val member: RoomMember, + ) : NodeInputs + + private val inputs = inputs() + private val presenter = presenterFactory.create(inputs.member) + + @Composable + override fun View(modifier: Modifier) { + + val context = LocalContext.current + + fun onShareUser() { + val permalinkResult = PermalinkBuilder.permalinkForUser(UserId(inputs.member.userId)) + permalinkResult.onSuccess { permalink -> + startSharePlainTextIntent( + context = context, + activityResultLauncher = null, + chooserTitle = context.getString(R.string.screen_room_details_share_room_title), + text = permalink, + noActivityFoundMessage = context.getString(AndroidUtilsR.string.error_no_compatible_app_found) + ) + }.onFailure { + Timber.e(it) + } + } + + val state = presenter.present() + RoomMemberDetailsView( + state = state, + modifier = modifier, + goBack = this::navigateUp, + onShareUser = ::onShareUser + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt new file mode 100644 index 0000000000..0e997c4499 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -0,0 +1,51 @@ +/* + * 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.features.roomdetails.impl.members.details + +import androidx.compose.runtime.Composable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.RoomMember + +class RoomMemberDetailsPresenter @AssistedInject constructor( + private val room: MatrixRoom, + @Assisted private val roomMember: RoomMember, +) : Presenter { + + interface Factory { + fun create(roomMember: RoomMember): RoomMemberDetailsPresenter + } + + @Composable + override fun present(): RoomMemberDetailsState { + +// fun handleEvents(event: RoomMemberDetailsEvents) { +// when (event) { +// } +// } + + return RoomMemberDetailsState( + userId = roomMember.userId, + userName = roomMember.displayName, + avatarUrl = roomMember.avatarUrl, + isBlocked = roomMember.isIgnored, +// eventSink = ::handleEvents + ) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt new file mode 100644 index 0000000000..d9e3f949e7 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsState.kt @@ -0,0 +1,25 @@ +/* + * 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.features.roomdetails.impl.members.details + +data class RoomMemberDetailsState( + val userId: String, + val userName: String?, + val avatarUrl: String?, + val isBlocked: Boolean, +// val eventSink: (RoomMemberDetailsEvents) -> Unit +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt new file mode 100644 index 0000000000..c719ab7a26 --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsStateProvider.kt @@ -0,0 +1,37 @@ +/* + * 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.features.roomdetails.impl.members.details + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +open class RoomMemberDetailsStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aRoomMemberDetailsState(), + aRoomMemberDetailsState().copy(userName = null), + aRoomMemberDetailsState().copy(isBlocked = true), + // Add other states here + ) +} + +fun aRoomMemberDetailsState() = RoomMemberDetailsState( + userId = "@daniel:domain.com", + userName = "Daniel", + avatarUrl = null, + isBlocked = false, +// eventSink = {}, +) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt new file mode 100644 index 0000000000..d7a97e5fdc --- /dev/null +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsView.kt @@ -0,0 +1,177 @@ +/* + * 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.features.roomdetails.impl.members.details + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.roomdetails.impl.R +import io.element.android.libraries.designsystem.ElementTextStyles +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory +import io.element.android.libraries.designsystem.components.preferences.PreferenceText +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.LocalColors +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.ui.strings.R as StringR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoomMemberDetailsView( + state: RoomMemberDetailsState, + onShareUser: () -> Unit, + goBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar(title = { }, navigationIcon = { BackButton(onClick = goBack) }) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + HeaderSection( + avatarUrl = state.avatarUrl, + userId = state.userId, + userName = state.userName, + ) + + ShareSection(onShareUser = onShareUser) + + SendMessageSection(onSendMessage = { + // TODO implement send DM + }) + + BlockSection(isBlocked = state.isBlocked, onToggleBlock = { + // TODO implement block & unblock + }) + } + } +} + +@Composable +internal fun HeaderSection( + avatarUrl: String?, + userId: String, + userName: String?, + modifier: Modifier = Modifier +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Box(modifier = Modifier.size(70.dp)) { + Avatar( + avatarData = AvatarData(userId, userName, avatarUrl, AvatarSize.HUGE), + modifier = Modifier.fillMaxSize() + ) + } + Spacer(modifier = Modifier.height(30.dp)) + if (userName != null) { + Text(userName, style = ElementTextStyles.Bold.title1) + Spacer(modifier = Modifier.height(8.dp)) + } + Text(userId, style = ElementTextStyles.Regular.body, color = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +internal fun ShareSection(onShareUser: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(StringR.string.action_share), + icon = Icons.Outlined.Share, + onClick = onShareUser, + ) + } +} + +@Composable +internal fun SendMessageSection(onSendMessage: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(modifier = modifier) { + PreferenceText( + title = stringResource(StringR.string.action_send_message), + icon = Icons.Outlined.ChatBubbleOutline, + onClick = onSendMessage, + ) + } +} + +@Composable +internal fun BlockSection(isBlocked: Boolean, onToggleBlock: () -> Unit, modifier: Modifier = Modifier) { + PreferenceCategory(showDivider = false, modifier = modifier) { + if (isBlocked) { + PreferenceText( + title = stringResource(R.string.screen_dm_details_unblock_user), + icon = Icons.Outlined.Block, + ) + } else { + PreferenceText( + title = stringResource(R.string.screen_dm_details_block_user), + icon = Icons.Outlined.Block, + tintColor = LocalColors.current.textActionCritical, + ) + } + } +} + +@Preview +@Composable +fun RoomMemberDetailsViewLightPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +fun RoomMemberDetailsViewDarkPreview(@PreviewParameter(RoomMemberDetailsStateProvider::class) state: RoomMemberDetailsState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: RoomMemberDetailsState) { + RoomMemberDetailsView( + state = state, + onShareUser = {}, + goBack = {}, + ) +} 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/RoomDetailsPresenterTests.kt index 99e87a64be..0dedd71c06 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/RoomDetailsPresenterTests.kt @@ -232,7 +232,8 @@ fun aRoomMember( membership: RoomMembershipState = RoomMembershipState.JOIN, isNameAmbiguous: Boolean = false, powerLevel: Long = 0L, - normalizedPowerLevel: Long = 0L + normalizedPowerLevel: Long = 0L, + isIgnored: Boolean = false, ) = RoomMember( userId = userId.value, displayName = displayName, @@ -241,4 +242,5 @@ fun aRoomMember( isNameAmbiguous = isNameAmbiguous, powerLevel = powerLevel, normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, ) 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/RoomMemberListPresenterTests.kt index 3564daa5f1..373ebbb347 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/RoomMemberListPresenterTests.kt @@ -22,29 +22,37 @@ import app.cash.turbine.test import com.google.common.truth.Truth import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter import io.element.android.features.userlist.api.SelectionMode -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.userlist.impl.DefaultUserListPresenter -import io.element.android.features.userlist.test.FakeMatrixUserDataSource +import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.ui.components.aMatrixUser +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.internal.toImmutableList import org.junit.Test +@ExperimentalCoroutinesApi class RoomMemberListPresenterTests { @Test fun `present - search is done automatically on start, but is async`() = runTest { val searchResult = listOf(aMatrixUser()) - val userListDataSource = FakeMatrixUserDataSource().apply { + val userListDataSource = FakeUserListDataSource().apply { givenSearchResult(searchResult) } + val userListDataStore = UserListDataStore() val userListFactory = object : UserListPresenter.Factory { - override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource) + override fun create( + args: UserListPresenterArgs, + userListDataSource: UserListDataSource, + userListDataStore: UserListDataStore, + ) = DefaultUserListPresenter(args, userListDataSource, userListDataStore) } - val presenter = RoomMemberListPresenter(userListFactory, userListDataSource) + val presenter = RoomMemberListPresenter(userListFactory, userListDataSource, userListDataStore) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { @@ -58,5 +66,4 @@ class RoomMemberListPresenterTests { Truth.assertThat((loadedState.allUsers as? Async.Success)?.state).isEqualTo(searchResult.toImmutableList()) } } - } 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/RoomMemberDetailsPresenterTests.kt new file mode 100644 index 0000000000..de4904fb7f --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/details/RoomMemberDetailsPresenterTests.kt @@ -0,0 +1,48 @@ +/* + * 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.features.roomdetails.members.details + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth +import io.element.android.features.roomdetails.aMatrixRoom +import io.element.android.features.roomdetails.aRoomMember +import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@ExperimentalCoroutinesApi +class RoomMemberDetailsPresenterTests { + + @Test + fun `present - returns the room member's data`() = runTest { + val room = aMatrixRoom() + val roomMember = aRoomMember(displayName = "Alice") + val presenter = RoomMemberDetailsPresenter(room, roomMember) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + Truth.assertThat(initialState.userId).isEqualTo(roomMember.userId) + Truth.assertThat(initialState.userName).isEqualTo(roomMember.displayName) + Truth.assertThat(initialState.avatarUrl).isEqualTo(roomMember.avatarUrl) + Truth.assertThat(initialState.isBlocked).isEqualTo(roomMember.isIgnored) + } + } +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt similarity index 96% rename from features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt rename to features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt index 08eddfd7e9..afe2d1ab3d 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/MatrixUserDataSource.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataSource.kt @@ -19,7 +19,7 @@ package io.element.android.features.userlist.api import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser -interface MatrixUserDataSource { +interface UserListDataSource { suspend fun search(query: String): List suspend fun getProfile(userId: UserId): MatrixUser? } diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataStore.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataStore.kt new file mode 100644 index 0000000000..f9c73950f1 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListDataStore.kt @@ -0,0 +1,39 @@ +/* + * 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.features.userlist.api + +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +class UserListDataStore @Inject constructor() { + + private val selectedUsers: MutableStateFlow> = MutableStateFlow(emptyList()) + + fun selectUser(user: MatrixUser) { + if (user !in selectedUsers.value) { + selectedUsers.tryEmit(selectedUsers.value.plus(user)) + } + } + + fun removeUserFromSelection(user: MatrixUser) { + selectedUsers.tryEmit(selectedUsers.value.minus(user)) + } + + fun selectedUsers(): Flow> = selectedUsers +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt index c328efd44e..90205eab2a 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListPresenter.kt @@ -21,6 +21,10 @@ import io.element.android.libraries.architecture.Presenter interface UserListPresenter : Presenter { interface Factory { - fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter + fun create( + args: UserListPresenterArgs, + userListDataSource: UserListDataSource, + userListDataStore: UserListDataStore, + ): UserListPresenter } } diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt index 80de1e991f..dfbfddbcf5 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListState.kt @@ -16,7 +16,6 @@ package io.element.android.features.userlist.api -import androidx.compose.foundation.lazy.LazyListState import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -24,7 +23,6 @@ data class UserListState( val searchQuery: String, val searchResults: ImmutableList, val selectedUsers: ImmutableList, - val selectedUsersListState: LazyListState, val isSearchActive: Boolean, val selectionMode: SelectionMode, val eventSink: (UserListEvents) -> Unit, diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt index d97a4537ed..7ea0ba0827 100644 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListStateProvider.kt @@ -16,11 +16,10 @@ package io.element.android.features.userlist.api -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUserList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList open class UserListStateProvider : PreviewParameterProvider { override val values: Sequence @@ -38,14 +37,14 @@ open class UserListStateProvider : PreviewParameterProvider { isSearchActive = true, searchQuery = "@someone:matrix.org", selectedUsers = aListOfSelectedUsers(), - searchResults = aListOfResults(), + searchResults = aMatrixUserList().toImmutableList(), ), aUserListState().copy( isSearchActive = true, searchQuery = "@someone:matrix.org", selectionMode = SelectionMode.Multiple, selectedUsers = aListOfSelectedUsers(), - searchResults = aListOfResults(), + searchResults = aMatrixUserList().toImmutableList(), ) ) } @@ -55,33 +54,8 @@ fun aUserListState() = UserListState( searchQuery = "", searchResults = persistentListOf(), selectedUsers = persistentListOf(), - selectedUsersListState = LazyListState( - firstVisibleItemIndex = 0, - firstVisibleItemScrollOffset = 0, - ), selectionMode = SelectionMode.Single, eventSink = {} ) -fun aListOfSelectedUsers() = persistentListOf( - MatrixUser(id = UserId("@someone:matrix.org")), - MatrixUser(id = UserId("@other:matrix.org"), username = "other"), -) - -fun aListOfResults() = persistentListOf( - MatrixUser(id = UserId("@someone:matrix.org")), - MatrixUser(id = UserId("@other:matrix.org"), username = "other"), - MatrixUser( - id = UserId("@someone_with_a_very_long_matrix_identifier:a_very_long_domain.org"), - username = "hey, I am someone with a very long display name" - ), - MatrixUser(id = UserId("@someone_2:matrix.org"), username = "someone 2"), - MatrixUser(id = UserId("@someone_3:matrix.org"), username = "someone 3"), - MatrixUser(id = UserId("@someone_4:matrix.org"), username = "someone 4"), - MatrixUser(id = UserId("@someone_5:matrix.org"), username = "someone 5"), - MatrixUser(id = UserId("@someone_6:matrix.org"), username = "someone 6"), - MatrixUser(id = UserId("@someone_7:matrix.org"), username = "someone 7"), - MatrixUser(id = UserId("@someone_8:matrix.org"), username = "someone 8"), - MatrixUser(id = UserId("@someone_9:matrix.org"), username = "someone 9"), - MatrixUser(id = UserId("@someone_10:matrix.org"), username = "someone 10"), -) +fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList() diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt deleted file mode 100644 index bc355a0a26..0000000000 --- a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/UserListView.kt +++ /dev/null @@ -1,311 +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.features.userlist.api - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -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.components.avatar.Avatar -import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.preview.ElementPreviewDark -import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.theme.components.SearchBar -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow -import io.element.android.libraries.matrix.ui.components.MatrixUserRow -import io.element.android.libraries.matrix.ui.model.MatrixUser -import io.element.android.libraries.matrix.ui.model.getBestName -import kotlinx.collections.immutable.ImmutableList -import io.element.android.libraries.ui.strings.R as StringR - -@Composable -fun UserListView( - state: UserListState, - modifier: Modifier = Modifier, - onUserSelected: (MatrixUser) -> Unit = {}, - onUserDeselected: (MatrixUser) -> Unit = {}, -) { - Column( - modifier = modifier, - ) { - SearchUserBar( - modifier = Modifier.fillMaxWidth(), - query = state.searchQuery, - results = state.searchResults, - selectedUsers = state.selectedUsers, - selectedUsersListState = state.selectedUsersListState, - active = state.isSearchActive, - isMultiSelectionEnabled = state.isMultiSelectionEnabled, - onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, - onUserSelected = { - state.eventSink(UserListEvents.AddToSelection(it)) - onUserSelected(it) - }, - onUserDeselected = { - state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) - }, - ) - - if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) { - SelectedUsersList( - listState = state.selectedUsersListState, - modifier = Modifier.padding(16.dp), - selectedUsers = state.selectedUsers, - onUserRemoved = { - state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) - }, - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SearchUserBar( - query: String, - results: ImmutableList, - selectedUsers: ImmutableList, - selectedUsersListState: LazyListState, - active: Boolean, - isMultiSelectionEnabled: Boolean, - modifier: Modifier = Modifier, - placeHolderTitle: String = stringResource(StringR.string.common_search_for_someone), - onActiveChanged: (Boolean) -> Unit = {}, - onTextChanged: (String) -> Unit = {}, - onUserSelected: (MatrixUser) -> Unit = {}, - onUserDeselected: (MatrixUser) -> Unit = {}, -) { - val focusManager = LocalFocusManager.current - - if (!active) { - onTextChanged("") - focusManager.clearFocus() - } - - SearchBar( - query = query, - onQueryChange = onTextChanged, - onSearch = { focusManager.clearFocus() }, - active = active, - onActiveChange = onActiveChanged, - modifier = modifier - .padding(horizontal = if (!active) 16.dp else 0.dp), - placeholder = { - Text( - text = placeHolderTitle, - modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) - ) - }, - leadingIcon = if (active) { - { BackButton(onClick = { onActiveChanged(false) }) } - } else { - null - }, - trailingIcon = when { - active && query.isNotEmpty() -> { - { - IconButton(onClick = { onTextChanged("") }) { - Icon(Icons.Default.Close, stringResource(StringR.string.action_clear)) - } - } - } - !active -> { - { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource(StringR.string.action_search), - modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) - ) - } - } - else -> null - }, - colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent), - content = { - if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { - SelectedUsersList( - listState = selectedUsersListState, - modifier = Modifier.padding(16.dp), - selectedUsers = selectedUsers, - onUserRemoved = onUserDeselected, - ) - } - - LazyColumn { - if (isMultiSelectionEnabled) { - items(results) { matrixUser -> - SearchMultipleUsersResultItem( - modifier = Modifier.fillMaxWidth(), - matrixUser = matrixUser, - isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null, - onCheckedChange = { checked -> - if (checked) { - onUserSelected(matrixUser) - } else { - onUserDeselected(matrixUser) - } - } - ) - } - } else { - items(results) { matrixUser -> - SearchSingleUserResultItem( - modifier = Modifier.fillMaxWidth(), - matrixUser = matrixUser, - onClick = { onUserSelected(matrixUser) } - ) - } - } - } - }, - ) -} - -@Composable -fun SearchMultipleUsersResultItem( - matrixUser: MatrixUser, - isUserSelected: Boolean, - modifier: Modifier = Modifier, - onCheckedChange: (Boolean) -> Unit, -) { - CheckableMatrixUserRow( - checked = isUserSelected, - modifier = modifier, - matrixUser = matrixUser, - avatarSize = AvatarSize.Custom(36.dp), - onCheckedChange = onCheckedChange, - ) -} - -@Composable -fun SearchSingleUserResultItem( - matrixUser: MatrixUser, - modifier: Modifier = Modifier, - onClick: () -> Unit = {}, -) { - MatrixUserRow( - modifier = modifier.clickable(onClick = onClick), - matrixUser = matrixUser, - avatarSize = AvatarSize.Custom(36.dp), - ) -} - -@Composable -fun SelectedUsersList( - listState: LazyListState, - selectedUsers: ImmutableList, - modifier: Modifier = Modifier, - onUserRemoved: (MatrixUser) -> Unit = {}, -) { - LazyRow( - state = listState, - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(24.dp), - ) { - items(selectedUsers.toList()) { matrixUser -> - SelectedUser( - matrixUser = matrixUser, - onUserRemoved = onUserRemoved, - ) - } - } -} - -@Composable -fun SelectedUser( - matrixUser: MatrixUser, - modifier: Modifier = Modifier, - onUserRemoved: (MatrixUser) -> Unit, -) { - Box(modifier = modifier.width(56.dp)) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Avatar(matrixUser.avatarData.copy(size = AvatarSize.Custom(56.dp))) - Text( - text = matrixUser.getBestName(), - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = MaterialTheme.typography.bodyLarge, - ) - } - IconButton( - modifier = Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary) - .size(20.dp) - .align(Alignment.TopEnd), - onClick = { onUserRemoved(matrixUser) } - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(id = StringR.string.action_remove), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } -} - -@Preview -@Composable -internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = - ElementPreviewLight { ContentToPreview(state) } - -@Preview -@Composable -internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = - ElementPreviewDark { ContentToPreview(state) } - -@Composable -private fun ContentToPreview(state: UserListState) { - UserListView(state = state) -} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchMultipleUsersResultItem.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchMultipleUsersResultItem.kt new file mode 100644 index 0000000000..7267af1f9b --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchMultipleUsersResultItem.kt @@ -0,0 +1,61 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.layout.Column +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.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.MatrixUser + +@Composable +fun SearchMultipleUsersResultItem( + matrixUser: MatrixUser, + isUserSelected: Boolean, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit = {}, +) { + CheckableMatrixUserRow( + checked = isUserSelected, + modifier = modifier, + matrixUser = matrixUser, + avatarSize = AvatarSize.Custom(36.dp), + onCheckedChange = onCheckedChange, + ) +} + +@Preview +@Composable +internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true) + SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = false) + } +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchSingleUserResultItem.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchSingleUserResultItem.kt new file mode 100644 index 0000000000..67af583473 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchSingleUserResultItem.kt @@ -0,0 +1,55 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.clickable +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.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.components.MatrixUserRow +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.MatrixUser + +@Composable +fun SearchSingleUserResultItem( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) { + MatrixUserRow( + modifier = modifier.clickable(onClick = onClick), + matrixUser = matrixUser, + avatarSize = AvatarSize.Custom(36.dp), + ) +} + +@Preview +@Composable +internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SearchSingleUserResultItem(matrixUser = aMatrixUser()) +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchUserBar.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchUserBar.kt new file mode 100644 index 0000000000..baee8c5b2a --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SearchUserBar.kt @@ -0,0 +1,144 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.SearchBar +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.ui.strings.R +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchUserBar( + query: String, + results: ImmutableList, + selectedUsers: ImmutableList, + active: Boolean, + isMultiSelectionEnabled: Boolean, + modifier: Modifier = Modifier, + placeHolderTitle: String = stringResource(R.string.common_search_for_someone), + onActiveChanged: (Boolean) -> Unit = {}, + onTextChanged: (String) -> Unit = {}, + onUserSelected: (MatrixUser) -> Unit = {}, + onUserDeselected: (MatrixUser) -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + + if (!active) { + onTextChanged("") + focusManager.clearFocus() + } + + SearchBar( + query = query, + onQueryChange = onTextChanged, + onSearch = { focusManager.clearFocus() }, + active = active, + onActiveChange = onActiveChanged, + modifier = modifier + .padding(horizontal = if (!active) 16.dp else 0.dp), + placeholder = { + Text( + text = placeHolderTitle, + modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) + ) + }, + leadingIcon = if (active) { + { BackButton(onClick = { onActiveChanged(false) }) } + } else { + null + }, + trailingIcon = when { + active && query.isNotEmpty() -> { + { + IconButton(onClick = { onTextChanged("") }) { + Icon(Icons.Default.Close, stringResource(R.string.action_clear)) + } + } + } + !active -> { + { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.action_search), + modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) + ) + } + } + else -> null + }, + colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent), + content = { + if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { + SelectedUsersList( + contentPadding = PaddingValues(16.dp), + selectedUsers = selectedUsers, + autoScroll = true, + onUserRemoved = onUserDeselected, + ) + } + + LazyColumn { + if (isMultiSelectionEnabled) { + items(results) { matrixUser -> + SearchMultipleUsersResultItem( + modifier = Modifier.fillMaxWidth(), + matrixUser = matrixUser, + isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null, + onCheckedChange = { checked -> + if (checked) { + onUserSelected(matrixUser) + } else { + onUserDeselected(matrixUser) + } + } + ) + } + } else { + items(results) { matrixUser -> + SearchSingleUserResultItem( + modifier = Modifier.fillMaxWidth(), + matrixUser = matrixUser, + onClick = { onUserSelected(matrixUser) } + ) + } + } + } + }, + ) +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUser.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUser.kt new file mode 100644 index 0000000000..666a0c5265 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUser.kt @@ -0,0 +1,94 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.MaterialTheme +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.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.model.MatrixUser +import io.element.android.libraries.matrix.ui.model.getBestName +import io.element.android.libraries.ui.strings.R + +@Composable +fun SelectedUser( + matrixUser: MatrixUser, + modifier: Modifier = Modifier, + onUserRemoved: (MatrixUser) -> Unit = {}, +) { + Box(modifier = modifier.width(56.dp)) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar(matrixUser.avatarData.copy(size = AvatarSize.Custom(56.dp))) + Text( + text = matrixUser.getBestName(), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.bodyLarge, + ) + } + IconButton( + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .size(20.dp) + .align(Alignment.TopEnd), + onClick = { onUserRemoved(matrixUser) } + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(id = R.string.action_remove), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +@Preview +@Composable +internal fun SelectedUserLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedUserDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SelectedUser(aMatrixUser()) +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUsersList.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUsersList.kt new file mode 100644 index 0000000000..6909345a51 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/SelectedUsersList.kt @@ -0,0 +1,87 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.element.android.features.userlist.api.aListOfSelectedUsers +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.model.MatrixUser +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SelectedUsersList( + selectedUsers: ImmutableList, + modifier: Modifier = Modifier, + autoScroll: Boolean = false, + contentPadding: PaddingValues = PaddingValues(0.dp), + onUserRemoved: (MatrixUser) -> Unit = {}, +) { + val lazyListState = rememberLazyListState() + if (autoScroll) { + var currentSize by rememberSaveable { mutableStateOf(selectedUsers.size) } + LaunchedEffect(selectedUsers.size) { + val isItemAdded = selectedUsers.size > currentSize + if (isItemAdded) { + lazyListState.animateScrollToItem(selectedUsers.lastIndex) + } + currentSize = selectedUsers.size + } + } + + LazyRow( + state = lazyListState, + modifier = modifier, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + items(selectedUsers.toList()) { matrixUser -> + SelectedUser( + matrixUser = matrixUser, + onUserRemoved = onUserRemoved, + ) + } + } +} + +@Preview +@Composable +internal fun SelectedUsersListLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + SelectedUsersList( + selectedUsers = aListOfSelectedUsers(), + ) +} diff --git a/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/UserListView.kt b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/UserListView.kt new file mode 100644 index 0000000000..6532dea2b6 --- /dev/null +++ b/features/userlist/api/src/main/kotlin/io/element/android/features/userlist/api/components/UserListView.kt @@ -0,0 +1,90 @@ +/* + * 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.features.userlist.api.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.features.userlist.api.UserListEvents +import io.element.android.features.userlist.api.UserListState +import io.element.android.features.userlist.api.UserListStateProvider +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.matrix.ui.model.MatrixUser + +@Composable +fun UserListView( + state: UserListState, + modifier: Modifier = Modifier, + onUserSelected: (MatrixUser) -> Unit = {}, + onUserDeselected: (MatrixUser) -> Unit = {}, +) { + Column( + modifier = modifier, + ) { + SearchUserBar( + modifier = Modifier.fillMaxWidth(), + query = state.searchQuery, + results = state.searchResults, + selectedUsers = state.selectedUsers, + active = state.isSearchActive, + isMultiSelectionEnabled = state.isMultiSelectionEnabled, + onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, + onUserSelected = { + state.eventSink(UserListEvents.AddToSelection(it)) + onUserSelected(it) + }, + onUserDeselected = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onUserDeselected(it) + }, + ) + + if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) { + SelectedUsersList( + contentPadding = PaddingValues(16.dp), + selectedUsers = state.selectedUsers, + autoScroll = true, + onUserRemoved = { + state.eventSink(UserListEvents.RemoveFromSelection(it)) + onUserDeselected(it) + }, + ) + } + } +} + +@Preview +@Composable +internal fun UserListViewLightPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = + ElementPreviewLight { ContentToPreview(state) } + +@Preview +@Composable +internal fun UserListViewDarkPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = + ElementPreviewDark { ContentToPreview(state) } + +@Composable +private fun ContentToPreview(state: UserListState) { + UserListView(state = state) +} diff --git a/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt b/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt index 567d183e15..06f4caed86 100644 --- a/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt +++ b/features/userlist/impl/src/main/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenter.kt @@ -16,26 +16,25 @@ package io.element.android.features.userlist.impl -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState 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.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.squareup.anvil.annotations.ContributesBinding import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListEvents +import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.userlist.api.UserListState -import io.element.android.features.userlist.api.UserListPresenter import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.UserId @@ -43,28 +42,27 @@ import io.element.android.libraries.matrix.ui.model.MatrixUser import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch class DefaultUserListPresenter @AssistedInject constructor( @Assisted val args: UserListPresenterArgs, - @Assisted val matrixUserDataSource: MatrixUserDataSource, + @Assisted val userListDataSource: UserListDataSource, + @Assisted val userListDataStore: UserListDataStore, ) : UserListPresenter { @AssistedFactory @ContributesBinding(SessionScope::class) interface DefaultUserListFactory : UserListPresenter.Factory { - override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): DefaultUserListPresenter + override fun create( + args: UserListPresenterArgs, + userListDataSource: UserListDataSource, + userListDataStore: UserListDataStore, + ): DefaultUserListPresenter } @Composable override fun present(): UserListState { - val localCoroutineScope = rememberCoroutineScope() var isSearchActive by rememberSaveable { mutableStateOf(false) } - val selectedUsers: MutableState> = remember { - mutableStateOf(persistentListOf()) - } - val selectedUsersListState = rememberLazyListState() + val selectedUsers = userListDataStore.selectedUsers().collectAsState(emptyList()) var searchQuery by rememberSaveable { mutableStateOf("") } val searchResults: MutableState> = remember { mutableStateOf(persistentListOf()) @@ -74,13 +72,8 @@ class DefaultUserListPresenter @AssistedInject constructor( when (event) { is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active is UserListEvents.UpdateSearchQuery -> searchQuery = event.query - is UserListEvents.AddToSelection -> { - if (event.matrixUser !in selectedUsers.value) { - selectedUsers.value = selectedUsers.value.plus(event.matrixUser).toImmutableList() - } - localCoroutineScope.scrollToFirstSelectedUser(selectedUsersListState) - } - is UserListEvents.RemoveFromSelection -> selectedUsers.value = selectedUsers.value.minus(event.matrixUser).toImmutableList() + is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser) + is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser) } } @@ -100,8 +93,7 @@ class DefaultUserListPresenter @AssistedInject constructor( return UserListState( searchQuery = searchQuery, searchResults = searchResults.value, - selectedUsers = selectedUsers.value.reversed().toImmutableList(), - selectedUsersListState = selectedUsersListState, + selectedUsers = selectedUsers.value.toImmutableList(), isSearchActive = isSearchActive, selectionMode = args.selectionMode, eventSink = ::handleEvents, @@ -110,16 +102,12 @@ class DefaultUserListPresenter @AssistedInject constructor( private suspend fun performSearch(query: String): ImmutableList { val isMatrixId = MatrixPatterns.isUserId(query) - val results = matrixUserDataSource.search(query).toMutableList() + val results = userListDataSource.search(query).toMutableList() if (isMatrixId && results.none { it.id.value == query }) { - val getProfileResult: MatrixUser? = matrixUserDataSource.getProfile(UserId(query)) + val getProfileResult: MatrixUser? = userListDataSource.getProfile(UserId(query)) val profile = getProfileResult ?: MatrixUser(UserId(query)) results.add(0, profile) } return results.toImmutableList() } - - private fun CoroutineScope.scrollToFirstSelectedUser(listState: LazyListState) = launch { - listState.scrollToItem(index = 0) - } } diff --git a/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt index 1cae186d56..29432c2ff1 100644 --- a/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt +++ b/features/userlist/impl/src/test/kotlin/io/element/android/features/userlist/impl/DefaultUserListPresenterTests.kt @@ -21,10 +21,11 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.userlist.api.SelectionMode +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListEvents import io.element.android.features.userlist.api.UserListPresenterArgs -import io.element.android.features.userlist.api.SelectionMode -import io.element.android.features.userlist.test.FakeMatrixUserDataSource +import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser @@ -37,13 +38,14 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class DefaultUserListPresenterTests { - private val userListDataSource = FakeMatrixUserDataSource() + private val userListDataSource = FakeUserListDataSource() @Test fun `present - initial state for single selection`() = runTest { val presenter = DefaultUserListPresenter( UserListPresenterArgs(selectionMode = SelectionMode.Single), - userListDataSource + userListDataSource, + UserListDataStore(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -61,7 +63,8 @@ class DefaultUserListPresenterTests { fun `present - initial state for multiple selection`() = runTest { val presenter = DefaultUserListPresenter( UserListPresenterArgs(selectionMode = SelectionMode.Multiple), - userListDataSource + userListDataSource, + UserListDataStore(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -79,7 +82,8 @@ class DefaultUserListPresenterTests { fun `present - update search query`() = runTest { val presenter = DefaultUserListPresenter( UserListPresenterArgs(selectionMode = SelectionMode.Single), - userListDataSource + userListDataSource, + UserListDataStore(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() @@ -111,7 +115,8 @@ class DefaultUserListPresenterTests { val presenter = DefaultUserListPresenter( UserListPresenterArgs(selectionMode = SelectionMode.Single), - userListDataSource + userListDataSource, + UserListDataStore(), ) moleculeFlow(RecompositionClock.Immediate) { presenter.present() diff --git a/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListDataSource.kt similarity index 90% rename from features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt rename to features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListDataSource.kt index db6297ec05..ba0ccd2c89 100644 --- a/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeMatrixUserDataSource.kt +++ b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListDataSource.kt @@ -16,11 +16,11 @@ package io.element.android.features.userlist.test -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.ui.model.MatrixUser -class FakeMatrixUserDataSource : MatrixUserDataSource { +class FakeUserListDataSource : UserListDataSource { private var searchResult: List = emptyList() private var profile: MatrixUser? = null diff --git a/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListPresenterFactory.kt b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListPresenterFactory.kt index 37d50c303c..00966a5082 100644 --- a/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListPresenterFactory.kt +++ b/features/userlist/test/src/main/kotlin/io/element/android/features/userlist/test/FakeUserListPresenterFactory.kt @@ -16,7 +16,8 @@ package io.element.android.features.userlist.test -import io.element.android.features.userlist.api.MatrixUserDataSource +import io.element.android.features.userlist.api.UserListDataSource +import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenterArgs @@ -24,5 +25,9 @@ class FakeUserListPresenterFactory( private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter() ) : UserListPresenter.Factory { - override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter = fakeUserListPresenter + override fun create( + args: UserListPresenterArgs, + userListDataSource: UserListDataSource, + userListDataStore: UserListDataStore, + ): UserListPresenter = fakeUserListPresenter } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 509a8614b8..5783cff201 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ android_gradle_plugin = "7.4.2" kotlin = "1.8.10" ksp = "1.8.10-1.0.9" -molecule = "0.8.0" +molecule = "0.9.0" # AndroidX material = "1.8.0" @@ -19,7 +19,7 @@ activity = "1.7.0" startup = "1.1.1" # Compose -compose_bom = "2023.03.00" +compose_bom = "2023.04.00" composecompiler = "1.4.2" # Coroutines @@ -135,6 +135,8 @@ sqldelight-driver-jvm = { module = "com.squareup.sqldelight:sqlite-driver", vers sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.3" sqlite = "androidx.sqlite:sqlite:2.3.1" +unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" +gujun_span = "me.gujun.android:span:1.7" # Di inject = "javax.inject:javax.inject:1" 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 5f18f46827..c5bccc97f5 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 @@ -32,6 +32,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import io.element.android.libraries.androidutils.R import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat /** @@ -125,7 +126,7 @@ fun startNotificationSettingsIntent(context: Context, activityResultLauncher: Ac fun openAppSettingsPage( activity: Activity, - noActivityFoundMessage: String, + noActivityFoundMessage: String = activity.getString(R.string.error_no_compatible_app_found), ) { try { activity.startActivity( @@ -156,7 +157,7 @@ fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String fun startAddGoogleAccountIntent( context: Context, activityResultLauncher: ActivityResultLauncher, - noActivityFoundMessage: String, + noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found), ) { val intent = Intent(Settings.ACTION_ADD_ACCOUNT) intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf("com.google")) @@ -171,7 +172,7 @@ fun startAddGoogleAccountIntent( fun startInstallFromSourceIntent( context: Context, activityResultLauncher: ActivityResultLauncher, - noActivityFoundMessage: String, + noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found), ) { val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) .setData(Uri.parse(String.format("package:%s", context.packageName))) @@ -189,7 +190,7 @@ fun startSharePlainTextIntent( text: String, subject: String? = null, extraTitle: String? = null, - noActivityFoundMessage: String, + noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found), ) { val share = Intent(Intent.ACTION_SEND) share.type = "text/plain" @@ -217,7 +218,7 @@ fun startSharePlainTextIntent( fun startImportTextFromFileIntent( context: Context, activityResultLauncher: ActivityResultLauncher, - noActivityFoundMessage: String, + noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found), ) { val intent = Intent(Intent.ACTION_GET_CONTENT).apply { type = "text/plain" diff --git a/libraries/androidutils/src/main/res/values-es/translations.xml b/libraries/androidutils/src/main/res/values-es/translations.xml new file mode 100644 index 0000000000..80b2b88347 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-es/translations.xml @@ -0,0 +1,4 @@ + + + "No se encontró ninguna aplicación compatible con esta acción." + \ No newline at end of file diff --git a/libraries/androidutils/src/main/res/values-it/translations.xml b/libraries/androidutils/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..03aaf3ffd1 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ + + + "Non è stata trovata alcuna app compatibile per gestire questa azione." + \ No newline at end of file diff --git a/libraries/androidutils/src/main/res/values-ro/translations.xml b/libraries/androidutils/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..d2149227c5 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ro/translations.xml @@ -0,0 +1,4 @@ + + + "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune." + \ No newline at end of file diff --git a/libraries/androidutils/src/main/res/values/localazy.xml b/libraries/androidutils/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..0599c8922b --- /dev/null +++ b/libraries/androidutils/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "No compatible app was found to handle this action." + \ No newline at end of file diff --git a/libraries/deeplink/build.gradle.kts b/libraries/deeplink/build.gradle.kts new file mode 100644 index 0000000000..3fe27bfd1c --- /dev/null +++ b/libraries/deeplink/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.deeplink" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.libraries.di) + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(projects.libraries.matrix.api) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.robolectric) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt new file mode 100644 index 0000000000..df26ef2fa0 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/Constants.kt @@ -0,0 +1,20 @@ +/* + * 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.deeplink + +internal const val SCHEME = "elementx" +internal const val HOST = "open" diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt new file mode 100644 index 0000000000..71aa7ebddd --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeepLinkCreator.kt @@ -0,0 +1,39 @@ +/* + * 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.deeplink + +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 javax.inject.Inject + +class DeepLinkCreator @Inject constructor() { + fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?): String { + return buildString { + append("$SCHEME://$HOST/") + append(sessionId.value) + if (roomId != null) { + append("/") + append(roomId.value) + if (threadId != null) { + append("/") + append(threadId.value) + } + } + } + } +} diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt new file mode 100644 index 0000000000..d393a37c16 --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkData.kt @@ -0,0 +1,27 @@ +/* + * 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.deeplink + +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 + +data class DeeplinkData( + val sessionId: SessionId, + val roomId: RoomId? = null, + val threadId: ThreadId? = null, +) diff --git a/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt new file mode 100644 index 0000000000..9f217f497e --- /dev/null +++ b/libraries/deeplink/src/main/kotlin/io/element/android/libraries/deeplink/DeeplinkParser.kt @@ -0,0 +1,47 @@ +/* + * 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.deeplink + +import android.content.Intent +import android.net.Uri +import io.element.android.libraries.matrix.api.core.asRoomId +import io.element.android.libraries.matrix.api.core.asSessionId +import io.element.android.libraries.matrix.api.core.asThreadId +import javax.inject.Inject + +class DeeplinkParser @Inject constructor() { + fun getFromIntent(intent: Intent): DeeplinkData? { + return intent + .takeIf { it.action == Intent.ACTION_VIEW } + ?.data + ?.toDeeplinkData() + } + + private fun Uri.toDeeplinkData(): DeeplinkData? { + if (scheme != SCHEME) return null + if (host != HOST) return null + val pathBits = path.orEmpty().split("/").drop(1) + val sessionId = pathBits.elementAtOrNull(0)?.asSessionId() ?: return null + val roomId = pathBits.elementAtOrNull(1)?.asRoomId() + val threadId = pathBits.elementAtOrNull(2)?.asThreadId() + return DeeplinkData( + sessionId = sessionId, + roomId = roomId, + threadId = threadId, + ) + } +} diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt new file mode 100644 index 0000000000..730bdde248 --- /dev/null +++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeepLinkCreatorTest.kt @@ -0,0 +1,37 @@ +/* + * 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.deeplink + +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_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import org.junit.Test + +class DeepLinkCreatorTest { + + @Test + fun create() { + val sut = DeepLinkCreator() + assertThat(sut.create(A_SESSION_ID, null, null)) + .isEqualTo("elementx://open/@alice:server.org") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") + assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) + .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") + } +} diff --git a/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt new file mode 100644 index 0000000000..259cd6fde1 --- /dev/null +++ b/libraries/deeplink/src/test/kotlin/io/element/android/libraries/deeplink/DeeplinkParserTest.kt @@ -0,0 +1,78 @@ +/* + * 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.deeplink + +import android.content.Intent +import androidx.core.net.toUri +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_SESSION_ID +import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.tests.testutils.assertNullOrThrow +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DeeplinkParserTest { + companion object { + const val A_URI = + "elementx://open/@alice:server.org" + const val A_URI_WITH_ROOM = + "elementx://open/@alice:server.org/!aRoomId:domain" + const val A_URI_WITH_ROOM_WITH_THREAD = + "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId" + } + + private val sut = DeeplinkParser() + + @Test + fun `nominal cases`() { + assertThat(sut.getFromIntent(createIntent(A_URI))) + .isEqualTo(DeeplinkData(A_SESSION_ID, null, null)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM))) + .isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, null)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD))) + .isEqualTo(DeeplinkData(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID)) + } + + @Test + fun `error cases`() { + val sut = DeeplinkParser() + // Bad scheme + assertThat(sut.getFromIntent(createIntent("x://open/@alice:server.org"))).isNull() + // Bad host + assertThat(sut.getFromIntent(createIntent("elementx://close/@alice:server.org"))).isNull() + // No session Id + assertThat(sut.getFromIntent(createIntent("elementx://open"))).isNull() + // Invalid sessionId + assertNullOrThrow { + sut.getFromIntent(createIntent("elementx://open/alice:server.org")) + } + // Empty sessionId + assertNullOrThrow { + sut.getFromIntent(createIntent("elementx://open//")) + } + } + + private fun createIntent(uri: String): Intent { + return Intent().apply { + action = Intent.ACTION_VIEW + data = uri.toUri() + } + } +} diff --git a/libraries/designsystem/build.gradle.kts b/libraries/designsystem/build.gradle.kts index 45430e5d82..4533c950b1 100644 --- a/libraries/designsystem/build.gradle.kts +++ b/libraries/designsystem/build.gradle.kts @@ -19,6 +19,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.ksp) + id("kotlin-parcelize") } android { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 7f2cddea09..3bf4f7d0b4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -16,15 +16,20 @@ package io.element.android.libraries.designsystem.components.avatar +import android.os.Parcelable import androidx.compose.runtime.Immutable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize @Immutable +@Parcelize data class AvatarData( val id: String, val name: String?, val url: String? = null, + @IgnoredOnParcel val size: AvatarSize = AvatarSize.MEDIUM -) { +) : Parcelable { fun getInitial(): String { val firstChar = name?.firstOrNull() ?: id.getOrNull(1) ?: '?' return firstChar.uppercase() diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 5e4bad531c..61c76d80c3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -27,8 +27,8 @@ open class AvatarDataProvider : PreviewParameterProvider { ) } -fun anAvatarData() = AvatarData( +fun anAvatarData(id: String = "@id_of_alice:server.org", name: String = "Alice") = AvatarData( // Let's the id not start with a 'a'. - id = "@id_of_alice:server.org", - name = "Alice", + id = id, + name = name, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt new file mode 100644 index 0000000000..3e703962a0 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/RadioButton.kt @@ -0,0 +1,63 @@ +/* + * 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.designsystem.theme.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.RadioButtonColors +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight + +@Composable +fun RadioButton( + selected: Boolean, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: RadioButtonColors = RadioButtonDefaults.colors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + androidx.compose.material3.RadioButton( + selected = selected, + onClick = onClick, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview +@Composable +internal fun RadioButtonLightPreview() = ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +internal fun RadioButtonDarkPreview() = ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + Column { + RadioButton(selected = false, onClick = {}) + RadioButton(selected = true, onClick = {}) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 5a9e2a4bb6..41e86f2701 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.api 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.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService @@ -32,8 +33,9 @@ interface MatrixClient : Closeable { val sessionId: SessionId val roomSummaryDataSource: RoomSummaryDataSource fun getRoom(roomId: RoomId): MatrixRoom? - suspend fun createDM(userId: UserId): Result fun findDM(userId: UserId): MatrixRoom? + suspend fun createRoom(createRoomParams: CreateRoomParameters): Result + suspend fun createDM(userId: UserId): Result fun startSync() fun stopSync() fun mediaResolver(): MediaResolver diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt index dc5e7ab16a..a6e6d7edb3 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/EventId.kt @@ -16,9 +16,14 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline value class EventId(val value: String) : Serializable -fun String.asEventId() = EventId(this) +fun String.asEventId() = if (BuildConfig.DEBUG && !MatrixPatterns.isEventId(this)) { + error("`$this` is not a valid event Id") +} else { + EventId(this) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt index f295fe81b9..a30baadb55 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/MatrixPatterns.kt @@ -91,6 +91,14 @@ object MatrixPatterns { PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER ) + /** + * Tells if a string is a valid session Id. This is an alias for [isUserId] + * + * @param str the string to test + * @return true if the string is a valid session id + */ + fun isSessionId(str: String?) = isUserId(str) + /** * Tells if a string is a valid user Id. * @@ -101,6 +109,14 @@ object MatrixPatterns { return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER } + /** + * Tells if a string is a valid space id. This is an alias for [isRoomId] + * + * @param str the string to test + * @return true if the string is a valid space Id + */ + fun isSpaceId(str: String?) = isRoomId(str) + /** * Tells if a string is a valid room id. * @@ -134,6 +150,14 @@ object MatrixPatterns { str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4) } + /** + * Tells if a string is a valid thread id. This is an alias for [isEventId]. + * + * @param str the string to test + * @return true if the string is a valid thread id. + */ + fun isThreadId(str: String?) = isEventId(str) + /** * Tells if a string is a valid group id. * diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt index df10038b05..9ff8c1d76d 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomId.kt @@ -16,9 +16,18 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline -value class RoomId(val value: String) : Serializable +value class RoomId(val value: String) : Serializable { + override fun toString(): String { + return value + } +} -fun String.asRoomId() = RoomId(this) +fun String.asRoomId() = if (BuildConfig.DEBUG && !MatrixPatterns.isRoomId(this)) { + error("`$this` is not a valid room Id") +} else { + RoomId(this) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt index bea1f3c671..f6d45dc6df 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SessionId.kt @@ -16,6 +16,12 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig + typealias SessionId = UserId -fun String.asSessionId() = SessionId(this) +fun String.asSessionId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSessionId(this)) { + error("`$this` is not a valid session Id") +} else { + SessionId(this) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt index 849dd7d637..342a13d693 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/SpaceId.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline @@ -26,4 +27,8 @@ value class SpaceId(val value: String) : Serializable */ val MAIN_SPACE = SpaceId("!mainSpace") -fun String.asSpaceId() = SpaceId(this) +fun String.asSpaceId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSpaceId(this)) { + error("`$this` is not a valid space Id") +} else { + SpaceId(this) +} 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 57fc187406..7599cd8a6a 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 @@ -16,9 +16,14 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline value class ThreadId(val value: String) : Serializable -fun String.asThreadId() = ThreadId(this) +fun String.asThreadId() = if (BuildConfig.DEBUG && !MatrixPatterns.isThreadId(this)) { + error("`$this` is not a valid thread Id") +} else { + ThreadId(this) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt index 216faade45..93810f8815 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/UserId.kt @@ -16,9 +16,18 @@ package io.element.android.libraries.matrix.api.core +import io.element.android.libraries.matrix.api.BuildConfig import java.io.Serializable @JvmInline -value class UserId(val value: String) : Serializable +value class UserId(val value: String) : Serializable { + override fun toString(): String { + return value + } +} -fun String.asUserId() = UserId(this) +fun String.asUserId() = if (BuildConfig.DEBUG && !MatrixPatterns.isUserId(this)) { + error("`$this` is not a valid user Id") +} else { + UserId(this) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt new file mode 100644 index 0000000000..c65aae6156 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt @@ -0,0 +1,30 @@ +/* + * 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.matrix.api.createroom + +import io.element.android.libraries.matrix.api.core.UserId + +data class CreateRoomParameters( + val name: String?, + val topic: String? = null, + val isEncrypted: Boolean, + val isDirect: Boolean = false, + val visibility: RoomVisibility, + val preset: RoomPreset, + val invite: List? = null, + val avatar: String? = null, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt new file mode 100644 index 0000000000..c2254e395f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomPreset.kt @@ -0,0 +1,22 @@ +/* + * 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.matrix.api.createroom + +enum class RoomPreset { + PRIVATE_CHAT, + PUBLIC_CHAT, + TRUSTED_PRIVATE_CHAT, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt new file mode 100644 index 0000000000..d2715363e8 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/RoomVisibility.kt @@ -0,0 +1,21 @@ +/* + * 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.matrix.api.createroom + +enum class RoomVisibility { + PUBLIC, + PRIVATE, +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt index 92e2bcef1d..2d8456be38 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/permalink/PermalinkBuilder.kt @@ -19,8 +19,14 @@ package io.element.android.libraries.matrix.api.permalink import io.element.android.libraries.matrix.api.config.MatrixConfiguration import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.UserId object PermalinkBuilder { + + private const val ROOM_PATH = "room/" + private const val USER_PATH = "user/" + private const val GROUP_PATH = "group/" + private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.matrixToPermalinkBaseUrl).also { var baseUrl = it if (!baseUrl.endsWith("/")) { @@ -31,6 +37,21 @@ object PermalinkBuilder { } } + fun permalinkForUser(userId: UserId): Result { + return if (MatrixPatterns.isUserId(userId.value)) { + val url = buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(USER_PATH) + } + append(userId.value) + } + Result.success(url) + } else { + Result.failure(PermalinkBuilderError.InvalidRoomAlias) + } + } + fun permalinkForRoomAlias(roomAlias: String): Result { return if (MatrixPatterns.isRoomAlias(roomAlias)) { Result.success(permalinkForRoomAliasOrId(roomAlias)) @@ -49,10 +70,18 @@ object PermalinkBuilder { private fun permalinkForRoomAliasOrId(value: String): String { val id = escapeId(value) - return permalinkBaseUrl + id + return buildString { + append(permalinkBaseUrl) + if (!isMatrixTo()) { + append(ROOM_PATH) + } + append(id) + } } private fun escapeId(value: String) = value.replace("/", "%2F") + + private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.matrixToPermalinkBaseUrl) } sealed class PermalinkBuilderError : Throwable() { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index ef2291f8ce..71a642965f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -18,4 +18,5 @@ package io.element.android.libraries.matrix.api.pusher interface PushersService { suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result + suspend fun unsetHttpPusher(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt index 331dc70de0..f9726f5670 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.room 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.UserId import io.element.android.libraries.matrix.api.timeline.MatrixTimeline import kotlinx.coroutines.flow.Flow import java.io.Closeable @@ -38,6 +39,8 @@ interface MatrixRoom: Closeable { suspend fun memberCount(): Int + fun getMember(userId: UserId): RoomMember? + fun syncUpdateFlow(): Flow fun timeline(): MatrixTimeline diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 66de9bd627..9c3e771f22 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -16,6 +16,10 @@ package io.element.android.libraries.matrix.api.room +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class RoomMember( val userId: String, val displayName: String?, @@ -23,8 +27,9 @@ data class RoomMember( val membership: RoomMembershipState, val isNameAmbiguous: Boolean, val powerLevel: Long, - val normalizedPowerLevel: Long -) + val normalizedPowerLevel: Long, + val isIgnored: Boolean, +) : Parcelable enum class RoomMembershipState { BAN, INVITE, JOIN, KNOCK, LEAVE 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 a2fe3f872a..fe3dbfb911 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 @@ -20,6 +20,9 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters +import io.element.android.libraries.matrix.api.createroom.RoomPreset +import io.element.android.libraries.matrix.api.createroom.RoomVisibility import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService @@ -42,10 +45,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientDelegate -import org.matrix.rustcomponents.sdk.CreateRoomParameters import org.matrix.rustcomponents.sdk.RequiredState -import org.matrix.rustcomponents.sdk.RoomPreset -import org.matrix.rustcomponents.sdk.RoomVisibility import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncMode import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters @@ -55,6 +55,9 @@ import org.matrix.rustcomponents.sdk.use import timber.log.Timber import java.io.File import java.util.concurrent.atomic.AtomicBoolean +import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters +import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset +import org.matrix.rustcomponents.sdk.RoomVisibility as RustRoomVisibility class RustMatrixClient constructor( private val client: Client, @@ -175,24 +178,46 @@ class RustMatrixClient constructor( return roomId?.let { getRoom(it) } } - override suspend fun createDM(userId: UserId): Result = - withContext(dispatchers.io) { - runCatching { - val roomId = client.createRoom( - CreateRoomParameters( - name = null, - topic = null, - isEncrypted = true, - isDirect = true, - visibility = RoomVisibility.PRIVATE, - preset = RoomPreset.TRUSTED_PRIVATE_CHAT, - invite = listOf(userId.value), - avatar = null, - ) + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result = withContext(dispatchers.io) { + runCatching { + val roomId = client.createRoom( + RustCreateRoomParameters( + name = createRoomParams.name, + topic = createRoomParams.topic, + isEncrypted = createRoomParams.isEncrypted, + isDirect = createRoomParams.isDirect, + visibility = when (createRoomParams.visibility) { + RoomVisibility.PUBLIC -> RustRoomVisibility.PUBLIC + RoomVisibility.PRIVATE -> RustRoomVisibility.PRIVATE + }, + preset = when (createRoomParams.preset) { + RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT + RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT + RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT + }, + invite = createRoomParams.invite?.map { it.value }, + avatar = createRoomParams.avatar, ) - RoomId(roomId) - } + ) + RoomId(roomId) } + } + + override suspend fun createDM(userId: UserId): Result = withContext(dispatchers.io) { + runCatching { + val roomId = client.createRoom( + RustCreateRoomParameters( + name = null, + isEncrypted = true, + isDirect = true, + visibility = RustRoomVisibility.PRIVATE, + preset = RustRoomPreset.TRUSTED_PRIVATE_CHAT, + invite = listOf(userId.value), + ) + ) + RoomId(roomId) + } + } override fun mediaResolver(): MediaResolver = mediaResolver diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt index 4eaafef12d..60ca4df311 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -53,4 +53,9 @@ class RustPushersService( } } } + + override suspend fun unsetHttpPusher(): Result { + // TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK. + return Result.success(Unit) + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt index ebc93780ee..1fe0de6080 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomMemberMapper.kt @@ -32,6 +32,7 @@ object RoomMemberMapper { roomMember.isNameAmbiguous(), roomMember.powerLevel(), roomMember.normalizedPowerLevel(), + roomMember.isIgnored(), ) fun mapMembership(membershipState: RustMembershipState): RoomMembershipState = 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 948c4e9bd1..8498aa1376 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 @@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.data.tryOrNull 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.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.timeline.MatrixTimeline @@ -68,6 +69,10 @@ class RustMatrixRoom( return members().size } + override fun getMember(userId: UserId): RoomMember? { + return cachedMembers.firstOrNull { it.userId == userId.value } + } + override fun syncUpdateFlow(): Flow { return slidingSyncUpdateFlow .filter { 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 b570e9d9ae..5cb79564ba 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 @@ -20,6 +20,7 @@ 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.api.core.UserId +import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.media.MediaResolver import io.element.android.libraries.matrix.api.notification.NotificationService import io.element.android.libraries.matrix.api.pusher.PushersService @@ -54,16 +55,21 @@ class FakeMatrixClient( return FakeMatrixRoom(roomId) } + override fun findDM(userId: UserId): MatrixRoom? { + return findDmResult + } + + override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result { + delay(100) + return Result.success(A_ROOM_ID) + } + override suspend fun createDM(userId: UserId): Result { delay(100) createDmFailure?.let { throw it } return createDmResult } - override fun findDM(userId: UserId): MatrixRoom? { - return findDmResult - } - override fun startSync() = Unit override fun stopSync() = Unit 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 ec749025dc..6b4d83e43b 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 @@ -31,9 +31,9 @@ val A_USER_ID = UserId("@alice:server.org") val A_USER_ID_2 = UserId("@bob:server.org") val A_SESSION_ID = SessionId(A_USER_ID.value) val A_SESSION_ID_2 = SessionId(A_USER_ID_2.value) -val A_SPACE_ID = SpaceId("!aSpaceId") -val A_ROOM_ID = RoomId("!aRoomId") -val A_ROOM_ID_2 = RoomId("!aRoomId2") +val A_SPACE_ID = SpaceId("!aSpaceId:domain") +val A_ROOM_ID = RoomId("!aRoomId:domain") +val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") val A_THREAD_ID = ThreadId("\$aThreadId") val AN_EVENT_ID = EventId("\$anEventId") val AN_EVENT_ID_2 = EventId("\$anEventId2") 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 77087d132f..6ff7e4a20b 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 @@ -21,4 +21,5 @@ import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData class FakePushersService : PushersService { override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) + override suspend fun unsetHttpPusher(): Result = Result.success(Unit) } 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 ffc7a60363..97f06bcabf 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 @@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.test.room 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.UserId import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -85,6 +86,10 @@ class FakeMatrixRoom( } } + override fun getMember(userId: UserId): RoomMember? { + return members.firstOrNull { it.userId == userId.value } + } + override suspend fun sendMessage(message: String): Result { delay(100) return Result.success(Unit) diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index c9dead5eb4..6d2109d3f8 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -20,6 +20,7 @@ plugins { id("io.element.android-compose-library") alias(libs.plugins.anvil) alias(libs.plugins.ksp) + id("kotlin-parcelize") } android { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt index b95b93d348..e79e78802a 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/MatrixUserProvider.kt @@ -35,7 +35,20 @@ open class MatrixUserProvider : PreviewParameterProvider { fun aMatrixUser(id: String = "@id_of_alice:server.org", userName: String = "Alice") = MatrixUser( id = UserId(id), username = userName, - avatarData = anAvatarData() + avatarData = anAvatarData(id, userName) +) + +fun aMatrixUserList() = listOf( + aMatrixUser("@alice:server.org", "Alice"), + aMatrixUser("@bob:server.org", "Bob"), + aMatrixUser("@carol:server.org", "Carol"), + aMatrixUser("@david:server.org", "David"), + aMatrixUser("@eve:server.org", "Eve"), + aMatrixUser("@justin:server.org", "Justin"), + aMatrixUser("@mallory:server.org", "Mallory"), + aMatrixUser("@susie:server.org", "Susie"), + aMatrixUser("@victor:server.org", "Victor"), + aMatrixUser("@walter:server.org", "Walter"), ) open class MatrixUserWithNullProvider : PreviewParameterProvider { diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUser.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUser.kt index c524cbb733..f0ddb30a93 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUser.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUser.kt @@ -16,16 +16,19 @@ package io.element.android.libraries.matrix.ui.model +import android.os.Parcelable import androidx.compose.runtime.Immutable import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.parcelize.Parcelize +@Parcelize @Immutable data class MatrixUser( val id: UserId, val username: String? = null, val avatarData: AvatarData = AvatarData(id.value, username), -) +) : Parcelable fun MatrixUser.getBestName(): String { return username?.takeIf { it.isNotEmpty() } ?: id.value diff --git a/libraries/permissions/api/build.gradle.kts b/libraries/permissions/api/build.gradle.kts index d86f790a44..99bc60a2eb 100644 --- a/libraries/permissions/api/build.gradle.kts +++ b/libraries/permissions/api/build.gradle.kts @@ -16,6 +16,7 @@ plugins { id("io.element.android-compose-library") + alias(libs.plugins.ksp) } android { @@ -27,4 +28,6 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) + + ksp(libs.showkase.processor) } 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 382d8e9653..30f19aa31c 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 @@ -32,48 +32,52 @@ fun PermissionsView( ) { if (state.showDialog.not()) return - if (state.permissionGranted) { - // Notification Granted, nothing to do - } else if (state.permissionAlreadyDenied) { - // In this case, tell the user to go to the settings - ConfirmationDialog( - modifier = modifier, - title = "System", - content = "In order to let the application display notification, please grant the permission to the system settings", - submitText = "Open settings", - onSubmitClicked = { - state.eventSink.invoke(PermissionsEvents.CloseDialog) - openSystemSettings() - }, - onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, - ) - } else { - val textToShow = if (state.shouldShowRationale) { - // TODO Move to state - // If the user has denied the permission but the rationale can be shown, - // then gently explain why the app requires this permission - // permissions_rationale_msg_notification - "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." - } else { - // TODO Move to state - // If it's the first time the user lands on this feature, or the user - // doesn't want to be asked again for this permission, explain that the - // permission is required - "To be able to receive notifications, please grant the permission." + when { + state.permissionGranted -> { + // Notification Granted, nothing to do + } + state.permissionAlreadyDenied -> { + // In this case, tell the user to go to the settings + ConfirmationDialog( + modifier = modifier, + title = "System", + content = "In order to let the application display notification, please grant the permission to the system settings", + submitText = "Open settings", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.CloseDialog) + openSystemSettings() + }, + onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + ) + } + else -> { + val textToShow = if (state.shouldShowRationale) { + // TODO Move to state + // If the user has denied the permission but the rationale can be shown, + // then gently explain why the app requires this permission + // permissions_rationale_msg_notification + "To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages." + } else { + // TODO Move to state + // If it's the first time the user lands on this feature, or the user + // doesn't want to be asked again for this permission, explain that the + // permission is required + "To be able to receive notifications, please grant the permission." + } + ConfirmationDialog( + modifier = modifier, + title = "Notifications", + content = textToShow, + submitText = "Request permission", + onSubmitClicked = { + state.eventSink.invoke(PermissionsEvents.OpenSystemDialog) + }, + onCancelClicked = { + state.eventSink.invoke(PermissionsEvents.CloseDialog) + }, + onDismiss = {} + ) } - ConfirmationDialog( - modifier = modifier, - title = "Notifications", - content = textToShow, - submitText = "Request permission", - onSubmitClicked = { - state.eventSink.invoke(PermissionsEvents.OpenSystemDialog) - }, - onCancelClicked = { - state.eventSink.invoke(PermissionsEvents.CloseDialog) - }, - onDismiss = {} - ) } } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt index 5cf74aca90..e93b74d934 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsViewStateProvider.kt @@ -23,7 +23,8 @@ open class PermissionsViewStateProvider : PreviewParameterProvider get() = sequenceOf( aPermissionsState(), - // Add other state here + aPermissionsState().copy(shouldShowRationale = true), + aPermissionsState().copy(permissionAlreadyDenied = true), ) } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index 9e91869549..50012f01b7 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -33,6 +33,7 @@ import com.squareup.anvil.annotations.ContributesBinding import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.di.AppScope import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter @@ -40,6 +41,8 @@ import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.coroutines.launch import timber.log.Timber +private val loggerTag = LoggerTag("DefaultPermissionsPresenter") + class DefaultPermissionsPresenter @AssistedInject constructor( @Assisted val permission: String, private val permissionsStore: PermissionsStore, @@ -71,7 +74,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( var permissionState: PermissionState? = null fun onPermissionResult(result: Boolean) { - Timber.tag("PERMISSION").w("onPermissionResult: $result") + Timber.tag(loggerTag.value).d("onPermissionResult: $result") localCoroutineScope.launch { permissionsStore.setPermissionAsked(permission, true) } @@ -79,7 +82,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( if (!result) { // Should show rational true -> denied. if (permissionState?.status?.shouldShowRationale == true) { - Timber.tag("PERMISSION").w("onPermissionResult: reset the store") + Timber.tag(loggerTag.value).d("onPermissionResult: setPermissionDenied to true") localCoroutineScope.launch { permissionsStore.setPermissionDenied(permission, true) } @@ -102,7 +105,6 @@ class DefaultPermissionsPresenter @AssistedInject constructor( val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } fun handleEvents(event: PermissionsEvents) { - Timber.tag("PERMISSION").w("New event: $event") when (event) { PermissionsEvents.CloseDialog -> { showDialog.value = false @@ -123,7 +125,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( permissionAlreadyDenied = isAlreadyDenied, eventSink = ::handleEvents ).also { - Timber.tag("PERMISSION").w("New state: $it") + Timber.tag(loggerTag.value).d("New state: $it") } } diff --git a/libraries/push/api/build.gradle.kts b/libraries/push/api/build.gradle.kts index be1bbc13ef..0c5df8fb25 100644 --- a/libraries/push/api/build.gradle.kts +++ b/libraries/push/api/build.gradle.kts @@ -26,4 +26,5 @@ dependencies { implementation(libs.androidx.corektx) implementation(libs.coroutines.core) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushproviders.api) } 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 cf5792be35..83504e7a8a 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 @@ -17,12 +17,22 @@ package io.element.android.libraries.push.api import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PushProvider interface PushService { + // TODO Move away fun notificationStyleChanged() - // Ensure pusher is registered - suspend fun registerFirebasePusher(matrixClient: MatrixClient) + fun getAvailablePushProviders(): List + /** + * Will unregister any previous pusher and register a new one with the provided [PushProvider]. + * + * The method has effect only if the [PushProvider] is different than the current one. + */ + suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) + + // TODO Move away suspend fun testPush() } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt deleted file mode 100644 index 3fb4841aba..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/model/BackgroundSyncMode.kt +++ /dev/null @@ -1,48 +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.api.model - -/** - * Different strategies for Background sync, only applicable to F-Droid version of the app. - */ -enum class BackgroundSyncMode { - /** - * In this mode background syncs are scheduled via Workers, meaning that the system will have control on the periodicity - * of syncs when battery is low or when the phone is idle (sync will occur in allowed maintenance windows). After completion - * the sync work will schedule another one. - */ - FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY, - - /** - * This mode requires the app to be exempted from battery optimization. Alarms will be launched and will wake up the app - * in order to perform the background sync as a foreground service. After completion the service will schedule another alarm - */ - FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME, - - /** - * The app won't sync in background. - */ - FDROID_BACKGROUND_SYNC_MODE_DISABLED; - - companion object { - const val DEFAULT_SYNC_DELAY_SECONDS = 60 - const val DEFAULT_SYNC_TIMEOUT_SECONDS = 6 - - fun fromString(value: String?): BackgroundSyncMode = values().firstOrNull { it.name == value } - ?: FDROID_BACKGROUND_SYNC_MODE_DISABLED - } -} diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt index d2a6bda0b0..f478034063 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/store/PushDataStore.kt @@ -16,26 +16,8 @@ package io.element.android.libraries.push.api.store -import io.element.android.libraries.push.api.model.BackgroundSyncMode import kotlinx.coroutines.flow.Flow interface PushDataStore { val pushCounterFlow: Flow - - // TODO Move all those settings to the per user store... - fun areNotificationEnabledForDevice(): Boolean - fun setNotificationEnabledForDevice(enabled: Boolean) - - fun backgroundSyncTimeOut(): Int - fun setBackgroundSyncTimeout(timeInSecond: Int) - fun backgroundSyncDelay(): Int - fun setBackgroundSyncDelay(timeInSecond: Int) - fun isBackgroundSyncEnabled(): Boolean - fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) - fun getFdroidSyncBackgroundMode(): BackgroundSyncMode - - /** - * Return true if Pin code is disabled, or if user set the settings to see full notification content. - */ - fun useCompleteNotificationFormat(): Boolean } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 0968ac4344..81ba07fc63 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -43,21 +43,20 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) + api(projects.libraries.pushproviders.api) + api(projects.libraries.pushstore.api) api(projects.libraries.push.api) implementation(projects.services.analytics.api) implementation(projects.services.appnavstate.api) implementation(projects.services.toolbox.api) - api("me.gujun.android:span:1.7") { + api(libs.gujun.span) { exclude(group = "com.android.support", module = "support-annotations") } - implementation(platform(libs.google.firebase.bom)) - implementation("com.google.firebase:firebase-messaging-ktx") - - // UnifiedPush - api("com.github.UnifiedPush:android-connector:2.1.1") + // TODO Temporary use the deprecated LocalBroadcastManager, to be changed later. + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") testImplementation(libs.test.junit) testImplementation(libs.test.mockk) diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml index a386a89caf..6085ffe4a4 100644 --- a/libraries/push/impl/src/main/AndroidManifest.xml +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -14,63 +14,15 @@ ~ limitations under the License. --> - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 7924ddf996..28a58d1058 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 @@ -20,27 +20,41 @@ import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.push.impl.config.PushConfig -import io.element.android.libraries.push.impl.log.pushLoggerTag +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager -import timber.log.Timber +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPushService @Inject constructor( private val notificationDrawerManager: NotificationDrawerManager, private val pushersManager: PushersManager, - private val fcmHelper: FcmHelper, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, ) : PushService { override fun notificationStyleChanged() { notificationDrawerManager.notificationStyleChanged() } - override suspend fun registerFirebasePusher(matrixClient: MatrixClient) { - val pushKey = fcmHelper.getFcmToken() ?: return Unit.also { - Timber.tag(pushLoggerTag.value).w("Unable to register pusher, Firebase token is not known.") + override fun getAvailablePushProviders(): List { + return pushProviders.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) { + val userPushStore = userPushStoreFactory.create(matrixClient.sessionId) + val currentPushProviderName = userPushStore.getPushProviderName() + if (currentPushProviderName != pushProvider.name) { + // Unregister previous one if any + pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient) } - pushersManager.registerPusher(matrixClient, pushKey, PushConfig.pusher_http_url) + pushProvider.registerWith(matrixClient, distributor) + // Store new value + userPushStore.setPushProviderName(pushProvider.name) } override suspend fun testPush() { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt deleted file mode 100644 index 9b8b6c2281..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/FcmHelper.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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. - */ - -package io.element.android.libraries.push.impl - -interface FcmHelper { - fun isFirebaseAvailable(): Boolean - - /** - * Retrieves the FCM registration token. - * - * @return the FCM token or null if not received from FCM. - */ - fun getFcmToken(): String? - - /** - * Store FCM token to the SharedPrefs. - * - * @param token the token to store. - */ - fun storeFcmToken(token: String?) - - /** - * onNewToken may not be called on application upgrade, so ensure my shared pref is set. - * - * @param pushersManager the instance to register the pusher on. - * @param registerPusher whether the pusher should be registered. - */ - fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) - - /* - fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) - - fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) - */ -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt deleted file mode 100755 index 6c73607196..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/GoogleFcmHelper.kt +++ /dev/null @@ -1,104 +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 - -import android.content.Context -import android.content.SharedPreferences -import android.widget.Toast -import androidx.core.content.edit -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability -import com.google.firebase.messaging.FirebaseMessaging -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 kotlinx.coroutines.runBlocking -import timber.log.Timber -import javax.inject.Inject - -/** - * This class store the FCM token in SharedPrefs and ensure this token is retrieved. - * It has an alter ego in the fdroid variant. - */ -@ContributesBinding(AppScope::class) -class GoogleFcmHelper @Inject constructor( - @ApplicationContext private val context: Context, - @DefaultPreferences private val sharedPrefs: SharedPreferences, -) : FcmHelper { - override fun isFirebaseAvailable(): Boolean = true - - override fun getFcmToken(): String? { - return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) - } - - override fun storeFcmToken(token: String?) { - sharedPrefs.edit { - putString(PREFS_KEY_FCM_TOKEN, token) - } - } - - override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) { - // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' - if (checkPlayServices(context)) { - try { - FirebaseMessaging.getInstance().token - .addOnSuccessListener { token -> - storeFcmToken(token) - if (registerPusher) { - runBlocking {// TODO - pushersManager.enqueueRegisterPusherWithFcmKey(token) - } - } - } - .addOnFailureListener { e -> - Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") - } - } catch (e: Throwable) { - Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") - } - } else { - Toast.makeText(context, R.string.push_no_valid_google_play_services_apk_android, Toast.LENGTH_SHORT).show() - Timber.e("No valid Google Play Services found. Cannot use FCM.") - } - } - - /** - * Check the device to make sure it has the Google Play Services APK. If - * it doesn't, display a dialog that allows users to download the APK from - * the Google Play Store or enable it in the device's system settings. - */ - private fun checkPlayServices(context: Context): Boolean { - val apiAvailability = GoogleApiAvailability.getInstance() - val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) - return resultCode == ConnectionResult.SUCCESS - } - - /* - override fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) { - // No op - } - - override fun onEnterBackground(activeSessionHolder: ActiveSessionHolder) { - // No op - } - */ - - companion object { - private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN" - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt index f1ac346909..04d2875328 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt @@ -16,20 +16,19 @@ package io.element.android.libraries.push.impl +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.matrix.api.MatrixClient -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.SessionId import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData -import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.config.PushConfig import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest -import io.element.android.libraries.push.impl.userpushstore.UserPushStoreFactory -import io.element.android.libraries.push.impl.userpushstore.isFirebase -import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.api.toUserList +import io.element.android.libraries.push.providers.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.services.toolbox.api.appname.AppNameProvider import timber.log.Timber import javax.inject.Inject @@ -38,62 +37,32 @@ internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" private val loggerTag = LoggerTag("PushersManager", pushLoggerTag) +@ContributesBinding(AppScope::class) class PushersManager @Inject constructor( - private val unifiedPushHelper: UnifiedPushHelper, // private val localeProvider: LocaleProvider, private val appNameProvider: AppNameProvider, // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, private val pushClientSecret: PushClientSecret, - private val sessionStore: SessionStore, - private val matrixAuthenticationService: MatrixAuthenticationService, private val userPushStoreFactory: UserPushStoreFactory, - private val fcmHelper: FcmHelper, -) { +) : PusherSubscriber { + // TODO Move this to the PushProvider API suspend fun testPush() { pushGatewayNotifyRequest.execute( PushGatewayNotifyRequest.Params( - url = unifiedPushHelper.getPushGateway() ?: return, + url = "TODO", // unifiedPushHelper.getPushGateway() ?: return, appId = PushConfig.pusher_app_id, - pushKey = unifiedPushHelper.getEndpointOrToken().orEmpty(), + pushKey = "TODO", // unifiedPushHelper.getEndpointOrToken().orEmpty(), eventId = TEST_EVENT_ID ) ) } - suspend fun enqueueRegisterPusherWithFcmKey(pushKey: String) { - // return onNewFirebaseToken(pushKey, PushConfig.pusher_http_url) - TODO() - } - - suspend fun onNewUnifiedPushEndpoint( - pushKey: String, - gateway: String - ) { - TODO() - } - - suspend fun onNewFirebaseToken(firebaseToken: String) { - fcmHelper.storeFcmToken(firebaseToken) - - // Register the pusher for all the sessions - sessionStore.getAllSessions().toUserList().forEach { userId -> - val userDataStore = userPushStoreFactory.create(userId) - if (userDataStore.isFirebase()) { - matrixAuthenticationService.restoreSession(SessionId(userId)).getOrNull()?.use { client -> - registerPusher(client, firebaseToken, PushConfig.pusher_http_url) - } - } else { - Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") - } - } - } - /** * Register a pusher to the server if not done yet. */ - suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { - val userDataStore = userPushStoreFactory.create(matrixClient.sessionId.value) + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + val userDataStore = userPushStoreFactory.create(matrixClient.sessionId) if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { Timber.tag(loggerTag.value).d("Unnecessary to register again the same pusher") } else { @@ -162,9 +131,8 @@ class PushersManager @Inject constructor( // currentSession.pushersService().removeEmailPusher(email) } - suspend fun unregisterPusher(pushKey: String) { - // val currentSession = activeSessionHolder.getSafeActiveSession() ?: return - // currentSession.pushersService().removeHttpPusher(pushKey, PushConfig.pusher_app_id) + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + matrixClient.pushersService().unsetHttpPusher() } companion object { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt deleted file mode 100644 index 12ed3f1993..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushHelper.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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. - */ - -package io.element.android.libraries.push.impl - -import android.content.Context -import io.element.android.libraries.androidutils.system.getApplicationLabel -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.push.impl.config.PushConfig -import io.element.android.services.toolbox.api.strings.StringProvider -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.unifiedpush.android.connector.UnifiedPush -import timber.log.Timber -import java.net.URL -import javax.inject.Inject - -class UnifiedPushHelper @Inject constructor( - @ApplicationContext private val context: Context, - private val unifiedPushStore: UnifiedPushStore, - // private val matrix: Matrix, - private val fcmHelper: FcmHelper, - private val stringProvider: StringProvider, -) { - - /* TODO EAx - @MainThread - fun showSelectDistributorDialog( - context: Context, - onDistributorSelected: (String) -> Unit, - ) { - val internalDistributorName = stringProvider.getString( - if (fcmHelper.isFirebaseAvailable()) { - R.string.push_distributor_firebase_android - } else { - R.string.push_distributor_background_sync_android - } - ) - - val distributors = UnifiedPush.getDistributors(context) - val distributorsName = distributors.map { - if (it == context.packageName) { - internalDistributorName - } else { - context.getApplicationLabel(it) - } - } - - MaterialAlertDialogBuilder(context) - .setTitle(stringProvider.getString(R.string.push_choose_distributor_dialog_title_android)) - .setItems(distributorsName.toTypedArray()) { _, which -> - val distributor = distributors[which] - onDistributorSelected(distributor) - } - .setOnCancelListener { - // we do not want to change the distributor on behalf of the user - if (UnifiedPush.getDistributor(context).isEmpty()) { - // By default, use internal solution (fcm/background sync) - onDistributorSelected(context.packageName) - } - } - .setCancelable(true) - .show() - } - - */ - - @Serializable - internal data class DiscoveryResponse( - @SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush() - ) - - @Serializable - internal data class DiscoveryUnifiedPush( - @SerialName("gateway") val gateway: String = "" - ) - - suspend fun storeCustomOrDefaultGateway( - endpoint: String, - onDoneRunnable: Runnable? = null - ) { - // if we use the embedded distributor, - // register app_id type upfcm on sygnal - // the pushkey if FCM key - if (UnifiedPush.getDistributor(context) == context.packageName) { - unifiedPushStore.storePushGateway(PushConfig.pusher_http_url) - onDoneRunnable?.run() - return - } - /* TODO EAx UnifiedPush - // else, unifiedpush, and pushkey is an endpoint - val gateway = PushConfig.default_push_gateway_http_url - val parsed = URL(endpoint) - val custom = "${parsed.protocol}://${parsed.host}/_matrix/push/v1/notify" - Timber.i("Testing $custom") - try { - val response = matrix.rawService().getUrl(custom, CacheStrategy.NoCache) - tryOrNull { Json.decodeFromString(response) } - ?.let { discoveryResponse -> - if (discoveryResponse.unifiedpush.gateway == "matrix") { - Timber.d("Using custom gateway") - unifiedPushStore.storePushGateway(custom) - onDoneRunnable?.run() - return - } - } - } catch (e: Throwable) { - Timber.d(e, "Cannot try custom gateway") - } - unifiedPushStore.storePushGateway(gateway) - onDoneRunnable?.run() - - */ - } - - fun getExternalDistributors(): List { - return UnifiedPush.getDistributors(context) - .filterNot { it == context.packageName } - } - - fun getCurrentDistributorName(): String { - return when { - isEmbeddedDistributor() -> stringProvider.getString(R.string.push_distributor_firebase_android) - isBackgroundSync() -> stringProvider.getString(R.string.push_distributor_background_sync_android) - else -> context.getApplicationLabel(UnifiedPush.getDistributor(context)) - } - } - - fun isEmbeddedDistributor(): Boolean { - return isInternalDistributor() && fcmHelper.isFirebaseAvailable() - } - - fun isBackgroundSync(): Boolean { - return isInternalDistributor() && !fcmHelper.isFirebaseAvailable() - } - - private fun isInternalDistributor(): Boolean { - return UnifiedPush.getDistributor(context).isEmpty() || - UnifiedPush.getDistributor(context) == context.packageName - } - - fun getPrivacyFriendlyUpEndpoint(): String? { - val endpoint = getEndpointOrToken() - if (endpoint.isNullOrEmpty()) return null - if (isEmbeddedDistributor()) { - return endpoint - } - return try { - val parsed = URL(endpoint) - "${parsed.protocol}://${parsed.host}/***" - } catch (e: Exception) { - Timber.e(e, "Error parsing unifiedpush endpoint") - null - } - } - - fun getEndpointOrToken(): String? { - return if (isEmbeddedDistributor()) fcmHelper.getFcmToken() - else unifiedPushStore.getEndpoint() - } - - fun getPushGateway(): String? { - return if (isEmbeddedDistributor()) PushConfig.pusher_http_url - else unifiedPushStore.getPushGateway() - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt index d2d1c96506..823dd7f693 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/config/PushConfig.kt @@ -17,25 +17,8 @@ package io.element.android.libraries.push.impl.config object PushConfig { - /** - * It is the push gateway for FCM embedded distributor. - * Note: pusher_http_url should have path '/_matrix/push/v1/notify' --> - */ - const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify" - - /** - * It is the push gateway for UnifiedPush. - * Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify' - */ - const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" - /** * Note: pusher_app_id cannot exceed 64 chars. */ const val pusher_app_id: String = "im.vector.app.android" - - /** - * Set to true to allow external push distributor such as Ntfy. - */ - const val allowExternalUnifiedPushDistributors: Boolean = false } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt index 52abb3f6a4..ce2b1d3fce 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/intent/IntentProvider.kt @@ -25,9 +25,7 @@ interface IntentProvider { /** * Provide an intent to start the application. */ - fun getMainIntent(): Intent - - fun getIntent( + fun getViewIntent( sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, 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 56054bbef8..bc20d49917 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 @@ -34,7 +34,6 @@ data class NotificationActionIds @Inject constructor( val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION" val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION" val dismissRoom = "${buildMeta.applicationId}.NotificationActions.DISMISS_ROOM_NOTIF_ACTION" - val tapToView = "${buildMeta.applicationId}.NotificationActions.TAP_TO_VIEW_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/NotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt index 8d3bfda4c3..cf0307fbd9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDrawerManager.kt @@ -70,7 +70,8 @@ class NotificationDrawerManager @Inject constructor( private var currentAppNavigationState: AppNavigationState? = null private val firstThrottler = FirstThrottler(200) - private var useCompleteNotificationFormat = pushDataStore.useCompleteNotificationFormat() + // TODO EAx add a setting per user for this + private var useCompleteNotificationFormat = true init { handlerThread.start() @@ -111,12 +112,6 @@ class NotificationDrawerManager @Inject constructor( } private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - if (!pushDataStore.areNotificationEnabledForDevice()) { - Timber.i("Notification are disabled for this device") - return - } - // If we support multi session, event list should be per userId - // Currently only manage single session if (buildMeta.lowPrivacyLoggingEnabled) { Timber.d("onNotifiableEventReceived(): $notifiableEvent") } else { @@ -185,7 +180,7 @@ class NotificationDrawerManager @Inject constructor( // TODO EAx Must be per account fun notificationStyleChanged() { updateEvents { - val newSettings = pushDataStore.useCompleteNotificationFormat() + val newSettings = true // pushDataStore.useCompleteNotificationFormat() if (newSettings != useCompleteNotificationFormat) { // Settings has changed, remove all current notifications notificationRenderer.cancelAllNotifications() 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 index 60fb1baa05..862b4784ac 100644 --- 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 @@ -34,6 +34,7 @@ data class NotificationEventQueue constructor( * 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 ) { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt index add8fd74eb..8aeaa998ca 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationUtils.kt @@ -482,15 +482,11 @@ class NotificationUtils @Inject constructor( } private fun buildOpenRoomIntent(sessionId: SessionId, roomId: RoomId): PendingIntent? { - val roomIntent = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = null) - roomIntent.action = actionIds.tapToView - // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that - roomIntent.data = createIgnoredUri("openRoom?$sessionId&$roomId") - + val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = null) return PendingIntent.getActivity( context, clock.epochMillis().toInt(), - roomIntent, + intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } @@ -498,22 +494,17 @@ class NotificationUtils @Inject constructor( private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: ThreadId?): PendingIntent? { val sessionId = roomInfo.sessionId val roomId = roomInfo.roomId - val threadIntentTap = intentProvider.getIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) - threadIntentTap.action = actionIds.tapToView - // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that - threadIntentTap.data = createIgnoredUri("openThread?$sessionId&$roomId&$threadId") - + val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = roomId, threadId = threadId) return PendingIntent.getActivity( context, clock.epochMillis().toInt(), - threadIntentTap, + intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } private fun buildOpenHomePendingIntentForSummary(sessionId: SessionId): PendingIntent { - val intent = intentProvider.getIntent(sessionId = sessionId, roomId = null, threadId = null) - intent.data = createIgnoredUri("tapSummary?$sessionId") + val intent = intentProvider.getViewIntent(sessionId = sessionId, roomId = null, threadId = null) return PendingIntent.getActivity( context, clock.epochMillis().toInt(), diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt similarity index 83% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index b4c9716b62..09afe0a861 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -21,19 +21,22 @@ import android.content.Intent import android.os.Handler import android.os.Looper import androidx.localbroadcastmanager.content.LocalBroadcastManager -import io.element.android.libraries.androidutils.network.WifiDetector +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.di.ApplicationContext import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.push.api.store.PushDataStore import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.clientsecret.PushClientSecret import io.element.android.libraries.push.impl.log.pushLoggerTag import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver import io.element.android.libraries.push.impl.notifications.NotificationActionIds import io.element.android.libraries.push.impl.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.push.providers.api.PushData +import io.element.android.libraries.push.providers.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 @@ -43,20 +46,20 @@ import javax.inject.Inject private val loggerTag = LoggerTag("PushHandler", pushLoggerTag) -class PushHandler @Inject constructor( +@ContributesBinding(AppScope::class) +class DefaultPushHandler @Inject constructor( private val notificationDrawerManager: NotificationDrawerManager, private val notifiableEventResolver: NotifiableEventResolver, - private val pushDataStore: PushDataStore, private val defaultPushDataStore: DefaultPushDataStore, + private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val actionIds: NotificationActionIds, @ApplicationContext private val context: Context, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, -) { +) : PushHandler { private val coroutineScope = CoroutineScope(SupervisorJob()) - private val wifiDetector: WifiDetector = WifiDetector(context) // UI handler private val mUIHandler by lazy { @@ -68,7 +71,7 @@ class PushHandler @Inject constructor( * * @param pushData the data received in the push. */ - suspend fun handle(pushData: PushData) { + override suspend fun handle(pushData: PushData) { Timber.tag(loggerTag.value).d("## handling pushData") if (buildMeta.lowPrivacyLoggingEnabled) { @@ -84,12 +87,6 @@ class PushHandler @Inject constructor( return } - // TODO EAx Should be per user - if (!pushDataStore.areNotificationEnabledForDevice()) { - Timber.tag(loggerTag.value).i("Notification are disabled for this device") - return - } - mUIHandler.post { coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } } @@ -134,6 +131,13 @@ class PushHandler @Inject constructor( return } + val userPushStore = userPushStoreFactory.create(userId) + if (!userPushStore.areNotificationEnabledForDevice()) { + // 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 + } + notificationDrawerManager.onNotifiableEventReceived(notificationData) } 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/store/DefaultPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt index ffbd575aa4..22faa91453 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/store/DefaultPushDataStore.kt @@ -17,20 +17,15 @@ package io.element.android.libraries.push.impl.store import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.core.data.tryOrNull 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.di.SingleIn -import io.element.android.libraries.push.api.model.BackgroundSyncMode import io.element.android.libraries.push.api.store.PushDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -42,7 +37,6 @@ private val Context.dataStore: DataStore by preferencesDataStore(na @ContributesBinding(AppScope::class) class DefaultPushDataStore @Inject constructor( @ApplicationContext private val context: Context, - @DefaultPreferences private val defaultPrefs: SharedPreferences, ) : PushDataStore { private val pushCounter = intPreferencesKey("push_counter") @@ -56,94 +50,4 @@ class DefaultPushDataStore @Inject constructor( settings[pushCounter] = currentCounterValue + 1 } } - - override fun areNotificationEnabledForDevice(): Boolean { - return defaultPrefs.getBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, true) - } - - override fun setNotificationEnabledForDevice(enabled: Boolean) { - defaultPrefs.edit { - putBoolean(SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY, enabled) - } - } - - override fun backgroundSyncTimeOut(): Int { - return tryOrNull { - // The xml pref is saved as a string so use getString and parse - defaultPrefs.getString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, null)?.toInt() - } ?: BackgroundSyncMode.DEFAULT_SYNC_TIMEOUT_SECONDS - } - - override fun setBackgroundSyncTimeout(timeInSecond: Int) { - defaultPrefs - .edit() - .putString(SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY, timeInSecond.toString()) - .apply() - } - - override fun backgroundSyncDelay(): Int { - return tryOrNull { - // The xml pref is saved as a string so use getString and parse - defaultPrefs.getString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, null)?.toInt() - } ?: BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS - } - - override fun setBackgroundSyncDelay(timeInSecond: Int) { - defaultPrefs - .edit() - .putString(SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY, timeInSecond.toString()) - .apply() - } - - override fun isBackgroundSyncEnabled(): Boolean { - return getFdroidSyncBackgroundMode() != BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED - } - - override fun setFdroidSyncBackgroundMode(mode: BackgroundSyncMode) { - defaultPrefs - .edit() - .putString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, mode.name) - .apply() - } - - override fun getFdroidSyncBackgroundMode(): BackgroundSyncMode { - return try { - val strPref = defaultPrefs - .getString(SETTINGS_FDROID_BACKGROUND_SYNC_MODE, BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY.name) - BackgroundSyncMode.values().firstOrNull { it.name == strPref } ?: BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY - } catch (e: Throwable) { - BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_BATTERY - } - } - - /** - * Return true if Pin code is disabled, or if user set the settings to see full notification content. - */ - override fun useCompleteNotificationFormat(): Boolean { - return true - /* - return !useFlagPinCode() || - defaultPrefs.getBoolean(SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG, true) - */ - } - - companion object { - // notifications - const val SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY = "SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY" - const val SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY = "SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY" - - // background sync - const val SETTINGS_START_ON_BOOT_PREFERENCE_KEY = "SETTINGS_START_ON_BOOT_PREFERENCE_KEY" - const val SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_ENABLE_BACKGROUND_SYNC_PREFERENCE_KEY" - const val SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY = "SETTINGS_SET_SYNC_TIMEOUT_PREFERENCE_KEY" - const val SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY = "SETTINGS_SET_SYNC_DELAY_PREFERENCE_KEY" - - const val SETTINGS_FDROID_BACKGROUND_SYNC_MODE = "SETTINGS_FDROID_BACKGROUND_SYNC_MODE" - const val SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY = "SETTINGS_BACKGROUND_SYNC_PREFERENCE_KEY" - - const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG" - - // notification method - const val SETTINGS_NOTIFICATION_METHOD_KEY = "SETTINGS_NOTIFICATION_METHOD_KEY" - } } diff --git a/libraries/pushproviders/api/build.gradle.kts b/libraries/pushproviders/api/build.gradle.kts new file mode 100644 index 0000000000..08d397b383 --- /dev/null +++ b/libraries/pushproviders/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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-library") +} + +android { + namespace = "io.element.android.libraries.push.providers.api" +} + +dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/Distributor.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/Distributor.kt new file mode 100644 index 0000000000..3d4d0add28 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/Distributor.kt @@ -0,0 +1,22 @@ +/* + * 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.providers.api + +data class Distributor( + val value: String, + val name: String, +) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushData.kt similarity index 64% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt rename to libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushData.kt index 864155e522..b304d10b34 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/PushData.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushData.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.push +package io.element.android.libraries.push.providers.api import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -22,14 +22,14 @@ import io.element.android.libraries.matrix.api.core.RoomId /** * Represent parsed data that the app has received from a Push content. * - * @property eventId The Event ID. If not null, it will not be empty, and will have a valid format. - * @property roomId The Room ID. If not null, it will not be empty, and will have a valid format. + * @property eventId The Event Id. + * @property roomId The Room Id. * @property unread Number of unread message. - * @property clientSecret A client secret, used to determine which user should receive the notification. + * @property clientSecret data used when the pusher was configured, to be able to determine the session. */ data class PushData( - val eventId: EventId?, - val roomId: RoomId?, - val unread: Int?, - val clientSecret: String?, + val eventId: EventId, + val roomId: RoomId, + val unread: Int?, + val clientSecret: String?, ) diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushHandler.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushHandler.kt new file mode 100644 index 0000000000..09ca420a1f --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushHandler.kt @@ -0,0 +1,21 @@ +/* + * 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.providers.api + +interface PushHandler { + suspend fun handle(pushData: PushData) +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushProvider.kt new file mode 100644 index 0000000000..4ad0179403 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PushProvider.kt @@ -0,0 +1,51 @@ +/* + * 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.providers.api + +import io.element.android.libraries.matrix.api.MatrixClient + +/** + * This is the main API for this module. + */ +interface PushProvider { + /** + * Allow to sort providers, from lower index to higher index. + */ + val index: Int + + /** + * User friendly name. + */ + val name: String + + fun getDistributors(): List + + /** + * Register the pusher to the homeserver. + */ + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) + + /** + * Unregister the pusher. + */ + suspend fun unregister(matrixClient: MatrixClient) + + /** + * Attempt to troubleshoot the push provider. + */ + suspend fun troubleshoot(): Result +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PusherSubscriber.kt new file mode 100644 index 0000000000..0bf0f949f3 --- /dev/null +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/push/providers/api/PusherSubscriber.kt @@ -0,0 +1,24 @@ +/* + * 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.providers.api + +import io.element.android.libraries.matrix.api.MatrixClient + +interface PusherSubscriber { + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) +} diff --git a/libraries/pushproviders/firebase/README.md b/libraries/pushproviders/firebase/README.md new file mode 100644 index 0000000000..204ac6dd19 --- /dev/null +++ b/libraries/pushproviders/firebase/README.md @@ -0,0 +1,7 @@ +# Firebase + +## Configuration + +In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module. + +To be able to change the values in the file `firebase.xml` from this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services//values/values.xml` to import the generated values into the `firebase.xml` files. diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts new file mode 100644 index 0000000000..17f2071624 --- /dev/null +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * 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-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.push.providers.firebase" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(libs.androidx.corektx) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.pushproviders.api) + + api(platform(libs.google.firebase.bom)) + api("com.google.firebase:firebase-messaging-ktx") + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml new file mode 100644 index 0000000000..540f0e9bbe --- /dev/null +++ b/libraries/pushproviders/firebase/src/debug/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:def0a4e454042e9b00427c + diff --git a/libraries/pushproviders/firebase/src/main/AndroidManifest.xml b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..40dc254644 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/EnsureFcmTokenIsRetrievedUseCase.kt similarity index 66% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/EnsureFcmTokenIsRetrievedUseCase.kt index 9e9b28ecb8..e859976789 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/EnsureFcmTokenIsRetrievedUseCase.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/EnsureFcmTokenIsRetrievedUseCase.kt @@ -14,24 +14,22 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.firebase +package io.element.android.libraries.push.providers.firebase -import io.element.android.libraries.push.impl.FcmHelper -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.UnifiedPushHelper import javax.inject.Inject +// TODO class EnsureFcmTokenIsRetrievedUseCase @Inject constructor( - private val unifiedPushHelper: UnifiedPushHelper, - private val fcmHelper: FcmHelper, +// private val unifiedPushHelper: UnifiedPushHelper, +// private val fcmHelper: FcmHelper, // private val activeSessionHolder: ActiveSessionHolder, ) { - fun execute(pushersManager: PushersManager, registerPusher: Boolean) { - if (unifiedPushHelper.isEmbeddedDistributor()) { - fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) - } - } +// fun execute(pushersManager: PushersManager, registerPusher: Boolean) { +// if (unifiedPushHelper.isEmbeddedDistributor()) { +// fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) +// } +// } private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) { /* diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseConfig.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseConfig.kt new file mode 100644 index 0000000000..bf35a1b18a --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseConfig.kt @@ -0,0 +1,28 @@ +/* + * 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.providers.firebase + +object FirebaseConfig { + /** + * It is the push gateway for firebase. + * Note: pusher_http_url should have path '/_matrix/push/v1/notify' --> + */ + const val pusher_http_url: String = "https://matrix.org/_matrix/push/v1/notify" + + const val index = 0 + const val name = "Firebase" +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseNewTokenHandler.kt new file mode 100644 index 0000000000..58464b5af0 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseNewTokenHandler.kt @@ -0,0 +1,57 @@ +/* + * 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.providers.firebase + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.asSessionId +import io.element.android.libraries.push.providers.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserList +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("FirebaseNewTokenHandler") + +/** + * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. + */ +class FirebaseNewTokenHandler @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) { + firebaseStore.storeFcmToken(firebaseToken) + // Register the pusher for all the sessions + sessionStore.getAllSessions().toUserList() + .mapNotNull { it.asSessionId() } + .forEach { userId -> + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.getPushProviderName() == FirebaseConfig.name) { + matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> + pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.pusher_http_url) + } + } else { + Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") + } + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParser.kt similarity index 71% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParser.kt index 906816eb56..d3af7d8448 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/FirebasePushParser.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParser.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,18 +14,17 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.firebase +package io.element.android.libraries.push.providers.firebase -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.push.impl.push.PushData +import io.element.android.libraries.push.providers.api.PushData import javax.inject.Inject class FirebasePushParser @Inject constructor() { - fun parse(message: Map): PushData { + fun parse(message: Map): PushData? { val pushDataFirebase = PushDataFirebase( eventId = message["event_id"], roomId = message["room_id"], - unread = message["unread"]?.let { tryOrNull { Integer.parseInt(it) } }, + unread = message["unread"]?.toIntOrNull(), clientSecret = message["cs"], ) return pushDataFirebase.toPushData() diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushProvider.kt new file mode 100644 index 0000000000..15530033d5 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushProvider.kt @@ -0,0 +1,58 @@ +/* + * 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.providers.firebase + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PushProvider +import io.element.android.libraries.push.providers.api.PusherSubscriber +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("FirebasePushProvider") + +class FirebasePushProvider @Inject constructor( + private val firebaseStore: FirebaseStore, + private val firebaseTroubleshooter: FirebaseTroubleshooter, + private val pusherSubscriber: PusherSubscriber, +) : PushProvider { + override val index = FirebaseConfig.index + override val name = FirebaseConfig.name + + override fun getDistributors(): List { + return listOf(Distributor("Firebase", "Firebase")) + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") + } + pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url) + } + + override suspend fun unregister(matrixClient: MatrixClient) { + val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + } + pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.pusher_http_url) + } + + override suspend fun troubleshoot(): Result { + return firebaseTroubleshooter.troubleshoot() + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseStore.kt new file mode 100644 index 0000000000..f25ce08bc7 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseStore.kt @@ -0,0 +1,43 @@ +/* + * 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.providers.firebase + +import android.content.SharedPreferences +import androidx.core.content.edit +import io.element.android.libraries.di.DefaultPreferences +import javax.inject.Inject + +/** + * This class store the Firebase token in SharedPrefs. + */ +class FirebaseStore @Inject constructor( + @DefaultPreferences private val sharedPrefs: SharedPreferences, +) { + fun getFcmToken(): String? { + return sharedPrefs.getString(PREFS_KEY_FCM_TOKEN, null) + } + + fun storeFcmToken(token: String?) { + sharedPrefs.edit { + putString(PREFS_KEY_FCM_TOKEN, token) + } + } + + companion object { + private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN" + } +} diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseTroubleshooter.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseTroubleshooter.kt new file mode 100644 index 0000000000..9fb9b5708d --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/FirebaseTroubleshooter.kt @@ -0,0 +1,79 @@ +/* + * 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.providers.firebase + +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.firebase.messaging.FirebaseMessaging +import io.element.android.libraries.di.ApplicationContext +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * This class force retrieving and storage of the Firebase token. + */ +class FirebaseTroubleshooter @Inject constructor( + @ApplicationContext private val context: Context, + private val newTokenHandler: FirebaseNewTokenHandler, +) { + suspend fun troubleshoot(): Result { + return runCatching { + val token = retrievedFirebaseToken() + newTokenHandler.handle(token) + } + } + + private suspend fun retrievedFirebaseToken(): String { + return suspendCoroutine { continuation -> + // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' + if (checkPlayServices(context)) { + try { + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> + continuation.resume(token) + } + .addOnFailureListener { e -> + Timber.e(e, "## retrievedFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } catch (e: Throwable) { + Timber.e(e, "## retrievedFirebaseToken() : failed") + continuation.resumeWithException(e) + } + } else { + val e = Exception("No valid Google Play Services found. Cannot use FCM.") + Timber.e(e) + continuation.resumeWithException(e) + } + } + } + + /** + * Check the device to make sure it has the Google Play Services APK. If + * it doesn't, display a dialog that allows users to download the APK from + * the Google Play Store or enable it in the device's system settings. + */ + private fun checkPlayServices(context: Context): Boolean { + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(context) + return resultCode == ConnectionResult.SUCCESS + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/PushDataFirebase.kt similarity index 71% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/PushDataFirebase.kt index bcf48bab15..5c336e7dc6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/PushDataFirebase.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/PushDataFirebase.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.firebase +package io.element.android.libraries.push.providers.firebase -import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asRoomId -import io.element.android.libraries.push.impl.push.PushData +import io.element.android.libraries.push.providers.api.PushData /** * In this case, the format is: @@ -41,9 +40,13 @@ data class PushDataFirebase( val clientSecret: String? ) -fun PushDataFirebase.toPushData() = PushData( - eventId = eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(), - roomId = roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), - unread = unread, - clientSecret = clientSecret, -) +fun PushDataFirebase.toPushData(): PushData? { + val safeEventId = eventId?.asEventId() ?: return null + val safeRoomId = roomId?.asRoomId() ?: return null + return PushData( + eventId = safeEventId, + roomId = safeRoomId, + unread = unread, + clientSecret = clientSecret, + ) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingService.kt similarity index 76% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingService.kt index 8769baa947..35434ceb2e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingService.kt @@ -14,25 +14,23 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.firebase +package io.element.android.libraries.push.providers.firebase import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.log.pushLoggerTag -import io.element.android.libraries.push.impl.push.PushHandler +import io.element.android.libraries.push.providers.api.PushHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Firebase", pushLoggerTag) +private val loggerTag = LoggerTag("Firebase") class VectorFirebaseMessagingService : FirebaseMessagingService() { - @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler @Inject lateinit var pushParser: FirebasePushParser @Inject lateinit var pushHandler: PushHandler @@ -46,15 +44,18 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { Timber.tag(loggerTag.value).d("New Firebase token") coroutineScope.launch { - pushersManager.onNewFirebaseToken(token) + firebaseNewTokenHandler.handle(token) } } override fun onMessageReceived(message: RemoteMessage) { Timber.tag(loggerTag.value).d("New Firebase message") coroutineScope.launch { - pushParser.parse(message.data).let { - pushHandler.handle(it) + val pushData = pushParser.parse(message.data) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from Firebase") + } else { + pushHandler.handle(pushData) } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingServiceBindings.kt similarity index 93% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingServiceBindings.kt index aef87e7df3..e17cc922ee 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/firebase/VectorFirebaseMessagingServiceBindings.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/VectorFirebaseMessagingServiceBindings.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.firebase +package io.element.android.libraries.push.providers.firebase import com.squareup.anvil.annotations.ContributesTo import io.element.android.libraries.di.AppScope diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/di/FirebaseModule.kt similarity index 60% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt rename to libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/di/FirebaseModule.kt index 49c98374f2..9e36754101 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/di/RoomMemberModule.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/push/providers/firebase/di/FirebaseModule.kt @@ -14,22 +14,20 @@ * limitations under the License. */ -package io.element.android.features.roomdetails.impl.di +package io.element.android.libraries.push.providers.firebase.di import com.squareup.anvil.annotations.ContributesTo import dagger.Binds import dagger.Module -import io.element.android.features.roomdetails.impl.members.RoomMatrixUserDataSource -import io.element.android.features.userlist.api.MatrixUserDataSource -import io.element.android.libraries.di.RoomScope -import javax.inject.Named +import dagger.multibindings.IntoSet +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.providers.api.PushProvider +import io.element.android.libraries.push.providers.firebase.FirebasePushProvider @Module -@ContributesTo(RoomScope::class) -interface RoomMemberModule { - +@ContributesTo(AppScope::class) +interface FirebaseModule { @Binds - @Named("RoomMembers") - fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource - + @IntoSet + fun bind(pushProvider: FirebasePushProvider): PushProvider } diff --git a/libraries/pushproviders/firebase/src/main/res/values/firebase.xml b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml new file mode 100644 index 0000000000..163717db91 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values/firebase.xml @@ -0,0 +1,10 @@ + + + 912726360885-e87n3jva9uoj4vbidvijq78ebg02asv2.apps.googleusercontent.com + https://vector-alpha.firebaseio.com + 912726360885 + AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c + AIzaSyAFZX8IhIfgzdOZvxDP_ISO5WYoU7jmQ5c + vector-alpha.appspot.com + vector-alpha + diff --git a/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml new file mode 100644 index 0000000000..f793ba93f9 --- /dev/null +++ b/libraries/pushproviders/firebase/src/nightly/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:e17435e0beb0303000427c + diff --git a/libraries/pushproviders/firebase/src/release/res/values/firebase.xml b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml new file mode 100644 index 0000000000..d563b43d05 --- /dev/null +++ b/libraries/pushproviders/firebase/src/release/res/values/firebase.xml @@ -0,0 +1,4 @@ + + + 1:912726360885:android:d097de99a4c23d2700427c + diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParserTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParserTest.kt new file mode 100644 index 0000000000..562aecc790 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/push/providers/firebase/FirebasePushParserTest.kt @@ -0,0 +1,90 @@ +/* + * 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.providers.firebase + +import com.google.common.truth.Truth.assertThat +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.push.providers.api.PushData +import io.element.android.tests.testutils.assertNullOrThrow +import org.junit.Test + +class FirebasePushParserTest { + private val validData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = "a-secret" + ) + + @Test + fun `test edge cases Firebase`() { + val pushParser = FirebasePushParser() + // Empty Json + assertThat(pushParser.parse(emptyMap())).isNull() + // Bad Json + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("unread", "str"))).isEqualTo(validData.copy(unread = null)) + // Extra data + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("extra", "5"))).isEqualTo(validData) + } + + @Test + fun `test Firebase format`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA)).isEqualTo(validData) + } + + @Test + fun `test empty roomId`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", null))).isNull() + assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "")) } + } + + @Test + fun `test invalid roomId`() { + val pushParser = FirebasePushParser() + assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("room_id", "aRoomId:domain")) } + } + + @Test + fun `test empty eventId`() { + val pushParser = FirebasePushParser() + assertThat(pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", null))).isNull() + assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "")) } + } + + @Test + fun `test invalid eventId`() { + val pushParser = FirebasePushParser() + assertNullOrThrow { pushParser.parse(FIREBASE_PUSH_DATA.mutate("event_id", "anEventId")) } + } + + companion object { + private val FIREBASE_PUSH_DATA = mapOf( + "event_id" to AN_EVENT_ID.value, + "room_id" to A_ROOM_ID.value, + "unread" to "1", + "prio" to "high", + "cs" to "a-secret", + ) + } +} + +private fun Map.mutate(key: String, value: String?): Map { + return toMutableMap().apply { put(key, value) } +} diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts new file mode 100644 index 0000000000..3546bb16e1 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -0,0 +1,57 @@ +/* + * 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-library") + alias(libs.plugins.anvil) + kotlin("plugin.serialization") version "1.8.10" +} + +android { + namespace = "io.element.android.libraries.push.providers.unifiedpush" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.pushproviders.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.services.toolbox.api) + + implementation(projects.libraries.network) + implementation(platform(libs.network.okhttp.bom)) + implementation("com.squareup.okhttp3:okhttp") + implementation(libs.network.retrofit) + + implementation(libs.serialization.json) + + // UnifiedPush library + api(libs.unifiedpush) + + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..719733ab3e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/GuardServiceStarter.kt similarity index 93% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/GuardServiceStarter.kt index 08bd4a8326..f92468d047 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/GuardServiceStarter.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/GuardServiceStarter.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/KeepInternalDistributor.kt similarity index 90% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/KeepInternalDistributor.kt index de66ed3914..d2e0713f74 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/KeepInternalDistributor.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/KeepInternalDistributor.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import android.content.BroadcastReceiver import android.content.Context diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/PushDataUnifiedPush.kt similarity index 63% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/PushDataUnifiedPush.kt index 56513ab970..618a3c989f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/PushDataUnifiedPush.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/PushDataUnifiedPush.kt @@ -14,12 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush -import io.element.android.libraries.matrix.api.core.MatrixPatterns import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.core.asRoomId -import io.element.android.libraries.push.impl.push.PushData +import io.element.android.libraries.push.providers.api.PushData import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,24 +40,28 @@ import kotlinx.serialization.Serializable */ @Serializable data class PushDataUnifiedPush( - val notification: PushDataUnifiedPushNotification? + val notification: PushDataUnifiedPushNotification? = null ) @Serializable data class PushDataUnifiedPushNotification( - @SerialName("event_id") val eventId: String?, - @SerialName("room_id") val roomId: String?, - @SerialName("counts") var counts: PushDataUnifiedPushCounts?, + @SerialName("event_id") val eventId: String? = null, + @SerialName("room_id") val roomId: String? = null, + @SerialName("counts") var counts: PushDataUnifiedPushCounts? = null, ) @Serializable data class PushDataUnifiedPushCounts( - @SerialName("unread") val unread: Int? + @SerialName("unread") val unread: Int? = null ) -fun PushDataUnifiedPush.toPushData() = PushData( - eventId = notification?.eventId?.takeIf { MatrixPatterns.isEventId(it) }?.asEventId(), - roomId = notification?.roomId?.takeIf { MatrixPatterns.isRoomId(it) }?.asRoomId(), - unread = notification?.counts?.unread, - clientSecret = null // TODO EAx check how client secret will be sent through UnifiedPush -) +fun PushDataUnifiedPush.toPushData(clientSecret: String): PushData? { + val safeEventId = notification?.eventId?.asEventId() ?: return null + val safeRoomId = notification.roomId?.asRoomId() ?: return null + return PushData( + eventId = safeEventId, + roomId = safeRoomId, + unread = notification.counts?.unread, + clientSecret = clientSecret + ) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/RegisterUnifiedPushUseCase.kt similarity index 53% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/RegisterUnifiedPushUseCase.kt index 50ca94f30d..bff6b06876 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -14,55 +14,60 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import android.content.Context import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.push.impl.config.PushConfig +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PusherSubscriber import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject class RegisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, + private val pusherSubscriber: PusherSubscriber, + private val unifiedPushStore: UnifiedPushStore, ) { sealed interface RegisterUnifiedPushResult { object Success : RegisterUnifiedPushResult object NeedToAskUserForDistributor : RegisterUnifiedPushResult + object Error : RegisterUnifiedPushResult } - fun execute(distributor: String = ""): RegisterUnifiedPushResult { - if (distributor.isNotEmpty()) { - saveAndRegisterApp(distributor) - return RegisterUnifiedPushResult.Success - } - - if (!PushConfig.allowExternalUnifiedPushDistributors) { - saveAndRegisterApp(context.packageName) + suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { + val distributorValue = distributor.value + if (distributorValue.isNotEmpty()) { + saveAndRegisterApp(distributorValue, clientSecret) + val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error + val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error + pusherSubscriber.registerPusher(matrixClient, endpoint, gateway) return RegisterUnifiedPushResult.Success } + // TODO Below should never happen? if (UnifiedPush.getDistributor(context).isNotEmpty()) { - registerApp() + registerApp(clientSecret) return RegisterUnifiedPushResult.Success } val distributors = UnifiedPush.getDistributors(context) return if (distributors.size == 1) { - saveAndRegisterApp(distributors.first()) + saveAndRegisterApp(distributors.first(), clientSecret) RegisterUnifiedPushResult.Success } else { RegisterUnifiedPushResult.NeedToAskUserForDistributor } } - private fun saveAndRegisterApp(distributor: String) { + private fun saveAndRegisterApp(distributor: String, clientSecret: String) { UnifiedPush.saveDistributor(context, distributor) - registerApp() + registerApp(clientSecret) } - private fun registerApp() { - UnifiedPush.registerApp(context) + private fun registerApp(clientSecret: String) { + UnifiedPush.registerApp(context = context, instance = clientSecret) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushConfig.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushConfig.kt new file mode 100644 index 0000000000..21b4ca9a76 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushConfig.kt @@ -0,0 +1,28 @@ +/* + * 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.providers.unifiedpush + +object UnifiedPushConfig { + /** + * It is the push gateway for UnifiedPush. + * Note: default_push_gateway_http_url should have path '/_matrix/push/v1/notify' + */ + const val default_push_gateway_http_url: String = "https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify" + + const val index = 1 + const val name = "UnifiedPush" +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushGatewayResolver.kt new file mode 100644 index 0000000000..9a1e1785a4 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushGatewayResolver.kt @@ -0,0 +1,56 @@ +/* + * 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.providers.unifiedpush + +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.push.providers.unifiedpush.network.UnifiedPushApi +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.URL +import javax.inject.Inject + +class UnifiedPushGatewayResolver @Inject constructor( + private val retrofitFactory: RetrofitFactory, + private val coroutineDispatchers: CoroutineDispatchers, +) { + suspend fun getGateway(endpoint: String): String? { + val gateway = UnifiedPushConfig.default_push_gateway_http_url + val url = URL(endpoint) + val custom = "${url.protocol}://${url.host}/_matrix/push/v1/notify" + Timber.i("Testing $custom") + try { + return withContext(coroutineDispatchers.io) { + val api = retrofitFactory.create("${url.protocol}://${url.host}") + .create(UnifiedPushApi::class.java) + try { + val discoveryResponse = api.discover() + if (discoveryResponse.unifiedpush.gateway == "matrix") { + Timber.d("Using custom gateway") + return@withContext custom + } + } catch (throwable: Throwable) { + Timber.tag("UnifiedPushHelper").e(throwable) + } + return@withContext gateway + } + } catch (e: Throwable) { + Timber.d(e, "Cannot try custom gateway") + } + return gateway + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000000..3c9833010e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -0,0 +1,52 @@ +/* + * 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.providers.unifiedpush + +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.push.providers.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import timber.log.Timber +import javax.inject.Inject + +private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler") + +/** + * Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider. + */ +class UnifiedPushNewGatewayHandler @Inject constructor( + private val pusherSubscriber: PusherSubscriber, + private val userPushStoreFactory: UserPushStoreFactory, + private val pushClientSecret: PushClientSecret, + private val matrixAuthenticationService: MatrixAuthenticationService, +) { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) { + // Register the pusher for the session with this client secret, if is it using UnifiedPush. + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also { + Timber.w("Unable to retrieve session") + } + val userDataStore = userPushStoreFactory.create(userId) + if (userDataStore.getPushProviderName() == UnifiedPushConfig.name) { + matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } + } else { + Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParser.kt similarity index 65% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParser.kt index 9788ecf1a1..6169e1f8eb 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnifiedPushParser.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParser.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,16 +14,18 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.push.impl.push.PushData +import io.element.android.libraries.push.providers.api.PushData import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import javax.inject.Inject class UnifiedPushParser @Inject constructor() { - fun parse(message: ByteArray): PushData? { - return tryOrNull { Json.decodeFromString(String(message)) }?.toPushData() + private val json by lazy { Json { ignoreUnknownKeys = true } } + + fun parse(message: ByteArray, clientSecret: String): PushData? { + return tryOrNull { json.decodeFromString(String(message)) }?.toPushData(clientSecret) } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushProvider.kt new file mode 100644 index 0000000000..854c070d7e --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushProvider.kt @@ -0,0 +1,63 @@ +/* + * 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.providers.unifiedpush + +import android.content.Context +import io.element.android.libraries.androidutils.system.getApplicationLabel +import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.providers.api.Distributor +import io.element.android.libraries.push.providers.api.PushProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import org.unifiedpush.android.connector.UnifiedPush +import javax.inject.Inject + +class UnifiedPushProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val pushClientSecret: PushClientSecret, +) : PushProvider { + override val index = UnifiedPushConfig.index + override val name = UnifiedPushConfig.name + + override fun getDistributors(): List { + val distributors = UnifiedPush.getDistributors(context) + return distributors.mapNotNull { + if (it == context.packageName) { + // Exclude self + null + } else { + Distributor(it, context.getApplicationLabel(it)) + } + } + } + + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret) + } + + override suspend fun unregister(matrixClient: MatrixClient) { + val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) + unRegisterUnifiedPushUseCase.execute(clientSecret) + } + + override suspend fun troubleshoot(): Result { + TODO("Not yet implemented") + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushStore.kt similarity index 65% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushStore.kt index 226d0c5669..3883c3348c 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/UnifiedPushStore.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushStore.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl +package io.element.android.libraries.push.providers.unifiedpush import android.content.Context import android.content.SharedPreferences @@ -23,9 +23,6 @@ import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.DefaultPreferences import javax.inject.Inject -/** - * TODO EAx Store in BDD (for multisession). - */ class UnifiedPushStore @Inject constructor( @ApplicationContext val context: Context, @DefaultPreferences private val defaultPrefs: SharedPreferences, @@ -33,40 +30,44 @@ class UnifiedPushStore @Inject constructor( /** * Retrieves the UnifiedPush Endpoint. * + * @param clientSecret the client secret, to identify the session * @return the UnifiedPush Endpoint or null if not received */ - fun getEndpoint(): String? { - return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN, null) + fun getEndpoint(clientSecret: String): String? { + return defaultPrefs.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 */ - fun storeUpEndpoint(endpoint: String?) { + fun storeUpEndpoint(endpoint: String?, clientSecret: String) { defaultPrefs.edit { - putString(PREFS_ENDPOINT_OR_TOKEN, endpoint) + putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint) } } /** * Retrieves the Push Gateway. * + * @param clientSecret the client secret, to identify the session * @return the Push Gateway or null if not defined */ - fun getPushGateway(): String? { - return defaultPrefs.getString(PREFS_PUSH_GATEWAY, null) + fun getPushGateway(clientSecret: String): String? { + return defaultPrefs.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 */ - fun storePushGateway(gateway: String?) { + fun storePushGateway(gateway: String?, clientSecret: String) { defaultPrefs.edit { - putString(PREFS_PUSH_GATEWAY, gateway) + putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnregisterUnifiedPushUseCase.kt similarity index 57% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnregisterUnifiedPushUseCase.kt index 6cd1af1de3..e6eb778f7f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -14,39 +14,34 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import android.content.Context import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.push.api.model.BackgroundSyncMode -import io.element.android.libraries.push.api.store.PushDataStore -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.UnifiedPushHelper -import io.element.android.libraries.push.impl.UnifiedPushStore import org.unifiedpush.android.connector.UnifiedPush import timber.log.Timber import javax.inject.Inject class UnregisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - private val pushDataStore: PushDataStore, + //private val pushDataStore: PushDataStore, private val unifiedPushStore: UnifiedPushStore, - private val unifiedPushHelper: UnifiedPushHelper, + private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, ) { - suspend fun execute(pushersManager: PushersManager?) { - val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - pushDataStore.setFdroidSyncBackgroundMode(mode) + suspend fun execute(clientSecret: String /*pushersManager: PushersManager?*/) { + //val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + //pushDataStore.setFdroidSyncBackgroundMode(mode) try { - unifiedPushHelper.getEndpointOrToken()?.let { + unifiedPushStore.getEndpoint(clientSecret)?.let { Timber.d("Removing $it") - pushersManager?.unregisterPusher(it) + // TODO pushersManager?.unregisterPusher(it) } } catch (e: Exception) { Timber.d(e, "Probably unregistering a non existing pusher") } - unifiedPushStore.storeUpEndpoint(null) - unifiedPushStore.storePushGateway(null) + unifiedPushStore.storeUpEndpoint(null, clientSecret) + unifiedPushStore.storePushGateway(null, clientSecret) UnifiedPush.unregisterApp(context) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiver.kt similarity index 60% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index 81dd389e78..0f065acc52 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -14,51 +14,39 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import android.content.Context import android.content.Intent -import android.widget.Toast import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.push.api.model.BackgroundSyncMode -import io.element.android.libraries.push.api.store.PushDataStore -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.UnifiedPushHelper -import io.element.android.libraries.push.impl.UnifiedPushStore -import io.element.android.libraries.push.impl.log.pushLoggerTag -import io.element.android.libraries.push.impl.push.PushHandler +import io.element.android.libraries.push.providers.api.PushHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.unifiedpush.android.connector.MessagingReceiver import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("Unified", pushLoggerTag) +private val loggerTag = LoggerTag("VectorUnifiedPushMessagingReceiver") class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { - @Inject lateinit var pushersManager: PushersManager @Inject lateinit var pushParser: UnifiedPushParser - - //@Inject lateinit var activeSessionHolder: ActiveSessionHolder - @Inject lateinit var pushDataStore: PushDataStore @Inject lateinit var pushHandler: PushHandler @Inject lateinit var guardServiceStarter: GuardServiceStarter @Inject lateinit var unifiedPushStore: UnifiedPushStore - @Inject lateinit var unifiedPushHelper: UnifiedPushHelper + @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver + @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler private val coroutineScope = CoroutineScope(SupervisorJob()) override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - // Inject context.applicationContext.bindings().inject(this) + super.onReceive(context, intent) } /** - * Called when message is received. + * Called when message is received. The message contains the full POST body of the push message. * * @param context the Android context * @param message the message @@ -67,48 +55,58 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { override fun onMessage(context: Context, message: ByteArray, instance: String) { Timber.tag(loggerTag.value).d("New message") coroutineScope.launch { - pushParser.parse(message)?.let { - pushHandler.handle(it) - } ?: run { - Timber.tag(loggerTag.value).w("Invalid received data Json format") + val pushData = pushParser.parse(message, instance) + if (pushData == null) { + Timber.tag(loggerTag.value).w("Invalid data received from UnifiedPush") + } else { + pushHandler.handle(pushData) } } } + /** + * Called when a new endpoint is to be used for sending push messages. + * You should send the endpoint to your application server and sync for missing notifications. + */ override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") - if (pushDataStore.areNotificationEnabledForDevice() /* TODO EAx && activeSessionHolder.hasActiveSession() */) { - // If the endpoint has changed - // or the gateway has changed - if (unifiedPushHelper.getEndpointOrToken() != endpoint) { - unifiedPushStore.storeUpEndpoint(endpoint) - coroutineScope.launch { - unifiedPushHelper.storeCustomOrDefaultGateway(endpoint) { - unifiedPushHelper.getPushGateway()?.let { - coroutineScope.launch { - pushersManager.onNewUnifiedPushEndpoint(endpoint, it) - } - } - } + // If the endpoint has changed + // or the gateway has changed + if (unifiedPushStore.getEndpoint(instance) != endpoint) { + unifiedPushStore.storeUpEndpoint(endpoint, instance) + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + unifiedPushStore.storePushGateway(gateway, instance) + gateway?.let { pushGateway -> + newGatewayHandler.handle(endpoint, pushGateway, instance) } - } else { - Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") } + } else { + Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") } - val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_DISABLED - pushDataStore.setFdroidSyncBackgroundMode(mode) guardServiceStarter.stop() } + /** + * Called when the registration is not possible, eg. no network. + */ override fun onRegistrationFailed(context: Context, instance: String) { + Timber.tag(loggerTag.value).e("onRegistrationFailed for $instance") + /* Toast.makeText(context, "Push service registration failed", Toast.LENGTH_SHORT).show() val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME pushDataStore.setFdroidSyncBackgroundMode(mode) guardServiceStarter.start() + */ } + /** + * Called when this application is unregistered from receiving push messages. + */ override fun onUnregistered(context: Context, instance: String) { Timber.tag(loggerTag.value).d("Unifiedpush: Unregistered") + TODO() + /* val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME pushDataStore.setFdroidSyncBackgroundMode(mode) guardServiceStarter.start() @@ -119,5 +117,6 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { Timber.tag(loggerTag.value).d("Probably unregistering a non existing pusher") } } + */ } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt similarity index 93% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt rename to libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt index 90857d990d..603e297c6b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.unifiedpush +package io.element.android.libraries.push.providers.unifiedpush import com.squareup.anvil.annotations.ContributesTo import io.element.android.libraries.di.AppScope diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/di/UnifiedPushModule.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/di/UnifiedPushModule.kt new file mode 100644 index 0000000000..9e34b349e3 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/di/UnifiedPushModule.kt @@ -0,0 +1,33 @@ +/* + * 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.providers.unifiedpush.di + +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoSet +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.providers.api.PushProvider +import io.element.android.libraries.push.providers.unifiedpush.UnifiedPushProvider + +@Module +@ContributesTo(AppScope::class) +interface UnifiedPushModule { + @Binds + @IntoSet + fun bind(pushProvider: UnifiedPushProvider): PushProvider +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryResponse.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryResponse.kt new file mode 100644 index 0000000000..b961da1285 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryResponse.kt @@ -0,0 +1,25 @@ +/* + * 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.providers.unifiedpush.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiscoveryResponse( + @SerialName("unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush() +) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryUnifiedPush.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryUnifiedPush.kt new file mode 100644 index 0000000000..b4c7345fd7 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/DiscoveryUnifiedPush.kt @@ -0,0 +1,25 @@ +/* + * 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.providers.unifiedpush.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DiscoveryUnifiedPush( + @SerialName("gateway") val gateway: String = "" +) diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/UnifiedPushApi.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/UnifiedPushApi.kt new file mode 100644 index 0000000000..e384b8353b --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/push/providers/unifiedpush/network/UnifiedPushApi.kt @@ -0,0 +1,24 @@ +/* + * 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.providers.unifiedpush.network + +import retrofit2.http.GET + +interface UnifiedPushApi { + @GET("_matrix/push/v1/notify") + suspend fun discover(): DiscoveryResponse +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParserTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParserTest.kt new file mode 100644 index 0000000000..19231505cc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/push/providers/unifiedpush/UnifiedPushParserTest.kt @@ -0,0 +1,93 @@ +/* + * 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.providers.unifiedpush + +import com.google.common.truth.Truth.assertThat +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.push.providers.api.PushData +import io.element.android.tests.testutils.assertNullOrThrow +import org.junit.Test + +class UnifiedPushParserTest { + private val aClientSecret = "a-client-secret" + private val validData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = aClientSecret + ) + + @Test + fun `test edge cases UnifiedPush`() { + val pushParser = UnifiedPushParser() + // Empty string + assertThat(pushParser.parse("".toByteArray(), aClientSecret)).isNull() + // Empty Json + assertThat(pushParser.parse("{}".toByteArray(), aClientSecret)).isNull() + // Bad Json + assertThat(pushParser.parse("ABC".toByteArray(), aClientSecret)).isNull() + } + + @Test + fun `test UnifiedPush format`() { + val pushParser = UnifiedPushParser() + assertThat(pushParser.parse(UNIFIED_PUSH_DATA.toByteArray(), aClientSecret)).isEqualTo(validData) + } + + @Test + fun `test empty roomId`() { + val pushParser = UnifiedPushParser() + assertNullOrThrow { + pushParser.parse(UNIFIED_PUSH_DATA.replace(A_ROOM_ID.value, "").toByteArray(), aClientSecret) + } + } + + @Test + fun `test invalid roomId`() { + val pushParser = UnifiedPushParser() + assertNullOrThrow { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(A_ROOM_ID.value, "aRoomId:domain"), aClientSecret) + } + } + + @Test + fun `test empty eventId`() { + val pushParser = UnifiedPushParser() + assertNullOrThrow { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, ""), aClientSecret) + } + } + + @Test + fun `test invalid eventId`() { + val pushParser = UnifiedPushParser() + assertNullOrThrow { + pushParser.parse(UNIFIED_PUSH_DATA.mutate(AN_EVENT_ID.value, "anEventId"), aClientSecret) + } + } + + companion object { + private val UNIFIED_PUSH_DATA = + "{\"notification\":{\"event_id\":\"${AN_EVENT_ID.value}\",\"room_id\":\"${A_ROOM_ID.value}\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" + // TODO Check client secret format? + } +} + +private fun String.mutate(oldValue: String, newValue: String): ByteArray { + return replace(oldValue, newValue).toByteArray() +} diff --git a/libraries/pushstore/api/build.gradle.kts b/libraries/pushstore/api/build.gradle.kts new file mode 100644 index 0000000000..9a97bf693f --- /dev/null +++ b/libraries/pushstore/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * 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-library") +} + +android { + namespace = "io.element.android.libraries.pushstore.api" +} + +dependencies { + implementation(projects.libraries.matrix.api) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt similarity index 62% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt rename to libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt index 82c4beaf20..28577ba3f8 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -14,27 +14,24 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.userpushstore - -const val NOTIFICATION_METHOD_FIREBASE = "NOTIFICATION_METHOD_FIREBASE" -const val NOTIFICATION_METHOD_UNIFIEDPUSH = "NOTIFICATION_METHOD_UNIFIEDPUSH" +package io.element.android.libraries.pushstore.api /** * Store data related to push about a user. */ interface UserPushStore { - /** - * [NOTIFICATION_METHOD_FIREBASE] or [NOTIFICATION_METHOD_UNIFIEDPUSH]. - */ - suspend fun getNotificationMethod(): String - - suspend fun setNotificationMethod(value: String) - + suspend fun getPushProviderName(): String? + suspend fun setPushProviderName(value: String) suspend fun getCurrentRegisteredPushKey(): String? - suspend fun setCurrentRegisteredPushKey(value: String) + suspend fun areNotificationEnabledForDevice(): Boolean + suspend fun setNotificationEnabledForDevice(enabled: Boolean) + + /** + * Return true if Pin code is disabled, or if user set the settings to see full notification content. + */ + fun useCompleteNotificationFormat(): Boolean + suspend fun reset() } - -suspend fun UserPushStore.isFirebase(): Boolean = getNotificationMethod() == NOTIFICATION_METHOD_FIREBASE diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt new file mode 100644 index 0000000000..52e4596ca0 --- /dev/null +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStoreFactory.kt @@ -0,0 +1,26 @@ +/* + * 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.pushstore.api + +import io.element.android.libraries.matrix.api.core.SessionId + +/** + * Store data related to push about a user. + */ +interface UserPushStoreFactory { + fun create(userId: SessionId): UserPushStore +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt similarity index 94% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt rename to libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt index 93f5f43ce4..dbdd22ce07 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecret.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecret.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.api.clientsecret import io.element.android.libraries.matrix.api.core.SessionId diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt similarity index 91% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt rename to libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt index 4ab6c775e3..128302d5c0 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactory.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretFactory.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.api.clientsecret interface PushClientSecretFactory { fun create(): String diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt similarity index 93% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt rename to libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt index c5f7358241..e2bd5a6084 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/clientsecret/PushClientSecretStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.api.clientsecret import io.element.android.libraries.matrix.api.core.SessionId diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts new file mode 100644 index 0000000000..4625f293cb --- /dev/null +++ b/libraries/pushstore/impl/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * 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-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.push.pushstore.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(libs.dagger) + implementation(projects.libraries.architecture) + implementation(projects.libraries.core) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.sessionStorage.api) + implementation(libs.androidx.corektx) + implementation(libs.androidx.datastore.preferences) + + testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt similarity index 69% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt index 0323713de7..ed32dba472 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreFactory.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/DefaultUserPushStoreFactory.kt @@ -14,28 +14,34 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.userpushstore +package io.element.android.libraries.pushstore.impl 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.di.SingleIn +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.asSessionId +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import javax.inject.Inject @SingleIn(AppScope::class) -class UserPushStoreFactory @Inject constructor( +@ContributesBinding(AppScope::class, boundType = UserPushStoreFactory::class) +class DefaultUserPushStoreFactory @Inject constructor( @ApplicationContext private val context: Context, private val sessionObserver: SessionObserver, -) : SessionListener { +) : UserPushStoreFactory, SessionListener { init { observeSessions() } // We can have only one class accessing a single data store, so keep a cache of them. - private val cache = mutableMapOf() - fun create(userId: String): UserPushStore { + private val cache = mutableMapOf() + override fun create(userId: SessionId): UserPushStore { return cache.getOrPut(userId) { UserPushStoreDataStore( context = context, @@ -54,6 +60,6 @@ class UserPushStoreFactory @Inject constructor( override suspend fun onSessionDeleted(userId: String) { // Delete the store - create(userId).reset() + userId.asSessionId()?.let { create(it).reset() } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt similarity index 60% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt index 6f25599e54..56867a6584 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/userpushstore/UserPushStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -14,14 +14,18 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.userpushstore +package io.element.android.libraries.pushstore.impl import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import io.element.android.libraries.core.bool.orTrue +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.UserPushStore import kotlinx.coroutines.flow.first /** @@ -29,19 +33,20 @@ import kotlinx.coroutines.flow.first */ class UserPushStoreDataStore( private val context: Context, - userId: String, + userId: SessionId, ) : UserPushStore { private val Context.dataStore: DataStore by preferencesDataStore(name = "push_store_$userId") - private val notificationMethod = stringPreferencesKey("notificationMethod") + private val pushProviderName = stringPreferencesKey("pushProviderName") private val currentPushKey = stringPreferencesKey("currentPushKey") + private val notificationEnabled = booleanPreferencesKey("notificationEnabled") - override suspend fun getNotificationMethod(): String { - return context.dataStore.data.first()[notificationMethod] ?: NOTIFICATION_METHOD_FIREBASE + override suspend fun getPushProviderName(): String? { + return context.dataStore.data.first()[pushProviderName] } - override suspend fun setNotificationMethod(value: String) { + override suspend fun setPushProviderName(value: String) { context.dataStore.edit { - it[notificationMethod] = value + it[pushProviderName] = value } } @@ -55,6 +60,20 @@ class UserPushStoreDataStore( } } + override suspend fun areNotificationEnabledForDevice(): Boolean { + return context.dataStore.data.first()[notificationEnabled].orTrue() + } + + override suspend fun setNotificationEnabledForDevice(enabled: Boolean) { + context.dataStore.edit { + it[notificationEnabled] = enabled + } + } + + override fun useCompleteNotificationFormat(): Boolean { + return true + } + override suspend fun reset() { context.dataStore.edit { it.clear() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt similarity index 86% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt index 1d7a1e6247..4e6e718a60 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretFactoryImpl.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretFactoryImpl.kt @@ -14,10 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory import java.util.UUID import javax.inject.Inject diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt similarity index 84% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt index b57b24d25e..ca0ed14e33 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImpl.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImpl.kt @@ -14,11 +14,14 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore import javax.inject.Inject @ContributesBinding(AppScope::class) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt similarity index 94% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt rename to libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt index 055de6fc47..2431120c9e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretStoreDataStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret import android.content.Context import androidx.datastore.core.DataStore @@ -27,6 +27,7 @@ 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.asSessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore import kotlinx.coroutines.flow.first import javax.inject.Inject diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt similarity index 85% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt rename to libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt index 25823a57e8..b1cb93e49c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/FakePushClientSecretFactory.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/FakePushClientSecretFactory.kt @@ -14,7 +14,9 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret + +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretFactory private const val A_SECRET_PREFIX = "A_SECRET_" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt similarity index 89% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt rename to libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt index a2d2d9c83c..8c9b577967 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/InMemoryPushClientSecretStore.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt @@ -14,9 +14,10 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore class InMemoryPushClientSecretStore : PushClientSecretStore { private val secrets = mutableMapOf() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt similarity index 97% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt rename to libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt index a9d740bf31..d7f8e2e337 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/clientsecret/PushClientSecretImplTest.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt @@ -16,7 +16,7 @@ @file:OptIn(ExperimentalCoroutinesApi::class) -package io.element.android.libraries.push.impl.clientsecret +package io.element.android.libraries.pushstore.impl.clientsecret import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index 4b14f3a4a7..564ede34a8 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -107,7 +107,6 @@ "Símbolos" "No se pudo crear el enlace permanente" "Error al cargar mensajes" - "No se encontró ninguna aplicación compatible con esta acción." "Algunos mensajes no se han enviado" "Lo siento, se ha producido un error" "Hola, puedes hablar conmigo en %1$s: %2$s" @@ -138,8 +137,6 @@ "Desbloquear" "Al desbloquear al usuario, podrás volver a ver todos sus mensajes." "Desbloquear usuario" - "Se ha producido un error al intentar iniciar un chat" - "No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación." "Agitar con fuerza" "Umbral de detección" "General" diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 96d0648d3b..a8ec05115a 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -107,7 +107,6 @@ "Simboli" "Impossibile creare il collegamento permanente" "Caricamento dei messaggi non riuscito" - "Non è stata trovata alcuna app compatibile per gestire questa azione." "Alcuni messaggi non sono stati inviati" "Siamo spiacenti, si è verificato un errore" "Ehi, parlami su %1$s: %2$s" @@ -138,8 +137,6 @@ "Sblocca" "Dopo aver sbloccato l\'utente, potrai vedere nuovamente tutti i suoi messaggi." "Sblocca utente" - "Si è verificato un errore durante il tentativo di avviare una chat" - "Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto." "Rageshake" "Soglia di rilevamento" "Generali" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 1872bb057f..091dd7f36d 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -107,7 +107,6 @@ "Simboluri" "Crearea permalink-ului a eșuat" "Încărcarea mesajelor a eșuat" - "Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune." "Unele mesaje nu au fost trimise" "Ne pare rău, a apărut o eroare" "Hei, vorbește cu mine pe %1$s: %2$s" @@ -140,8 +139,6 @@ "Deblocați" "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." "Deblocați utilizatorul" - "A apărut o eroare la încercarea începerii conversației" - "Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită." "Rageshake" "Prag de detecție" "General" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 37665e7207..72c7dfe08f 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -114,7 +114,6 @@ "Symbols" "Failed creating the permalink" "Failed loading messages" - "No compatible app was found to handle this action." "Some messages have not been sent" "Sorry, an error occurred" "Hey, talk to me on %1$s: %2$s" @@ -122,21 +121,67 @@ "Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite." "Are you sure that you want to leave the room?" "%1$s Android" + "Call" + "Listening for events" + "Noisy notifications" + "Silent notifications" + "** Failed to send - please open room" + "Join" + "Reject" + "New Messages" + "Mark as read" + "Quick reply" + "Me" + "You are viewing the notification! Click me!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + "%1$s and %2$s" + "%1$s in %2$s" + "%1$s in %2$s and %3$s" "%1$d member" "%1$d members" + + "%1$s: %2$d message" + "%1$s: %2$d messages" + + + "%d notification" + "%d notifications" + + + "%d invitation" + "%d invitations" + + + "%d new message" + "%d new messages" + + + "%d unread notified message" + "%d unread notified messages" + + + "%d room" + "%d rooms" + "%1$d room change" "%1$d room changes" "Rageshake to report bug" + "Choose how to receive notifications" + "Background synchronization" + "Google Services" + "No valid Google Play Services found. Notifications may not work properly." "You seem to be shaking the phone in frustration. Would you like to open the bug report screen?" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." "Reason for reporting this content" "This is the beginning of %1$s." "This is the beginning of this conversation." "New" + "No Invites" "%1$s invited you" "Block user" "Check if you want to hide all current and future messages from this user" @@ -146,8 +191,6 @@ "Unblock" "On unblocking the user, you will be able to see all messages by them again." "Unblock user" - "An error occurred when trying to start a chat" - "We can’t validate this user’s Matrix ID. The invite might not be received." "Rageshake" "Detection threshold" "General" diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 9f0cf92099..1427269755 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -75,6 +75,12 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:core")) implementation(project(":libraries:permissions:impl")) implementation(project(":libraries:push:impl")) + implementation(project(":libraries:push:impl")) + // Comment to not include firebase in the project + implementation(project(":libraries:pushproviders:firebase")) + // Comment to not include unified push in the project + implementation(project(":libraries:pushproviders:unifiedpush")) + implementation(project(":libraries:pushstore:impl")) implementation(project(":libraries:architecture")) implementation(project(":libraries:dateformatter:impl")) implementation(project(":libraries:di")) diff --git a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt index 8a96c35af1..5ead00c976 100644 --- a/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt +++ b/services/appnavstate/api/src/main/kotlin/io/element/android/services/appnavstate/api/AppNavigationState.kt @@ -24,6 +24,10 @@ import io.element.android.libraries.matrix.api.core.ThreadId /** * Can represent the current global app navigation state. * @param owner mostly a Node identifier associated with the state. + * We are using the owner parameter to check when calling onLeaving methods is still using the same owner than his companion onNavigate. + * Why this is needed : for now we rely on lifecycle methods of the node, which are async. + * If you navigate quickly between nodes, onCreate of the new node is called before onDestroy of the previous node. + * So we assume if we don't get the same owner, we can skip the onLeaving action as we already replaced it. */ sealed class AppNavigationState(open val owner: String) { object Root : AppNavigationState("ROOT") diff --git a/settings.gradle.kts b/settings.gradle.kts index 7429f80b43..1173288adb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,6 +49,7 @@ rootProject.name = "ElementX" include(":app") include(":appnav") include(":tests:uitests") +include(":tests:testutils") include(":anvilannotations") include(":anvilcodegen") diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts new file mode 100644 index 0000000000..0c28da1f06 --- /dev/null +++ b/tests/testutils/build.gradle.kts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("io.element.android-library") + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.tests.testutils" +} + +dependencies { + implementation(libs.test.junit) + implementation(libs.test.mockk) + implementation(libs.test.truth) + implementation(libs.test.turbine) + implementation(libs.coroutines.test) + implementation(projects.libraries.matrix.test) + implementation(projects.services.appnavstate.test) +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/NullOrThrow.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/NullOrThrow.kt new file mode 100644 index 0000000000..adfd58da5f --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/NullOrThrow.kt @@ -0,0 +1,33 @@ +/* + * 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.tests.testutils + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows + +/** + * Assert that the lambda throws on debug and returns null on release. + */ +fun assertNullOrThrow(lambda: () -> Any?) { + if (BuildConfig.DEBUG) { + assertThrows(IllegalStateException::class.java) { + lambda() + } + } else { + assertThat(lambda()).isNull() + } +} diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png index ad94119f4c..596f01f6a6 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:489aa166bf6d0f283da497b2752e457f81620959d404b5b9c3d96c7e18e8b953 -size 28100 +oid sha256:b96cf938377ee0f02e0cf2d5896eb83935d9416a5b87849ed81caed4db5e90f2 +size 41264 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png index 85c608618b..8b87a5a644 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a8969c9a82f41bbdbdfb013412d039f3be4fcf450a0dd55f3217c64d51c8489 -size 23495 +oid sha256:56148beb26b35c8309190271b43fc225e11b90b8b849a8e60abf98b6ab663c1b +size 101010 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png index 2074516963..339b2adf16 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d5f29443193cd21eeb721a4c86f9063466439dd5ad4830708e6fa587f95e5abd -size 27456 +oid sha256:92fe30f0927b8be4ffc384548e6731e7d5e347dc5caa1c84251f2a932ee1873c +size 38848 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png index 2139d8a6c5..ba7682c859 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.addpeople_null_DefaultGroup_AddPeopleViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa79c54d2431dc0c8399a2bcb8e03d73749e9d54f505ac893e2376fded0e7dfe -size 22599 +oid sha256:9fa60afaf7ab23f66622d7d77e168db8b9d8bc15d178e8adb63f944de3cc29df +size 96242 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e5f8881070 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aaeb60c55803711952413d99191209f467b4f77e1b03ad46601111691c2fc7fe +size 38188 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..8197e52f75 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_AvatarLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c8ccd79709924e19793977ebe5ab4f6ded7f20507d9534dc24f46513fdd7a69 +size 37844 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..6f85c2f397 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b1243c92ee9f3735e81c2ab77910a7323b692a72c3c81c4b2ea776c1f0e5c84 +size 16267 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3229cb3561 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_LabelledTextFieldLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7db235009f0e0a50eda1196f2cd24eb490af10ebe42e26cc73fdf8ea2fdb0bf8 +size 15906 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..582b3621ea --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5abeb4bb717e33b34ff0a61d657a01b4e7ad965b5d7f8e4252c7e956c4caada3 +size 38719 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..81a08165fa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.components_null_DefaultGroup_RoomPrivacyOptionLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74c34325693f03c620a64493ea9bead27d8db1719d849ef4e1f589940efc73c9 +size 34559 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..699b8973c1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:756d835de7820c96019995ab47d34c2663330c4ddc9ca124eac266ea9eeef0ab +size 64397 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..62e855dab1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df7631ad85dc6b3fbdabfc820b11b4e12a6128202e3faa2d105ba22793fe8c22 +size 103428 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a9a3720ece --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a03aa11c787804adc5b2947359421b90e6abcdc7e840983a0276a6e02da59a2f +size 58526 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4045dae649 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom_null_DefaultGroup_ConfigureRoomViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c2731ea21ca5b001aadc29ecbb15a2b263b0c042b7344cb10149174ff88ec0a +size 96835 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png index 1c22798a9a..de4377ecc9 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:365161fefa9ea3c82bf3ed2527b4847df27860266e1d5f0e770962e95154b4a6 -size 19178 +oid sha256:6f36c2b4f4266048d5295df08f380e4128630d11cce7ac11cf3a0eaaa5594d61 +size 19924 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png index d404485dee..0bdbed7968 100644 --- a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.root_null_DefaultGroup_CreateRoomRootViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3f4413c59f47c45c06433f53c3f1fb7e9095bb47e3d8c3e7fabefcb19cdd5146 -size 18346 +oid sha256:23b9e996b8f0cc2efcb3adb6294bfa7b4a53fefbb7b6ee07add4105da9b9d40e +size 18984 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9af1f155a8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6b4eaf1009581383dc6b95f1e27c3f4f94f63ef9e6b1875521ba5249f449ce9 +size 28705 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..75a3ccc4ee --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32b9585ac785804c27eba9a630d5634c79b08b276a11607ea80c83eacccb59a0 +size 26252 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..479406f955 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:312a9c09176df3772386c5bb6f1d6945ae2638101e790a991e6becaec2440b8a +size 29618 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..ea22da115e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf5bef51ccb02f801be411b46859f86d5ff0a571978763b589135eb12f1ec60e +size 28265 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a11a39bb0e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:625cad77761037dbf600a1ce7635257051d66138ffbde2663f94550a96d3007c +size 25872 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b982a61f9c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomdetails.impl.members.details_null_DefaultGroup_RoomMemberDetailsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c210546e81ac92d6a3f2583dd3443e0314f2259aa455d1ecdf6da77048fd861 +size 28733 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e2b920b8ed --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:247d303776a79f310144487784fb3841a430a6b0fa7fdee28485ed6157e42354 +size 7056 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b2c1d53b70 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.theme.components_null_DefaultGroup_RadioButtonLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d93540b38b57540142d2e57e4ce2caf7424fcd2aa3017ab16449d60c1e156797 +size 6730 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4abfbacbbc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d91a9a9decf08f9bd9301d5282e889fb4e12d4270e8dc7c4b8b24de0b6059126 +size 24662 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..cc898176e8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44aa08cd01010ca90fb9ca33cb724dd0ebc6d523eff25b40e65f73f3ca280c19 +size 34209 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..3141cfc7aa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cca345dc82e59d0a87411f00e39e12128b4e61d2d7343c8d7af3d96e54038ca0 +size 28591 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..22ad0f0059 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_0,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6548a7cc39e0861de6af8e55bc00424d8835e5aa3d99d4e7c68db682e054d677 +size 24542 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4a8614354a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:817a15eb656b7dbcc23a0a62528804d346ca045d9c765579829ac5d3e8d16974 +size 34224 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..dbd6530f91 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.permissions.api_null_DefaultGroup_PermissionsViewLightPreview_0_null_2,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:775380d09f801b563ba5444e046088963d6830c9bf2fa98cf106fae28f94784a +size 28600 diff --git a/tools/adb/deeplink.sh b/tools/adb/deeplink.sh new file mode 100755 index 0000000000..5d50ec9409 --- /dev/null +++ b/tools/adb/deeplink.sh @@ -0,0 +1,28 @@ +#! /bin/bash +# +# 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. +# + +# Format is: +# elementx://open/{sessionId} to open a session +# elementx://open/{sessionId}/{roomId} to open a room +# elementx://open/{sessionId}/{roomId}/{eventId} to open a thread + +# Open a session +# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org +# Open a room +adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org +# Open a thread +# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 59c6bd6911..9cbd6cd620 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -28,7 +28,8 @@ { "name": ":features:createroom:impl", "includeRegex": [ - "screen_create_room_.*" + "screen_create_room_.*", + "screen_start_chat_.*" ] }, { @@ -43,6 +44,12 @@ "rich_text_editor_.*" ] }, + { + "name": ":libraries:androidutils", + "includeRegex": [ + "error_no_compatible_app_found" + ] + }, { "name": ":libraries:push:impl", "includeRegex": [