Merge branch 'develop' into renovate/compose_bom

This commit is contained in:
ganfra
2023-04-14 10:50:39 +02:00
322 changed files with 12879 additions and 155 deletions

View File

@@ -11,7 +11,7 @@ jobs:
- run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger
uses: danger/danger-js@11.2.4
uses: danger/danger-js@11.2.5
with:
args: "--dangerfile ./tools/danger/dangerfile.js"
env:

View File

@@ -42,7 +42,7 @@ jobs:
yarn add danger-plugin-lint-report --dev
- name: Danger lint
if: always()
uses: danger/danger-js@11.2.4
uses: danger/danger-js@11.2.5
with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env:

View File

@@ -11,10 +11,15 @@ jobs:
if: github.repository == 'vector-im/element-x-android'
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: 3.9
- name: Setup Localazy
run: |
curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list
sudo apt-get update && sudo apt-get install localazy
- name: Run Localazy script
run: ./tools/localazy/downloadStrings.sh --all
- name: Create Pull Request for Strings

View File

@@ -25,7 +25,7 @@ dependencies {
implementation(projects.anvilannotations)
api(libs.anvil.compiler.api)
implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.12.0")
implementation("com.squareup:kotlinpoet:1.13.0")
implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")

View File

@@ -33,6 +33,8 @@ plugins {
id("com.google.firebase.appdistribution") version "4.0.0"
id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize")
// To be able to update the firebase.xml files, uncomment and build the project
// id("com.google.gms.google-services")
}
android {
@@ -204,6 +206,7 @@ dependencies {
allLibrariesImpl()
allServicesImpl()
allFeaturesImpl(rootDir)
implementation(projects.libraries.deeplink)
implementation(projects.tests.uitests)
implementation(projects.anvilannotations)
implementation(projects.appnav)
@@ -213,10 +216,12 @@ dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.androidx.preference)
implementation(libs.coil)
implementation(platform(libs.network.okhttp.bom))

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2022 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
@@ -31,15 +30,24 @@
tools:targetApi="33">
<activity
android:name=".MainActivity"
android:theme="@style/Theme.ElementX.Splash"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleInstance"
android:theme="@style/Theme.ElementX.Splash"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Handle deep-link for notification, uncomment to be able to test deeplink with ./tools/adb/deeplink.sh -->
<!--intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="open"
android:scheme="elementx" />
</intent-filter-->
</activity>
<provider

View File

@@ -16,6 +16,7 @@
package io.element.android.x
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
@@ -27,12 +28,16 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.x.di.AppBindings
import timber.log.Timber
class MainActivity : NodeComponentActivity() {
lateinit var mainNode: MainNode
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
@@ -42,16 +47,42 @@ class MainActivity : NodeComponentActivity() {
setContent {
ElementTheme {
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode(it, appBindings.mainDaggerComponentOwner())
MainNode(
it,
appBindings.mainDaggerComponentOwner(),
plugins = listOf(
object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) {
mainNode = node
mainNode.handleIntent(intent)
}
}
)
)
}
}
}
}
}
/**
* Called when:
* - the launcher icon is clicked (if the app is already running);
* - a notification is clicked.
* - the app is going to background (<- this is strange)
*/
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
Timber.w("onNewIntent")
intent ?: return
mainNode.handleIntent(intent)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
bindings<AppBindings>().matrixClientsHolder().onSaveInstanceState(outState)

View File

@@ -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<Plugin>,
) :
ParentNode<MainNode.RootNavTarget>(
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<RootFlowNode>(buildContext, plugins = listOf(loggedInFlowNodeCallback, roomFlowNodeCallback))
return createNode<RootFlowNode>(
context = buildContext,
plugins = listOf(
loggedInFlowNodeCallback,
roomFlowNodeCallback,
)
)
}
@Composable
@@ -81,6 +93,12 @@ class MainNode(
Children(navModel = navModel)
}
fun handleIntent(intent: Intent) {
lifecycleScope.launch {
waitForChildAttached<RootFlowNode>().handleIntent(intent)
}
}
@Parcelize
object RootNavTarget : Parcelable
}

View File

@@ -17,6 +17,9 @@
package io.element.android.x.di
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Resources
import androidx.preference.PreferenceManager
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
@@ -25,6 +28,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
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.x.BuildConfig
import io.element.android.x.R
@@ -47,6 +51,11 @@ object AppModule {
return File(context.filesDir, "sessions")
}
@Provides
fun providesResources(@ApplicationContext context: Context): Resources {
return context.resources
}
@Provides
@SingleIn(AppScope::class)
fun providesAppCoroutineScope(): CoroutineScope {
@@ -69,6 +78,13 @@ object AppModule {
okHttpLoggingLevel = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.BASIC,
)
@Provides
@SingleIn(AppScope::class)
@DefaultPreferences
fun providesDefaultSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
@Provides
@SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers {

View File

@@ -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.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
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.push.impl.intent.IntentProvider
import io.element.android.x.MainActivity
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class IntentProviderImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val deepLinkCreator: DeepLinkCreator,
) : IntentProvider {
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()
}
}
}

View File

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

View File

@@ -41,11 +41,18 @@ dependencies {
allFeaturesApi(rootDir)
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)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.features.verifysession.api)
implementation(projects.features.roomdetails.api)
implementation(projects.tests.uitests)

View File

@@ -33,9 +33,11 @@ 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
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
@@ -55,9 +57,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.parcelize.Parcelize
import kotlin.coroutines.coroutineContext
@ContributesNode(AppScope::class)
class LoggedInFlowNode @AssistedInject constructor(
@@ -125,6 +125,9 @@ class LoggedInFlowNode @AssistedInject constructor(
}
sealed interface NavTarget : Parcelable {
@Parcelize
object Permanent : NavTarget
@Parcelize
object RoomList : NavTarget
@@ -143,6 +146,9 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext)
}
NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) {
@@ -209,13 +215,30 @@ 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) {
Children(
navModel = backstack,
modifier = modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
Box(modifier = modifier) {
Children(
navModel = backstack,
modifier = Modifier,
// Animate navigation to settings and to a room
transitionHandler = rememberDefaultTransitionHandler(),
)
PermanentChild(navTarget = NavTarget.Permanent)
}
}
}

View File

@@ -18,7 +18,6 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe

View File

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

View File

@@ -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.appnav.loggedin
// sealed interface LoggedInEvents {
// object MyEvent : LoggedInEvents
// }

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav.loggedin
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.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class LoggedInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val loggedInPresenter: LoggedInPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,67 @@
/*
* 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.appnav.loggedin
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
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 javax.inject.Inject
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val pushService: PushService,
) : Presenter<LoggedInState> {
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
// Ensure pusher is registered
// TODO Manually select push provider for now
val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect
val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect
pushService.registerWith(matrixClient, pushProvider, distributor)
}
val permissionsState = postNotificationPermissionsPresenter.present()
// fun handleEvents(event: LoggedInEvents) {
// when (event) {
// }
// }
return LoggedInState(
permissionsState = permissionsState,
// eventSink = ::handleEvents
)
}
}

View File

@@ -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.appnav.loggedin
import io.element.android.libraries.permissions.api.PermissionsState
data class LoggedInState(
val permissionsState: PermissionsState,
// val eventSink: (LoggedInEvents) -> Unit
)

View File

@@ -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.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
// Add other state here
)
}
fun aLoggedInState() = LoggedInState(
permissionsState = createDummyPostNotificationPermissionsState(),
// eventSink = {}
)

View File

@@ -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.appnav.loggedin
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.permissions.api.PermissionsView
@Composable
fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val activity = LocalContext.current as? Activity
PermissionsView(
state = state.permissionsState,
modifier = modifier,
openSystemSettings = {
activity?.let { openAppSettingsPage(it) }
}
)
}
@Preview
@Composable
fun LoggedInViewLightPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun LoggedInViewDarkPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: LoggedInState) {
LoggedInView(
state = state
)
}

View File

@@ -0,0 +1,72 @@
/*
* 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.appnav.loggedin
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.libraries.matrix.api.MatrixClient
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
class LoggedInPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionsState.permission).isEmpty()
}
}
private fun createPresenter(): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(),
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter()
}
},
pushService = object : PushService {
override fun notificationStyleChanged() {
}
override fun getAvailablePushProviders(): List<PushProvider> {
return emptyList()
}
override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) {
}
override suspend fun testPush() {
}
}
)
}
}

View File

@@ -4,6 +4,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript {
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
classpath("com.google.gms:google-services:4.3.15")
}
}
@@ -230,6 +231,7 @@ koverMerged {
overrideClassFilter {
includes += "*Presenter"
excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
}
bound {
minValue = 90
@@ -246,6 +248,7 @@ koverMerged {
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
}
bound {
minValue = 90

1
changelog.d/110.feature Normal file
View File

@@ -0,0 +1 @@
[Create and join rooms] Create a room screen (UI)

1
changelog.d/300.feature Normal file
View File

@@ -0,0 +1 @@
Implement room member details screen

View File

@@ -251,7 +251,8 @@ Main libraries and frameworks used in this application:
- Navigation state with [Appyx](https://bumble-tech.github.io/appyx/). Please
watch [this video](https://www.droidcon.com/2022/11/15/model-driven-navigation-with-appyx-from-zero-to-hero/) to learn more about Appyx!
- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil)
- DI: [Dagger](https://dagger.dev/) and [Anvil](https://github.com/square/anvil). Please
watch [this video](https://www.droidcon.com/2022/06/28/dagger-anvil-learning-to-love-dependency-injection/) to learn more about Anvil!
- Reactive State management with Compose runtime and [Molecule](https://github.com/cashapp/molecule)
Some patterns are inspired by [Circuit](https://slackhq.github.io/circuit/)

View File

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

View File

@@ -31,12 +31,14 @@ 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.configureroom.ConfigureRoomNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode
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.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@@ -58,6 +60,9 @@ class CreateRoomFlowNode @AssistedInject constructor(
@Parcelize
object NewRoom : NavTarget
@Parcelize
data class ConfigureRoom(val users: List<MatrixUser>) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -72,9 +77,19 @@ class CreateRoomFlowNode @AssistedInject constructor(
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
}
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
createNode<CreateRoomRootNode>(context = buildContext, plugins = listOf(callback))
}
NavTarget.NewRoom -> {
val callback = object : AddPeopleNode.Callback {
override fun onContinue(selectedUsers: List<MatrixUser>) {
backstack.push(NavTarget.ConfigureRoom(selectedUsers))
}
}
createNode<AddPeopleNode>(context = buildContext, plugins = listOf(callback))
}
is NavTarget.ConfigureRoom -> {
createNode<ConfigureRoomNode>(context = buildContext, plugins = listOf(ConfigureRoomNode.Inputs(navTarget.users)))
}
NavTarget.NewRoom -> createNode<AddPeopleNode>(buildContext)
}
}

View File

@@ -21,10 +21,12 @@ 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.libraries.matrix.ui.model.MatrixUser
@ContributesNode(SessionScope::class)
class AddPeopleNode @AssistedInject constructor(
@@ -33,6 +35,14 @@ class AddPeopleNode @AssistedInject constructor(
private val presenter: AddPeoplePresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinue(selectedUsers: List<MatrixUser>)
}
private fun onContinue(selectedUsers: List<MatrixUser>) {
plugins<Callback>().forEach { it.onContinue(selectedUsers) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -40,7 +50,7 @@ class AddPeopleNode @AssistedInject constructor(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onNextPressed = { },
onNextPressed = this::onContinue,
)
}
}

View File

@@ -29,8 +29,8 @@ 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.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
@@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.CenterAlignedT
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.matrix.ui.model.MatrixUser
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@@ -46,7 +47,7 @@ fun AddPeopleView(
state: AddPeopleState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onNextPressed: () -> Unit = {},
onNextPressed: (List<MatrixUser>) -> Unit = {},
) {
val eventSink = state.eventSink
@@ -56,7 +57,7 @@ fun AddPeopleView(
AddPeopleViewTopBar(
hasSelectedUsers = state.userListState.selectedUsers.isNotEmpty(),
onBackPressed = onBackPressed,
onNextPressed = onNextPressed,
onNextPressed = { onNextPressed(state.userListState.selectedUsers) },
)
}
}

View File

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

View File

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

View File

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

View File

@@ -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.features.createroom.impl.configureroom
import android.net.Uri
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
object CreateRoom : ConfigureRoomEvents
}

View File

@@ -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.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.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.ui.model.MatrixUser
@ContributesNode(SessionScope::class)
class ConfigureRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: ConfigureRoomPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val selectedUsers: List<MatrixUser>
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter by lazy {
presenterFactory.create(ConfigureRoomPresenterArgs(inputs.selectedUsers))
}
@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
)
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList
class ConfigureRoomPresenter @AssistedInject constructor(
@Assisted val args: ConfigureRoomPresenterArgs,
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
fun create(args: ConfigureRoomPresenterArgs): ConfigureRoomPresenter
}
@Composable
override fun present(): ConfigureRoomState {
var roomName by rememberSaveable { mutableStateOf("") }
var topic by rememberSaveable { mutableStateOf("") }
var avatarUri by rememberSaveable { mutableStateOf<Uri?>(null) }
var privacy by rememberSaveable { mutableStateOf<RoomPrivacy?>(null) }
val isCreateButtonEnabled by remember {
derivedStateOf {
roomName.isNotEmpty() && privacy != null
}
}
fun handleEvents(event: ConfigureRoomEvents) {
when (event) {
is ConfigureRoomEvents.AvatarUriChanged -> avatarUri = event.uri
is ConfigureRoomEvents.RoomNameChanged -> roomName = event.name
is ConfigureRoomEvents.TopicChanged -> topic = event.topic
is ConfigureRoomEvents.RoomPrivacyChanged -> privacy = event.privacy
ConfigureRoomEvents.CreateRoom -> Unit // TODO
}
}
return ConfigureRoomState(
selectedUsers = args.selectedUsers.toImmutableList(),
roomName = roomName,
topic = topic,
avatarUri = avatarUri,
privacy = privacy,
isCreateButtonEnabled = isCreateButtonEnabled,
eventSink = ::handleEvents,
)
}
}

View File

@@ -0,0 +1,23 @@
/*
* 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.libraries.matrix.ui.model.MatrixUser
data class ConfigureRoomPresenterArgs(
val selectedUsers: List<MatrixUser>,
)

View File

@@ -0,0 +1,31 @@
/*
* 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
import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState(
val selectedUsers: ImmutableList<MatrixUser>,
val roomName: String,
val topic: String,
val avatarUri: Uri?,
val privacy: RoomPrivacy?,
val isCreateButtonEnabled: Boolean,
val eventSink: (ConfigureRoomEvents) -> Unit
)

View File

@@ -0,0 +1,38 @@
/*
* 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.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState>
get() = sequenceOf(
aConfigureRoomState(),
)
}
fun aConfigureRoomState() = ConfigureRoomState(
selectedUsers = aMatrixUserList().toImmutableList(),
roomName = "",
topic = "",
avatarUri = null,
privacy = null,
isCreateButtonEnabled = false,
eventSink = {}
)

View File

@@ -0,0 +1,210 @@
/*
* 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 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.lazy.rememberLazyListState
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.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 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.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 selectedUsersListState = rememberLazyListState()
Scaffold(
modifier = modifier,
topBar = {
ConfigureRoomToolbar(
isNextActionEnabled = state.isCreateButtonEnabled,
onBackPressed = onBackPressed,
onNextPressed = { state.eventSink(ConfigureRoomEvents.CreateRoom) },
)
}
) { padding ->
Column(
modifier = Modifier.padding(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
RoomNameWithAvatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarUri = state.avatarUri,
roomName = state.roomName,
onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) },
)
RoomTopic(
modifier = Modifier.padding(horizontal = 16.dp),
topic = state.topic,
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
SelectedUsersList(
listState = selectedUsersListState,
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.selectedUsers,
onUserRemoved = { }, // TODO
)
Spacer(Modifier.weight(1f))
RoomPrivacyOptions(
modifier = Modifier.padding(bottom = 40.dp),
selected = state.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,
)
}

View File

@@ -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 {
Public,
Private,
}

View File

@@ -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<RoomPrivacyItem> {
return RoomPrivacy.values()
.map {
when (it) {
RoomPrivacy.Public -> 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.Private -> 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()
}

View File

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

View File

@@ -3,4 +3,6 @@
<string name="screen_create_room_action_create_room">"Nueva sala"</string>
<string name="screen_create_room_action_invite_people">"Invitar gente"</string>
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
<string name="screen_start_chat_unknown_profile">"No podemos validar el ID de Matrix de este usuario. Es posible que no reciba la invitación."</string>
</resources>

View File

@@ -3,4 +3,6 @@
<string name="screen_create_room_action_create_room">"Nuova stanza"</string>
<string name="screen_create_room_action_invite_people">"Invita persone"</string>
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
<string name="screen_start_chat_unknown_profile">"Non possiamo convalidare l\'ID Matrix di questo utente. L\'invito potrebbe non essere ricevuto."</string>
</resources>

View File

@@ -3,4 +3,6 @@
<string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_action_invite_people">"Invitați persoane"</string>
<string name="screen_create_room_add_people_title">"Adaugați persoane"</string>
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_start_chat_unknown_profile">"Nu am putut valida ID-ul Matrix al acestui utilizator. Este posibil ca invitația să nu fi fost primită."</string>
</resources>

View File

@@ -3,4 +3,15 @@
<string name="screen_create_room_action_create_room">"New room"</string>
<string name="screen_create_room_action_invite_people">"Invite people"</string>
<string name="screen_create_room_add_people_title">"Add people"</string>
<string name="screen_create_room_private_option_description">"Messages in this room are encrypted. Encryption cant be disabled afterwards."</string>
<string name="screen_create_room_private_option_title">"Private room (invite only)"</string>
<string name="screen_create_room_public_option_description">"Messages are not encrypted and anyone can read them. You can enable encryption at a later date."</string>
<string name="screen_create_room_public_option_title">"Public room (anyone)"</string>
<string name="screen_create_room_room_name_label">"Room name"</string>
<string name="screen_create_room_room_name_placeholder">"e.g. Product Sprint"</string>
<string name="screen_create_room_title">"Create a room"</string>
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
<string name="screen_create_room_topic_placeholder">"What is this room about?"</string>
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
<string name="screen_start_chat_unknown_profile">"We cant validate this users Matrix ID. The invite might not be received."</string>
</resources>

View File

@@ -0,0 +1,115 @@
/*
* 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.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 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
@Before
fun setup() {
presenter = ConfigureRoomPresenter(ConfigureRoomPresenterArgs(emptyList()))
}
@Test
fun `present - initial state`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.roomName).isEmpty()
assertThat(initialState.topic).isEmpty()
assertThat(initialState.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()
assertThat(initialState.isCreateButtonEnabled).isFalse()
// Room name not empty
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
var newState: ConfigureRoomState = awaitItem()
assertThat(newState.roomName).isEqualTo(A_ROOM_NAME)
assertThat(newState.isCreateButtonEnabled).isFalse()
// Select privacy
initialState.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Private))
newState = awaitItem()
assertThat(newState.privacy).isEqualTo(RoomPrivacy.Private)
assertThat(newState.isCreateButtonEnabled).isTrue()
// Clear room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(""))
newState = awaitItem()
assertThat(newState.roomName).isEqualTo("")
assertThat(newState.isCreateButtonEnabled).isFalse()
}
}
@Test
fun `present - state is updated when fields are changed`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Room name
initialState.eventSink(ConfigureRoomEvents.RoomNameChanged(A_ROOM_NAME))
val stateAfterRoomNameChanged = awaitItem()
assertThat(stateAfterRoomNameChanged.roomName).isEqualTo(A_ROOM_NAME)
// Room topic
stateAfterRoomNameChanged.eventSink(ConfigureRoomEvents.TopicChanged(A_MESSAGE))
val stateAfterTopicChanged = awaitItem()
assertThat(stateAfterTopicChanged.topic).isEqualTo(A_MESSAGE)
// Room avatar
val anUri = Uri.parse(AN_AVATAR_URL)
stateAfterTopicChanged.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri))
val stateAfterAvatarUriChanged = awaitItem()
assertThat(stateAfterAvatarUriChanged.avatarUri).isEqualTo(anUri)
// Room privacy
stateAfterAvatarUriChanged.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(RoomPrivacy.Public))
val stateAfterPrivacyChanged = awaitItem()
assertThat(stateAfterPrivacyChanged.privacy).isEqualTo(RoomPrivacy.Public)
}
}
}

View File

@@ -49,7 +49,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
@@ -77,6 +79,7 @@ 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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.testtags.TestTags
@@ -206,6 +209,7 @@ internal fun ChangeServerSection(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
state: LoginRootState,
@@ -239,7 +243,11 @@ internal fun LoginForm(
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername),
.testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it))
}),
label = {
Text(text = stringResource(R.string.screen_login_username_hint))
},
@@ -279,7 +287,11 @@ internal fun LoginForm(
modifier = Modifier
.fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword),
.testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))
}),
onValueChange = {
passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it))

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_change_server_submit">"Continuer"</string>
<string name="screen_login_submit">"Continuer"</string>
</resources>

View File

@@ -29,10 +29,13 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@@ -54,6 +57,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
object RoomMemberList : NavTarget
@Parcelize
data class RoomMemberDetails(val roomMember: RoomMember) : NavTarget
}
interface Callback : Plugin {
@@ -69,7 +75,17 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.RoomDetails -> createNode<RoomDetailsNode>(buildContext, listOf(callback))
NavTarget.RoomMemberList -> createNode<RoomMemberListNode>(buildContext)
NavTarget.RoomMemberList -> {
val callback = object : RoomMemberListNode.Callback {
override fun openRoomMemberDetails(roomMember: RoomMember) {
backstack.push(NavTarget.RoomMemberDetails(roomMember))
}
}
createNode<RoomMemberListNode>(buildContext, listOf(callback))
}
is NavTarget.RoomMemberDetails -> {
createNode<RoomMemberDetailsNode>(buildContext, listOf(RoomMemberDetailsNode.Inputs(navTarget.roomMember)))
}
}
}

View File

@@ -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(
@@ -57,7 +56,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)
)
}
}

View File

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

View File

@@ -19,17 +19,35 @@ 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.RoomMatrixUserDataSource
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.features.userlist.api.MatrixUserDataSource
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 RoomMemberModule {
interface RoomMemberBindsModule {
@Binds
@Named("RoomMembers")
fun bindRoomMemberUserListDataSource(dataSource: RoomMatrixUserDataSource): MatrixUserDataSource
}
@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)
}
}
}
}

View File

@@ -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<Plugin>,
private val room: MatrixRoom,
private val presenter: RoomMemberListPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun openRoomMemberDetails(roomMember: RoomMember)
}
private val callback = plugins<Callback>().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

View File

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

View File

@@ -0,0 +1,80 @@
/*
* 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 android.content.Context
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<Plugin>,
presenterFactory: RoomMemberDetailsPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val member: RoomMember,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.member)
private fun onShareUser(context: Context) {
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)
}
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
val state = presenter.present()
RoomMemberDetailsView(
state = state,
modifier = modifier,
goBack = { navigateUp() },
onShareUser = { onShareUser(context) }
)
}
}

View File

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

View File

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

View File

@@ -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<RoomMemberDetailsState> {
override val values: Sequence<RoomMemberDetailsState>
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 = {},
)

View File

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

View File

@@ -233,7 +233,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,
@@ -242,4 +243,5 @@ fun aRoomMember(
isNameAmbiguous = isNameAmbiguous,
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
)

View File

@@ -29,10 +29,12 @@ import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource
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

View File

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

View File

@@ -41,6 +41,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
@@ -61,6 +62,7 @@ dependencies {
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.permissions.noop)
androidTestImplementation(libs.test.junitext)
}

View File

@@ -40,9 +40,9 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")),
roomList = aRoomListRoomSummaryList(),
filter = "filter",
eventSink = {},
snackbarMessage = null,
displayVerificationPrompt = false,
eventSink = {}
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {

View File

@@ -197,11 +197,13 @@ fun RoomListContent(
}
},
snackbarHost = {
SnackbarHost (snackbarHostState) { data ->
Snackbar(
snackbarData = data,
)
}
SnackbarHost(snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
)
}
},
)
}

View File

@@ -21,6 +21,7 @@ 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -97,7 +98,7 @@ fun UserListView(
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
SelectedUsersList(
listState = state.selectedUsersListState,
modifier = Modifier.padding(16.dp),
contentPadding = PaddingValues(16.dp),
selectedUsers = state.selectedUsers,
onUserRemoved = {
state.eventSink(UserListEvents.RemoveFromSelection(it))
@@ -174,7 +175,7 @@ fun SearchUserBar(
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
listState = selectedUsersListState,
modifier = Modifier.padding(16.dp),
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
onUserRemoved = onUserDeselected,
)
@@ -244,11 +245,13 @@ fun SelectedUsersList(
listState: LazyListState,
selectedUsers: ImmutableList<MatrixUser>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
onUserRemoved: (MatrixUser) -> Unit = {},
) {
LazyRow(
state = listState,
modifier = modifier,
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(selectedUsers.toList()) { matrixUser ->

View File

@@ -4,14 +4,13 @@
[versions]
# Project
android_gradle_plugin = "7.4.2"
firebase_gradle_plugin = "3.2.0"
kotlin = "1.8.10"
ksp = "1.8.10-1.0.9"
molecule = "0.8.0"
# AndroidX
material = "1.8.0"
corektx = "1.10.0"
core = "1.10.0"
datastore = "1.0.0"
constraintlayout = "2.1.4"
recyclerview = "1.3.0"
@@ -38,7 +37,7 @@ datetime = "0.4.0"
serialization_json = "1.5.0"
showkase = "1.0.0-beta17"
jsoup = "1.15.4"
appyx = "1.1.2"
appyx = "1.2.0"
dependencycheck = "8.2.1"
stem = "2.3.0"
sqldelight = "1.5.5"
@@ -55,12 +54,14 @@ dependencygraph = "0.10"
[libraries]
# Project
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
firebase_gradle_plugin = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebase_gradle_plugin" }
kotlin_gradle_plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
# https://firebase.google.com/docs/android/setup#available-libraries
google_firebase_bom = "com.google.firebase:firebase-bom:31.2.3"
# AndroidX
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "corektx" }
androidx_core = { module = "androidx.core:core", version.ref = "core" }
androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
@@ -73,6 +74,7 @@ androidx_security_crypto = "androidx.security:security-crypto:1.0.0"
androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" }
androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx_startup = { module = "androidx.startup:startup-runtime", version.ref = "startup" }
androidx_preference = "androidx.preference:preference:1.2.0"
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
@@ -131,6 +133,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"

View File

@@ -27,5 +27,6 @@ dependencies {
implementation(libs.timber)
implementation(libs.androidx.corektx)
implementation(libs.androidx.activity.activity)
implementation(libs.androidx.security.crypto)
implementation(projects.libraries.core)
}

View File

@@ -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.androidutils.file
import android.content.Context
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKeys
import java.io.File
class EncryptedFileFactory(
private val context: Context,
) {
fun create(file: File): EncryptedFile {
// We need to use the same key for all the encrypted files.
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
return EncryptedFile.Builder(
file,
context,
masterKeyAlias,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
}
}

View File

@@ -16,6 +16,7 @@
package io.element.android.libraries.androidutils.system
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Activity
import android.content.ActivityNotFoundException
@@ -31,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
/**
@@ -77,6 +79,7 @@ fun Context.getApplicationLabel(packageName: String): String {
* Note: If the user finally does not grant the permission, PushManager.isBackgroundSyncAllowed()
* will return false and the notification privacy will fallback to "LOW_DETAIL".
*/
@SuppressLint("BatteryLife")
fun requestDisablingBatteryOptimization(activity: Activity, activityResultLauncher: ActivityResultLauncher<Intent>) {
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
@@ -114,13 +117,30 @@ fun startNotificationSettingsIntent(context: Context, activityResultLauncher: Ac
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
} else {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
intent.putExtra("app_package", context.packageName)
intent.putExtra("app_uid", context.applicationInfo?.uid)
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.data = Uri.fromParts("package", context.packageName, null)
}
activityResultLauncher.launch(intent)
}
fun openAppSettingsPage(
activity: Activity,
noActivityFoundMessage: String = activity.getString(R.string.error_no_compatible_app_found),
) {
try {
activity.startActivity(
Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.fromParts("package", activity.packageName, null)
}
)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(noActivityFoundMessage)
}
}
/**
* Shows notification system settings for the given channel id.
*/
@@ -137,7 +157,7 @@ fun startNotificationChannelSettingsIntent(activity: Activity, channelID: String
fun startAddGoogleAccountIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>,
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"))
@@ -152,7 +172,7 @@ fun startAddGoogleAccountIntent(
fun startInstallFromSourceIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>,
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)))
@@ -170,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"
@@ -198,7 +218,7 @@ fun startSharePlainTextIntent(
fun startImportTextFromFileIntent(
context: Context,
activityResultLauncher: ActivityResultLauncher<Intent>,
noActivityFoundMessage: String,
noActivityFoundMessage: String = context.getString(R.string.error_no_compatible_app_found),
) {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
type = "text/plain"

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"No se encontró ninguna aplicación compatible con esta acción."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Non è stata trovata alcuna app compatibile per gestire questa azione."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nu a fost găsită nicio aplicație capabilă să gestioneze această acțiune."</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"No compatible app was found to handle this action."</string>
</resources>

View File

@@ -0,0 +1,41 @@
/*
* 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.core.cache
/**
* A FIFO circular buffer of T.
* This class is not thread safe.
*/
class CircularCache<T : Any>(cacheSize: Int, factory: (Int) -> Array<T?>) {
companion object {
inline fun <reified T : Any> create(cacheSize: Int) = CircularCache(cacheSize) { Array<T?>(cacheSize) { null } }
}
private val cache = factory(cacheSize)
private var writeIndex = 0
fun contains(value: T): Boolean = cache.contains(value)
fun put(value: T) {
if (writeIndex == cache.size) {
writeIndex = 0
}
cache[writeIndex] = value
writeIndex++
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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.core.cache
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CircularCacheTest {
@Test
fun `when putting more than cache size then cache is limited to cache size`() {
val (cache, internalData) = createIntCache(cacheSize = 3)
cache.putInOrder(1, 1, 1, 1, 1, 1)
assertThat(internalData).isEqualTo(arrayOf(1, 1, 1))
}
@Test
fun `when putting more than cache then acts as FIFO`() {
val (cache, internalData) = createIntCache(cacheSize = 3)
cache.putInOrder(1, 2, 3, 4)
assertThat(internalData).isEqualTo(arrayOf(4, 2, 3))
}
@Test
fun `given empty cache when checking if contains key then is false`() {
val (cache, _) = createIntCache(cacheSize = 3)
val result = cache.contains(1)
assertThat(result).isFalse()
}
@Test
fun `given cached key when checking if contains key then is true`() {
val (cache, _) = createIntCache(cacheSize = 3)
cache.put(1)
val result = cache.contains(1)
assertThat(result).isTrue()
}
private fun createIntCache(cacheSize: Int): Pair<CircularCache<Int>, Array<Int?>> {
var internalData: Array<Int?>? = null
val factory: (Int) -> Array<Int?> = {
Array<Int?>(it) { null }.also { array -> internalData = array }
}
return CircularCache(cacheSize, factory) to internalData!!
}
private fun CircularCache<Int>.putInOrder(vararg values: Int) {
values.forEach { put(it) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {

View File

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

View File

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

View File

@@ -29,8 +29,17 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
@@ -118,3 +127,26 @@ private fun ContentToPreview() {
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(autofillTypes: List<AutofillType>, onFill: (String) -> Unit) = composed {
val autofillNode = AutofillNode(autofillTypes, onFill = onFill)
LocalAutofillTree.current += autofillNode
val autofill = LocalAutofill.current
this
.onGloballyPositioned {
// Inform autofill framework of where our composable is so it can show the popup in the right place
autofillNode.boundingBox = it.boundsInWindow()
}
.onFocusChanged {
autofill?.run {
if (it.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}

View File

@@ -0,0 +1,21 @@
/*
* 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.di
import javax.inject.Qualifier
@Qualifier annotation class DefaultPreferences

View File

@@ -34,4 +34,6 @@ dependencies {
implementation(libs.sqlcipher)
implementation(libs.sqlite)
implementation(libs.androidx.security.crypto)
implementation(projects.libraries.androidutils)
}

View File

@@ -18,6 +18,7 @@ package io.element.encrypteddb.passphrase
import android.content.Context
import androidx.security.crypto.EncryptedFile
import io.element.android.libraries.androidutils.file.EncryptedFileFactory
import java.io.File
import java.security.SecureRandom
@@ -25,23 +26,16 @@ import java.security.SecureRandom
* Provides a secure passphrase for SQLCipher by generating a random secret and storing it into an [EncryptedFile].
* @param context Android [Context], used by [EncryptedFile] for cryptographic operations.
* @param file Destination file where the key will be stored.
* @param alias Alias of the key used to encrypt & decrypt the [EncryptedFile]'s contents.
* @param secretSize Length of the generated secret.
*/
class RandomSecretPassphraseProvider(
private val context: Context,
private val file: File,
private val alias: String,
private val secretSize: Int = 256,
) : PassphraseProvider {
override fun getPassphrase(): ByteArray {
val encryptedFile = EncryptedFile.Builder(
file,
context,
alias,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
val encryptedFile = EncryptedFileFactory(context).create(file)
return if (!file.exists()) {
val secret = generateSecret()
encryptedFile.openFileOutput().use { it.write(secret) }

View File

@@ -20,6 +20,8 @@ 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.media.MediaResolver
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
@@ -36,6 +38,8 @@ interface MatrixClient : Closeable {
fun stopSync()
fun mediaResolver(): MediaResolver
fun sessionVerificationService(): SessionVerificationService
fun pushersService(): PushersService
fun notificationService(): NotificationService
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String?>

View File

@@ -16,7 +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() = if (BuildConfig.DEBUG && !MatrixPatterns.isEventId(this)) {
error("`$this` is not a valid event Id")
} else {
EventId(this)
}

View File

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

View File

@@ -16,7 +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() = if (BuildConfig.DEBUG && !MatrixPatterns.isRoomId(this)) {
error("`$this` is not a valid room Id")
} else {
RoomId(this)
}

View File

@@ -16,4 +16,12 @@
package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
typealias SessionId = UserId
fun String.asSessionId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSessionId(this)) {
error("`$this` is not a valid session Id")
} else {
SessionId(this)
}

View File

@@ -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
@@ -25,3 +26,9 @@ value class SpaceId(val value: String) : Serializable
* Value to use when no space is selected by the user.
*/
val MAIN_SPACE = SpaceId("!mainSpace")
fun String.asSpaceId() = if (BuildConfig.DEBUG && !MatrixPatterns.isSpaceId(this)) {
error("`$this` is not a valid space Id")
} else {
SpaceId(this)
}

View File

@@ -16,7 +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() = if (BuildConfig.DEBUG && !MatrixPatterns.isThreadId(this)) {
error("`$this` is not a valid thread Id")
} else {
ThreadId(this)
}

View File

@@ -16,7 +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() = if (BuildConfig.DEBUG && !MatrixPatterns.isUserId(this)) {
error("`$this` is not a valid user Id")
} else {
UserId(this)
}

Some files were not shown because too many files have changed in this diff Show More