Merge branch 'develop' into feature/fga/better_media_handling

This commit is contained in:
ganfra
2023-07-18 15:13:59 +02:00
221 changed files with 4574 additions and 845 deletions

View File

@@ -34,7 +34,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
@@ -45,7 +45,7 @@ jobs:
name: elementx-debug
path: |
app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.0
- uses: rnkdsh/action-upload-diawi@v1.5.1
id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true

View File

@@ -29,8 +29,11 @@ jobs:
- name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
- name: 📈 Run screenshot tests, generate kover report and verify coverage
run: ./gradlew verifyPaparazziDebug koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: 📈 Generate kover report and verify coverage
run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: ✅ Upload kover report
if: always()
@@ -59,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis

View File

@@ -39,7 +39,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite
@@ -51,6 +51,12 @@ jobs:
name: linting-report
path: |
*/build/reports/**/*.*
- name: 🔊 Publish results to Sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
- name: Prepare Danger
if: always()
run: |

View File

@@ -24,7 +24,7 @@ jobs:
java-version: '17'
# Add gradle cache, this should speed up the process
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots

View File

@@ -33,15 +33,18 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.6.0
uses: gradle/gradle-build-action@v2.6.1
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
- name: 📈 Run screenshot tests, generate kover report and verify coverage
run: ./gradlew verifyPaparazziDebug koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
- name: 📈Generate kover report and verify coverage
run: ./gradlew koverMergedReport koverMergedVerify $CI_GRADLE_ARG_PROPERTIES -Pci-build=true
- name: 🚫 Upload kover failed coverage reports
if: failure()
@@ -80,13 +83,6 @@ jobs:
**/out/failures/
**/build/reports/tests/*UnitTest/
- name: 🔊 Publish results to Sonar
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
# https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov
if: always()

View File

@@ -30,6 +30,7 @@ jobs:
- name: Print itemId
run: echo ${{ steps.addItem.outputs.itemId }}
- uses: kalgurn/update-project-item-status@main
if: ${{ steps.addItem.outputs.itemId }}
with:
project-url: https://github.com/orgs/vector-im/projects/91
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}

View File

@@ -2,6 +2,7 @@
<dictionary name="shared">
<words>
<w>backstack</w>
<w>ftue</w>
<w>homeserver</w>
<w>kover</w>
<w>measurables</w>

View File

@@ -23,6 +23,8 @@ appId: ${APP_ID}
- inputText: ${PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
- tapOn: "Continue"
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View File

@@ -0,0 +1,6 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible:
id: "welcome_screen-title"
timeout: 10_000

View File

@@ -209,6 +209,7 @@ dependencies {
implementation(libs.androidx.core)
implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup)
implementation(libs.androidx.preference)

View File

@@ -32,6 +32,18 @@
android:theme="@style/Theme.ElementX"
tools:targetApi="33">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name='androidx.lifecycle.ProcessLifecycleInitializer'
android:value='androidx.startup' />
</provider>
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"

View File

@@ -54,6 +54,8 @@ dependencies {
implementation(projects.tests.uitests)
implementation(libs.coil)
implementation(projects.features.ftue.api)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api)

View File

@@ -19,6 +19,8 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -41,7 +43,6 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
@@ -49,6 +50,8 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -64,13 +67,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@@ -81,14 +81,14 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomListEntryPoint: RoomListEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val analyticsOptInEntryPoint: AnalyticsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val analyticsService: AnalyticsService,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@@ -99,19 +99,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins = plugins
) {
private fun observeAnalyticsState() {
analyticsService.didAskUserConsent()
.distinctUntilChanged()
.onEach { isConsentAsked ->
if (isConsentAsked) {
backstack.removeLast(NavTarget.AnalyticsOptIn)
} else {
backstack.push(NavTarget.AnalyticsOptIn)
}
}
.launchIn(lifecycleScope)
}
interface Callback : Plugin {
fun onOpenBugReport() = Unit
}
@@ -136,7 +123,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
observeAnalyticsState()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
@@ -146,6 +133,10 @@ class LoggedInFlowNode @AssistedInject constructor(
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) {
backstack.push(NavTarget.Ftue)
}
},
onResume = {
syncService.startSync()
@@ -209,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget
@Parcelize
object AnalyticsOptIn : NavTarget
object Ftue : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -306,8 +297,13 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
NavTarget.AnalyticsOptIn -> {
analyticsOptInEntryPoint.createNode(this, buildContext)
NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
backstack.pop()
}
}).build()
}
}
}
@@ -335,7 +331,11 @@ class LoggedInFlowNode @AssistedInject constructor(
transitionHandler = rememberDefaultTransitionHandler(),
)
PermanentChild(navTarget = NavTarget.Permanent)
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) {
PermanentChild(navTarget = NavTarget.Permanent)
}
}
}

View File

@@ -40,12 +40,11 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.preferences.api.CacheService
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -57,29 +56,22 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.UUID
@ContributesNode(AppScope::class)
class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val loginUserStory: LoginUserStory,
) :
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack(
@@ -91,26 +83,25 @@ class RootFlowNode @AssistedInject constructor(
) {
override fun onBuilt() {
matrixClientsHolder.restore(buildContext.savedStateMap)
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt()
observeLoggedInState()
observeNavState()
}
override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state)
matrixClientsHolder.save(state)
matrixClientsHolder.saveIntoSavedState(state)
navStateFlowFactory.saveIntoSavedState(state)
}
private fun observeLoggedInState() {
combine(
cacheService.onClearedCacheEventFlow(),
isUserLoggedInFlow(),
) { _, isLoggedIn -> isLoggedIn }
.onEach { isLoggedIn ->
Timber.v("isLoggedIn=$isLoggedIn")
if (isLoggedIn) {
private fun observeNavState() {
navStateFlowFactory.create(buildContext.savedStateMap)
.distinctUntilChanged()
.onEach { navState ->
Timber.v("navState=$navState")
if (navState.isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) },
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
@@ -120,19 +111,8 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
}
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow() {
@@ -145,14 +125,8 @@ class RootFlowNode @AssistedInject constructor(
onFailure: () -> Unit = {},
onSuccess: (SessionId) -> Unit = {},
) {
// If the session is already known it'll be restored by the node hierarchy
if (matrixClientsHolder.knowSession(sessionId)) {
Timber.v("Session $sessionId already alive, no need to restore.")
return
}
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
matrixClientsHolder.getOrRestore(sessionId)
.onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}
@@ -204,7 +178,7 @@ class RootFlowNode @AssistedInject constructor(
@Parcelize
data class LoggedInFlow(
val sessionId: SessionId,
val navId: UUID = UUID.randomUUID(),
val navId: Int
) : NavTarget
@Parcelize
@@ -278,11 +252,5 @@ class RootFlowNode @AssistedInject constructor(
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
}
}
private fun CacheService.onClearedCacheEventFlow(): Flow<Unit> {
return clearedCacheEventFlow
.onEach { sessionId -> matrixClientsHolder.remove(sessionId) }
.map { }
.onStart { emit((Unit)) }
}
}

View File

@@ -18,23 +18,28 @@ package io.element.android.appnav.di
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) {
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class MatrixClientsHolder @Inject constructor(private val authenticationService: MatrixAuthenticationService) : MatrixClientProvider {
private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
fun add(matrixClient: MatrixClient) {
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
private val restoreMutex = Mutex()
fun removeAll() {
sessionIdsToMatrixClient.clear()
@@ -44,16 +49,21 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
sessionIdsToMatrixClient.remove(sessionId)
}
fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty()
fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId)
fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId]
}
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
return restoreMutex.withLock {
when (val matrixClient = getOrNull(sessionId)) {
null -> restore(sessionId)
else -> Result.success(matrixClient)
}
}
}
@Suppress("UNCHECKED_CAST")
fun restore(state: SavedStateMap?) {
fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state")
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
Timber.w("Restore with non-empty map")
@@ -64,21 +74,25 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
// Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs.
runBlocking {
sessionIds.forEach { sessionId ->
Timber.d("Restore matrix session: $sessionId")
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
add(matrixClient)
}
.onFailure {
Timber.e("Fail to restore session")
}
restore(sessionId)
}
}
}
fun save(state: MutableSavedStateMap) {
fun saveIntoSavedState(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys
}
private suspend fun restore(sessionId: SessionId): Result<MatrixClient> {
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
.onFailure {
Timber.e("Fail to restore session")
}
}
}

View File

@@ -32,11 +32,11 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.NodeLifecycleCallback
import io.element.android.appnav.safeRoot
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.BackstackNode
@@ -92,9 +92,9 @@ class RoomFlowNode @AssistedInject constructor(
.distinctUntilChanged()
.onEach { isLoaded ->
if (isLoaded) {
backstack.safeRoot(NavTarget.Loaded)
backstack.newRoot(NavTarget.Loaded)
} else {
backstack.safeRoot(NavTarget.Loading)
backstack.newRoot(NavTarget.Loading)
}
}.launchIn(lifecycleScope)
}

View File

@@ -0,0 +1,32 @@
/*
* 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.root
/**
* [RootNavState] produced by [RootNavStateFlowFactory].
*/
data class RootNavState(
/**
* This value is incremented when a clear cache is done.
* Can be useful to track to force ui state to re-render
*/
val cacheIndex: Int,
/**
* true if we are currently loggedIn.
*/
val isLoggedIn: Boolean
)

View File

@@ -0,0 +1,99 @@
/*
* 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.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY"
/**
* This class is responsible for creating a flow of [RootNavState].
* It gathers data from multiple datasource and creates a unique one.
*/
class RootNavStateFlowFactory @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService,
private val matrixClientsHolder: MatrixClientsHolder,
private val loginUserStory: LoginUserStory,
) {
private var currentCacheIndex = 0
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine(
cacheIndexFlow(savedStateMap),
isUserLoggedInFlow(),
) { cacheIndex, isLoggedIn ->
RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn)
}
}
fun saveIntoSavedState(stateMap: MutableSavedStateMap) {
stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex
}
/**
* @return a flow of integer, where each time a clear cache is done, we have a new incremented value.
*/
private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow<Int> {
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
return cacheService.clearedCacheEventFlow
.onEach { sessionId ->
matrixClientsHolder.remove(sessionId)
}
.toIndexFlow(initialCacheIndex)
.onEach { cacheIndex ->
currentCacheIndex = cacheIndex
}
}
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
}
/**
* @return a flow of integer that increments the value by one each time a new element is emitted upstream.
*/
private fun Flow<Any>.toIndexFlow(initialValue: Int): Flow<Int> = flow {
var index = initialValue
emit(initialValue)
collect {
emit(++index)
}
}
private fun SavedStateMap?.getCacheIndexOrDefault(): Int {
return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0
}
}

View File

@@ -32,7 +32,7 @@ import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.NoopAppNavigationStateService
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import org.junit.Rule
import org.junit.Test
@@ -82,7 +82,7 @@ class RoomFlowNodeTest {
plugins = plugins,
messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = NoopAppNavigationStateService(),
appNavigationStateService = FakeAppNavigationStateService(),
roomMembershipObserver = RoomMembershipObserver()
)

View File

@@ -246,7 +246,8 @@ koverMerged {
name = "Check code coverage of states"
target = kotlinx.kover.api.VerificationTarget.CLASS
overrideClassFilter {
includes += "*State"
includes += "^*State$"
excludes += "io.element.android.appnav.root.RootNavState*"
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*"
@@ -259,6 +260,11 @@ koverMerged {
excludes += "io.element.android.libraries.designsystem.swipe.SwipeableActionsState*"
excludes += "io.element.android.features.messages.impl.timeline.components.ExpandableState*"
excludes += "io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*"
excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*"
excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState"
excludes += "io.element.android.libraries.maplibre.compose.SymbolState*"
excludes += "io.element.android.features.ftue.api.state.*"
excludes += "io.element.android.features.ftue.impl.welcome.state.*"
}
bound {
minValue = 90
@@ -283,13 +289,6 @@ koverMerged {
}
}
// Make Kover depend on Paparazzi
tasks.whenTaskAdded {
if (name.startsWith("koverMerged")) {
dependsOn(":tests:uitests:verifyPaparazziDebug")
}
}
// When running on the CI, run only debug test variants
val ciBuildProperty = "ci-build"
val isCiBuild = if (project.hasProperty(ciBuildProperty)) {

1
changelog.d/880.bugfix Normal file
View File

@@ -0,0 +1 @@
Fix sliding sync loop restarts due to expirations.

View File

@@ -16,12 +16,11 @@
package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -48,6 +47,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@@ -60,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun AnalyticsOptInView(
@@ -69,6 +71,16 @@ fun AnalyticsOptInView(
) {
LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink
fun onTermsAccepted() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
}
fun onTermsDeclined() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
}
BackHandler(onBack = ::onTermsDeclined)
HeaderFooterPage(
modifier = modifier
.fillMaxSize()
@@ -76,7 +88,13 @@ fun AnalyticsOptInView(
.imePadding(),
header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() },
footer = { AnalyticsOptInFooter(eventSink) })
footer = {
AnalyticsOptInFooter(
onTermsAccepted = ::onTermsAccepted,
onTermsDeclined = ::onTermsDeclined,
)
}
)
}
@Composable
@@ -114,6 +132,19 @@ private fun AnalyticsOptInHeader(
}
}
@Composable
private fun CheckIcon(modifier: Modifier = Modifier) {
Icon(
modifier = modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
@Composable
private fun AnalyticsOptInContent(
modifier: Modifier = Modifier,
@@ -125,80 +156,45 @@ private fun AnalyticsOptInContent(
verticalBias = -0.4f
)
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
idx = 0
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
idx = 1
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_settings),
idx = 2
)
}
}
}
@Composable
private fun AnalyticsOptInContentRow(
text: String,
idx: Int,
modifier: Modifier = Modifier,
) {
val radius = 14.dp
val bgShape = when (idx) {
0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
else -> RoundedCornerShape(0.dp)
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = ElementTheme.colors.temporaryColorBgSpecial,
shape = bgShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
) {
Icon(
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = text,
style = ElementTheme.typography.fontBodyMdMedium,
color = MaterialTheme.colorScheme.primary,
InfoListOrganism(
items = persistentListOf(
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconComposable = { CheckIcon() },
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconComposable = { CheckIcon() },
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconComposable = { CheckIcon() },
),
),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.textPrimary,
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
)
}
}
@Composable
private fun AnalyticsOptInFooter(
eventSink: (AnalyticsOptInEvents) -> Unit,
onTermsAccepted: () -> Unit,
onTermsDeclined: () -> Unit,
modifier: Modifier = Modifier,
) {
ButtonColumnMolecule(
modifier = modifier,
) {
Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
onClick = onTermsAccepted,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_ok))
}
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
onClick = onTermsDeclined,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_not_now))

View File

@@ -67,4 +67,8 @@ class FakeAnalyticsService(
override fun trackError(throwable: Throwable) {
}
override suspend fun reset() {
didAskUserConsentFlow.value = 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.ftue.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onFtueFlowFinished()
}
}

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.ftue.api.state
import kotlinx.coroutines.flow.StateFlow
interface FtueState {
val shouldDisplayFlow: StateFlow<Boolean>
suspend fun reset()
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.ftue.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.ftue.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test)
ksp(libs.showkase.processor)
}

View File

@@ -0,0 +1,46 @@
/*
* 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.ftue.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : FtueEntryPoint.NodeBuilder {
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
}
}
}
}

View File

@@ -0,0 +1,154 @@
/*
* 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.ftue.impl
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.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
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.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
) : BackstackNode<FtueFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Placeholder : NavTarget
@Parcelize
object WelcomeScreen : NavTarget
@Parcelize
object AnalyticsOptIn : NavTarget
}
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(onCreate = {
lifecycleScope.launch { moveToNextStep() }
})
analyticsService.didAskUserConsent()
.drop(1) // We only care about consent passing from not asked to asked state
.onEach { didAskUserConsent ->
if (didAskUserConsent) {
lifecycleScope.launch { moveToNextStep() }
}
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
ftueState.setWelcomeScreenShown()
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
}
}
private suspend fun moveToNextStep() {
when (ftueState.getNextStep()) {
is FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
is FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
null -> callback?.onFtueFlowFinished()
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
@ContributesNode(AppScope::class)
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
}
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
override fun onBackPressed() {
// No-op
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.state
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultFtueState @Inject constructor(
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
override suspend fun reset() {
welcomeScreenState.reset()
analyticsService.reset()
}
init {
analyticsService.didAskUserConsent()
.onEach { updateState() }
.launchIn(coroutineScope)
}
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
}
private fun isAnyStepIncomplete(): Boolean {
return listOf(
shouldDisplayWelcomeScreen(),
needsAnalyticsOptIn()
).any { it }
}
private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
}
private fun shouldDisplayWelcomeScreen(): Boolean {
return welcomeScreenState.isWelcomeScreenNeeded()
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
shouldDisplayFlow.value = isAnyStepIncomplete()
}
}
sealed interface FtueStep {
object WelcomeScreen : FtueStep
object AnalyticsOptIn : FtueStep
}

View File

@@ -0,0 +1,54 @@
/*
* 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.ftue.impl.welcome
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.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class WelcomeNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val buildMeta: BuildMeta,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinueClicked()
}
private fun onContinueClicked() {
plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() }
}
@Composable
override fun View(modifier: Modifier) {
WelcomeView(
applicationName = buildMeta.applicationName,
onContinueClicked = ::onContinueClicked,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,130 @@
/*
* 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.ftue.impl.welcome
import androidx.activity.compose.BackHandler
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.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddComment
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.NewReleases
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.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun WelcomeView(
applicationName: String,
modifier: Modifier = Modifier,
onContinueClicked: () -> Unit,
) {
BackHandler(onBack = onContinueClicked)
OnBoardingPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(78.dp))
ElementLogoAtom(size = ElementLogoAtomSize.Medium)
Spacer(modifier = Modifier.height(32.dp))
Text(
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
text = stringResource(R.string.screen_welcome_title, applicationName),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_welcome_subtitle),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
InfoListOrganism(
items = listItems(),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.iconSecondary,
backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(32.dp))
}
},
footer = {
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
Text(text = stringResource(CommonStrings.action_continue))
}
Spacer(modifier = Modifier.height(32.dp))
}
)
}
@Composable
private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_1),
iconVector = Icons.Outlined.NewReleases,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2),
iconVector = Icons.Outlined.Lock,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_3),
iconVector = Icons.Outlined.AddComment,
),
)
@DayNightPreviews
@Composable
internal fun WelcomeViewPreview() {
ElementPreview {
WelcomeView(applicationName = "Element X", onContinueClicked = {})
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.ftue.impl.welcome.state
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidWelcomeScreenState @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
) : WelcomeScreenState {
companion object {
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
}
override fun isWelcomeScreenNeeded(): Boolean {
return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
}
override fun setWelcomeScreenShown() {
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
}
override fun reset() {
sharedPreferences.edit {
remove(IS_WELCOME_SCREEN_SHOWN)
}
}
}

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.ftue.impl.welcome.state
interface WelcomeScreenState {
fun isWelcomeScreenNeeded(): Boolean
fun setWelcomeScreenShown()
fun reset()
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string>
<string name="screen_welcome_subtitle">"Heres what you need to know:"</string>
<string name="screen_welcome_title">"Welcome to %1$s!"</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.
*/
package io.element.android.features.ftue.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFtueStateTests {
@Test
fun `given any check being false, should display flow is true`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope)
assertThat(state.shouldDisplayFlow.value).isTrue()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
val steps = mutableListOf<FtueStep?>()
// First step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Second step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
// Final step (null)
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.WelcomeScreen,
FtueStep.AnalyticsOptIn,
null, // Final state
)
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
state.setWelcomeScreenShown()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
private fun createState(
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService()
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
}

View File

@@ -0,0 +1,34 @@
/*
* 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.ftue.impl.welcome.state
class FakeWelcomeState : WelcomeScreenState {
private var isWelcomeScreenNeeded = true
override fun isWelcomeScreenNeeded(): Boolean {
return isWelcomeScreenNeeded
}
override fun setWelcomeScreenShown() {
isWelcomeScreenNeeded = false
}
override fun reset() {
isWelcomeScreenNeeded = true
}
}

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invites_decline_chat_message">"Voulez-vous vraiment refuser linvitation à rejoindre %1$s ?"</string>
<string name="screen_invites_decline_chat_title">"Refuser l\'invitation"</string>
<string name="screen_invites_decline_direct_chat_message">"Voulez-vous vraiment refuser ce chat privé avec %1$s ?"</string>
<string name="screen_invites_decline_direct_chat_title">"Refuser le chat"</string>
<string name="screen_invites_empty_list">"Aucune invitation"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) vous a invité"</string>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -3,6 +3,8 @@
<string name="screen_account_provider_change">"Changer de fournisseur"</string>
<string name="screen_account_provider_continue">"Continuer"</string>
<string name="screen_account_provider_form_hint">"Adresse du serveur d\'accueil"</string>
<string name="screen_account_provider_form_notice">"Entrez un mot clé de recherche ou un nom de domaine."</string>
<string name="screen_account_provider_form_subtitle">"Rechercher une entreprise, une communauté ou un serveur privé."</string>
<string name="screen_account_provider_form_title">"Trouver un fournisseur de services"</string>
<string name="screen_account_provider_signin_subtitle">"C\'est ici que vos conversations seront stockées - tout comme vous utiliseriez un fournisseur de messagerie pour conserver vos e-mails."</string>
<string name="screen_account_provider_signin_title">"Vous êtes sur le point de vous connecter à %s"</string>
@@ -23,9 +25,23 @@
<string name="screen_login_error_unsupported_authentication">"Le serveur domestique sélectionné ne prend pas en charge le mot de passe ou la connexion OIDC. Contactez votre administrateur ou choisissez un autre serveur domestique."</string>
<string name="screen_login_form_header">"Saisir vos informations personnelles"</string>
<string name="screen_login_title">"Heureux de vous revoir!"</string>
<string name="screen_login_title_with_homeserver">"Se connecter à %1$s"</string>
<string name="screen_server_confirmation_change_server">"Changer de fournisseur de compte"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un serveur privé pour les employés dElement."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string>
<string name="screen_server_confirmation_message_register">"C\'est là que vos conversations seront conservées — de la même manière que votre service de-mail habituel conserverait vos e-mails."</string>
<string name="screen_server_confirmation_title_login">"Vous allez vous connecter à %1$s"</string>
<string name="screen_server_confirmation_title_register">"Vous allez créer un compte sur %1$s"</string>
<string name="screen_waitlist_message">"Il y a une forte demande pour %1$s sur %2$s en ce moment. Rouvrez lapp dans quelques jours et réessayez.
Merci de votre patience !"</string>
<string name="screen_waitlist_message_success">"Bienvenue sur %1$s !"</string>
<string name="screen_waitlist_title">"Vous y êtes presque."</string>
<string name="screen_waitlist_title_success">"Vous y êtes."</string>
<string name="screen_change_server_submit">"Continuer"</string>
<string name="screen_change_server_title">"Sélectionnez votre serveur"</string>
<string name="screen_login_password_hint">"Mot de passe"</string>
<string name="screen_login_submit">"Continuer"</string>
<string name="screen_login_subtitle">"Matrix est un réseau ouvert de communication sécurisée et décentralisée."</string>
<string name="screen_login_username_hint">"Nom d\'utilisateur"</string>
</resources>

View File

@@ -6,9 +6,9 @@
<string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string>
<string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string>
<string name="screen_account_provider_form_title">"Find an account provider"</string>
<string name="screen_account_provider_signin_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signin_subtitle">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signin_title">"Youre about to sign in to %s"</string>
<string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signup_subtitle">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signup_title">"Youre about to create an account on %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is an open network for secure, decentralized communication."</string>
<string name="screen_change_account_provider_other">"Other"</string>

View File

@@ -25,4 +25,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.libraries.textcomposer)
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.api
import io.element.android.libraries.textcomposer.MessageComposerMode
/**
* Hoist-able state of the message composer.
*
* Typical use case is inside other presenters, to know if
* the composer is in a thread, if it's editing a message, etc.
*/
interface MessageComposerContext {
val composerMode: MessageComposerMode
}

View File

@@ -25,7 +25,6 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
@@ -48,7 +47,7 @@ fun aMessagesState() = MessagesState(
roomAvatar = AvatarData("!id:domain", "Room name", size = AvatarSize.TimelineRoom),
userHasPermissionToSendMessage = true,
composerState = aMessageComposerState().copy(
text = StableCharSequence("Hello"),
text = "Hello",
isFullScreen = false,
mode = MessageComposerMode.Normal("Hello"),
),

View File

@@ -0,0 +1,34 @@
/*
* 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.messages.impl.messagecomposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.textcomposer.MessageComposerMode
import javax.inject.Inject
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class MessageComposerContextImpl @Inject constructor() : MessageComposerContext {
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal(""))
internal set
}

View File

@@ -26,7 +26,7 @@ sealed interface MessageComposerEvents {
data class SendMessage(val message: String) : MessageComposerEvents
object CloseSpecialMode : MessageComposerEvents
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
data class UpdateText(val text: CharSequence) : MessageComposerEvents
data class UpdateText(val text: String) : MessageComposerEvents
object AddAttachment : MessageComposerEvents
object DismissAttachmentMenu : MessageComposerEvents
sealed interface PickAttachmentSource : MessageComposerEvents {

View File

@@ -34,8 +34,6 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.di.RoomScope
@@ -64,6 +62,7 @@ class MessageComposerPresenter @Inject constructor(
private val mediaSender: MediaSender,
private val snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl,
) : Presenter<MessageComposerState> {
@SuppressLint("UnsafeOptInUsageError")
@@ -93,18 +92,15 @@ class MessageComposerPresenter @Inject constructor(
val hasFocus = remember {
mutableStateOf(false)
}
val text: MutableState<StableCharSequence> = remember {
mutableStateOf(StableCharSequence(""))
}
val composerMode: MutableState<MessageComposerMode> = rememberSaveable {
mutableStateOf(MessageComposerMode.Normal(""))
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(composerMode.value) {
when (val modeValue = composerMode.value) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
else -> Unit
}
}
@@ -122,20 +118,24 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
is MessageComposerEvents.UpdateText -> text.value = event.text
MessageComposerEvents.CloseSpecialMode -> {
text.value = "".toStableCharSequence()
composerMode.setToNormal()
text.value = ""
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(event.message, composerMode, text)
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
text = event.message,
updateComposerMode = { messageComposerContext.composerMode = it },
textState = text
)
is MessageComposerEvents.SetMode -> {
composerMode.value = event.composerMode
messageComposerContext.composerMode = event.composerMode
analyticsService.capture(
Composer(
inThread = false,
isEditing = composerMode.value is MessageComposerMode.Edit,
isReply = composerMode.value is MessageComposerMode.Reply,
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
isLocation = false,
)
)
@@ -171,7 +171,7 @@ class MessageComposerPresenter @Inject constructor(
text = text.value,
isFullScreen = isFullScreen.value,
hasFocus = hasFocus.value,
mode = composerMode.value,
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents
@@ -184,31 +184,30 @@ class MessageComposerPresenter @Inject constructor(
}
}
private fun MutableState<MessageComposerMode>.setToNormal() {
value = MessageComposerMode.Normal("")
}
private fun CoroutineScope.sendMessage(text: String, composerMode: MutableState<MessageComposerMode>, textState: MutableState<StableCharSequence>) =
launch {
val capturedMode = composerMode.value
// Reset composer right away
textState.value = "".toStableCharSequence()
composerMode.setToNormal()
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, text)
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
text
)
private fun CoroutineScope.sendMessage(
text: String,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
textState: MutableState<String>
) = launch {
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
textState.value = ""
updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)
is MessageComposerMode.Edit -> {
val eventId = capturedMode.eventId
val transactionId = capturedMode.transactionId
room.editMessage(eventId, transactionId, text)
}
is MessageComposerMode.Quote -> TODO()
is MessageComposerMode.Reply -> room.replyMessage(
capturedMode.eventId,
text
)
}
}
private fun CoroutineScope.sendAttachment(
attachment: Attachment,

View File

@@ -18,13 +18,12 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.collections.immutable.ImmutableList
@Immutable
data class MessageComposerState(
val text: StableCharSequence?,
val text: String?,
val isFullScreen: Boolean,
val hasFocus: Boolean,
val mode: MessageComposerMode,
@@ -32,7 +31,7 @@ data class MessageComposerState(
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
) {
val isSendButtonVisible: Boolean = text?.charSequence.isNullOrEmpty().not()
val isSendButtonVisible: Boolean = text.isNullOrEmpty().not()
}
@Immutable

View File

@@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.textcomposer.MessageComposerMode
open class MessageComposerStateProvider : PreviewParameterProvider<MessageComposerState> {
@@ -28,7 +27,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
}
fun aMessageComposerState() = MessageComposerState(
text = StableCharSequence(""),
text = "",
isFullScreen = false,
hasFocus = false,
mode = MessageComposerMode.Normal(content = ""),

View File

@@ -47,7 +47,7 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.CloseSpecialMode)
}
fun onComposerTextChange(text: CharSequence) {
fun onComposerTextChange(text: String) {
state.eventSink(MessageComposerEvents.UpdateText(text))
}
@@ -69,7 +69,7 @@ fun MessageComposerView(
onAddAttachment = ::onAddAttachment,
onFocusChanged = ::onFocusChanged,
composerCanSendMessage = state.isSendButtonVisible,
composerText = state.text?.charSequence?.toString(),
composerText = state.text,
modifier = modifier
)
}

View File

@@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val backPaginationEventLimit = 20
@@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
) : Presenter<TimelineState> {
private val timeline = room.timeline
@Composable
override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope()
val localScope = rememberCoroutineScope()
val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null)
}
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) }
val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val prevMostRecentItemId = rememberSaveable { mutableStateOf<String?>(null) }
val hasNewItems = remember { mutableStateOf(false) }
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards()
TimelineEvents.LoadMore -> localScope.paginateBackwards()
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
lastReadMarkerIndex = event.firstIndex
lastReadMarkerId = eventId
localCoroutineScope.sendReadReceipt(eventId)
if (event.firstIndex == 0) {
hasNewItems.value = false
}
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId
)
}
}
}
LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
}
LaunchedEffect(Unit) {
timeline
.timelineItems
@@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
canReply = userHasPermissionToSendMessage,
paginationState = paginationState,
timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
eventSink = ::handleEvents
)
}
/**
* This method compute the hasNewItem state passed as a [MutableState] each time the timeline items size changes.
* Basically, if we got new timeline event from sync or local, either from us or another user, we update the state so we tell we have new items.
* The state never goes back to false from this method, but need to be reset from somewhere else.
*/
private suspend fun computeHasNewItems(
timelineItems: ImmutableList<TimelineItem>,
prevMostRecentItemId: MutableState<String?>,
hasNewItemsState: MutableState<Boolean>
) = withContext(dispatchers.computation) {
val newMostRecentItem = timelineItems.firstOrNull()
val prevMostRecentItemIdValue = prevMostRecentItemId.value
val newMostRecentItemId = newMostRecentItem?.identifier()
val hasNewItems = prevMostRecentItemIdValue != null &&
newMostRecentItem is TimelineItem.Event &&
newMostRecentItem.origin != TimelineItemEventOrigin.PAGINATION &&
newMostRecentItemId != prevMostRecentItemIdValue
if (hasNewItems) {
hasNewItemsState.value = true
}
prevMostRecentItemId.value = newMostRecentItemId
}
private fun CoroutineScope.sendReadReceiptIfNeeded(
firstVisibleIndex: Int,
timelineItems: ImmutableList<TimelineItem>,
lastReadReceiptIndex: MutableState<Int>,
lastReadReceiptId: MutableState<EventId?>,
) = launch(dispatchers.computation) {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item
val eventId = getLastEventIdBeforeOrAt(firstVisibleIndex, timelineItems)
if (eventId != null && firstVisibleIndex <= lastReadReceiptIndex.value && eventId != lastReadReceiptId.value) {
lastReadReceiptIndex.value = firstVisibleIndex
lastReadReceiptId.value = eventId
timeline.sendReadReceipt(eventId)
}
}
private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
for (item in items.subList(index, items.count())) {
if (item is TimelineItem.Event) {
@@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize)
}
private fun CoroutineScope.sendReadReceipt(eventId: EventId) = launch {
timeline.sendReadReceipt(eventId)
}
}

View File

@@ -28,5 +28,6 @@ data class TimelineState(
val highlightedEventId: EventId?,
val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit
)

View File

@@ -27,11 +27,12 @@ import io.element.android.features.messages.impl.timeline.model.virtual.aTimelin
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -44,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null,
canReply = true,
eventSink = {}
hasNewItems = false,
eventSink = {},
)
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
@@ -102,7 +104,7 @@ fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null,
transactionId: TransactionId? = null,
isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@@ -126,6 +128,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
origin = null
)
}
@@ -152,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson
)
fun aGroupedEvents(): TimelineItem.GroupedEvents {
fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent(
isMine = true,
content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None
)
return TimelineItem.GroupedEvents(
id = id.toString(),
events = listOf(
event,
event,

View File

@@ -21,6 +21,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
@@ -48,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
@@ -71,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
@Composable
@@ -100,13 +99,6 @@ fun TimelineView(
// TODO implement this logic once we have support to 'jump to event X' in sliding sync
}
// Send an event to the presenter when the scrolling is finished, with the first visible index at the bottom.
val firstVisibleIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
LaunchedEffect(firstVisibleIndex, isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex))
}
Box(modifier = modifier) {
LazyColumn(
@@ -147,8 +139,8 @@ fun TimelineView(
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
onScrollFinishedAt = ::onScrollFinishedAt,
hasNewItems = state.hasNewItems,
onScrollFinishedAt = ::onScrollFinishedAt
)
}
}
@@ -244,63 +236,66 @@ fun TimelineItemRow(
}
@Composable
internal fun BoxScope.TimelineScrollHelper(
private fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>,
onScrollFinishedAt: (Int) -> Unit = {},
hasNewItems: Boolean,
onScrollFinishedAt: (Int) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } }
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
LaunchedEffect(timelineItems, firstVisibleItemIndex) {
if (!isScrollFinished) return@LaunchedEffect
// Auto-scroll when new timeline items appear
if (shouldAutoScrollToBottom) {
LaunchedEffect(canAutoScroll, hasNewItems) {
val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
if (shouldAutoScroll) {
coroutineScope.launch {
lazyListState.animateScrollToItem(0)
}
}
}
LaunchedEffect(isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
// Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(firstVisibleItemIndex)
LaunchedEffect(isScrollFinished) {
if (isScrollFinished) {
// Notify the parent composable about the first visible item index when scrolling finishes
onScrollFinishedAt(lazyListState.firstVisibleItemIndex)
}
}
// Jump to bottom button (display also in previews)
AnimatedVisibility(
JumpToBottomButton(
// Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp),
visible = showScrollToBottomButton || LocalInspectionMode.current,
enter = scaleIn(),
exit = scaleOut(),
onClick = {
coroutineScope.launch {
if (lazyListState.firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
}
)
}
@Composable
private fun JumpToBottomButton(
isVisible: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
modifier = modifier,
visible = isVisible || LocalInspectionMode.current,
enter = scaleIn(animationSpec = tween(100)),
exit = scaleOut(animationSpec = tween(100)),
) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
onClick = onClick,
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
shape = CircleShape,
modifier = Modifier
.shadow(
elevation = 4.dp,
shape = CircleShape,
ambientColor = ElementTheme.materialColors.primary,
spotColor = ElementTheme.materialColors.primary,
)
.size(36.dp),
modifier = Modifier.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary
) {

View File

@@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
@Composable
@@ -32,5 +33,7 @@ fun TimelineItemVirtualRow(
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.messages.impl.timeline.components.virtual
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
.clip(MaterialTheme.shapes.small)
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
.background(ElementTheme.colors.bgInfoSubtle)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
@DayNightPreviews
@Composable
internal fun TimelineEncryptedHistoryBannerViewPreview() {
ElementTheme {
TimelineEncryptedHistoryBannerView()
}
}

View File

@@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor(
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val timelineItemsCache = arrayListOf<TimelineItem?>()

View File

@@ -85,6 +85,7 @@ class TimelineItemEventFactory @Inject constructor(
localSendState = currentTimelineItem.event.localSendState,
inReplyTo = currentTimelineItem.event.inReplyTo(),
debugInfo = currentTimelineItem.event.debugInfo,
origin = currentTimelineItem.event.origin,
)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor(
fun create(
virtualTimelineItem: MatrixTimelineItem.Virtual,
): TimelineItem.Virtual {
val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
"encrypted_history_banner"
} else {
virtualTimelineItem.uniqueId.toString()
}
return TimelineItem.Virtual(
id = virtualTimelineItem.uniqueId.toString(),
id = id,
model = virtualTimelineItem.computeModel()
)
}
@@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor(
return when (val inner = virtual) {
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
}
}
}

View File

@@ -16,11 +16,22 @@
package io.element.android.features.messages.impl.timeline.groups
import androidx.annotation.VisibleForTesting
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemGrouper @Inject constructor() {
/**
* Keys are identifier of items in a group, only one by group will be kept.
* Values are the actual groupIds.
*/
private val groupIds = HashMap<String, String>()
/**
* Create a new list of [TimelineItem] by grouping some of them into [TimelineItem.GroupedEvents].
*/
@@ -34,14 +45,14 @@ class TimelineItemGrouper @Inject constructor() {
// timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group.
result.addGroup(currentGroup)
result.addGroup(groupIds, currentGroup)
currentGroup.clear()
}
result.add(timelineItem)
}
}
if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup)
result.addGroup(groupIds, currentGroup)
}
return result
}
@@ -51,16 +62,36 @@ class TimelineItemGrouper @Inject constructor() {
* Will add a group if there is more than 1 item, else add the item to the list.
*/
private fun MutableList<TimelineItem>.addGroup(
group: MutableList<TimelineItem.Event>
groupIds: MutableMap<String, String>,
groupOfItems: MutableList<TimelineItem.Event>
) {
if (group.size == 1) {
if (groupOfItems.size == 1) {
// Do not create a group with just 1 item, just add the item to the result
add(group.first())
add(groupOfItems.first())
} else {
val groupId = groupIds.getOrPutGroupId(groupOfItems)
add(
TimelineItem.GroupedEvents(
events = group.toImmutableList()
id = groupId,
events = groupOfItems.toImmutableList()
)
)
}
}
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
assert(timelineItems.isNotEmpty())
for (item in timelineItems) {
val itemIdentifier = item.identifier()
if (this.contains(itemIdentifier)) {
return this[itemIdentifier]!!
}
}
val timelineItem = timelineItems.first()
return computeGroupIdWith(timelineItem).also { groupId ->
this[timelineItem.identifier()] = groupId
}
}
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"

View File

@@ -22,10 +22,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import kotlinx.collections.immutable.ImmutableList
@Immutable
@@ -53,7 +55,7 @@ sealed interface TimelineItem {
data class Event(
val id: String,
val eventId: EventId? = null,
val transactionId: String? = null,
val transactionId: TransactionId? = null,
val senderId: UserId,
val senderDisplayName: String?,
val senderAvatar: AvatarData,
@@ -65,6 +67,7 @@ sealed interface TimelineItem {
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
@@ -80,9 +83,8 @@ sealed interface TimelineItem {
@Immutable
data class GroupedEvents(
val id: String,
val events: ImmutableList<Event>,
) : TimelineItem {
// use last id with a suffix. Last will not change in cas of new event from backpagination.
val id = "${events.last().id}_group"
}
) : TimelineItem
}

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.features.messages.impl.timeline.model.virtual
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
}

View File

@@ -4,10 +4,27 @@
<item quantity="one">"%1$d changement dans la conversation"</item>
<item quantity="other">"%1$d changements dans la conversation"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one"></item>
<item quantity="other">"%1$d de plus"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Appareil photo"</string>
<string name="screen_room_attachment_source_camera_photo">"Prendre une photo"</string>
<string name="screen_room_attachment_source_camera_video">"Enregistrer une vidéo"</string>
<string name="screen_room_attachment_source_files">"Pièce-jointe"</string>
<string name="screen_room_attachment_source_gallery">"Gallerie photo et vidéo"</string>
<string name="screen_room_encrypted_history_banner">"Lhistorique des messages nest pas disponible actuellement dans ce salon"</string>
<string name="screen_room_error_failed_retrieving_user_details">"Impossible de récupérer les détails de lutilisateur"</string>
<string name="screen_room_invite_again_alert_message">"Souhaitez-vous les inviter à revenir ?"</string>
<string name="screen_room_invite_again_alert_title">"Vous êtes seul dans ce chat"</string>
<string name="screen_room_message_copied">"Message copié"</string>
<string name="screen_room_no_permission_to_post">"Vous navez pas le droit de poster dans ce salon"</string>
<string name="screen_room_reactions_show_less">"Afficher moins"</string>
<string name="screen_room_reactions_show_more">"Afficher plus"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Renvoyer"</string>
<string name="screen_room_retry_send_menu_title">"Votre message n\'a pas pu être envoyé"</string>
<string name="screen_room_timeline_add_reaction">"Ajouter un emoji"</string>
<string name="screen_room_timeline_less_reactions">"Montrer moins"</string>
<string name="screen_room_error_failed_processing_media">"Échec du traitement du média avant son envoi, veuillez réessayer."</string>
<string name="screen_room_retry_send_menu_remove_action">"Supprimer"</string>
</resources>

View File

@@ -5,6 +5,11 @@
<item quantity="few">"%1$d zmeny miestnosti"</item>
<item quantity="other">"%1$d zmien miestnosti"</item>
</plurals>
<plurals name="screen_room_timeline_more_reactions">
<item quantity="one"></item>
<item quantity="few">"%1$d ďalšie"</item>
<item quantity="other">"%1$d ďalších"</item>
</plurals>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Odfotiť"</string>
<string name="screen_room_attachment_source_camera_video">"Nahrať video"</string>
@@ -21,6 +26,8 @@
<string name="screen_room_reactions_show_more">"Zobraziť viac"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>
<string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string>
<string name="screen_room_timeline_add_reaction">"Pridať emoji"</string>
<string name="screen_room_timeline_less_reactions">"Zobraziť menej"</string>
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_room_retry_send_menu_remove_action">"Odstrániť"</string>
</resources>

View File

@@ -19,6 +19,17 @@
<string name="screen_room_invite_again_alert_title">"You are alone in this chat"</string>
<string name="screen_room_message_copied">"Message copied"</string>
<string name="screen_room_no_permission_to_post">"You do not have permission to post to this room"</string>
<string name="screen_room_notification_settings_allow_custom">"Allow custom setting"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Turning this on will override your default setting"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Notify me in this chat for"</string>
<string name="screen_room_notification_settings_default_setting_footnote">"You can change it in your %1$s."</string>
<string name="screen_room_notification_settings_default_setting_footnote_content_link">"global settings"</string>
<string name="screen_room_notification_settings_default_setting_title">"Default setting"</string>
<string name="screen_room_notification_settings_error_loading_settings">"An error occurred while loading notification settings."</string>
<string name="screen_room_notification_settings_error_restoring_default">"Failed restoring the default mode, please try again."</string>
<string name="screen_room_notification_settings_error_setting_mode">"Failed setting the mode, please try again."</string>
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
<string name="screen_room_reactions_show_less">"Show less"</string>
<string name="screen_room_reactions_show_more">"Show more"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>

View File

@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.MessagesPresenter
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter
@@ -568,10 +569,13 @@ class MessagesPresenterTest {
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = FakeAnalyticsService(),
messageComposerContext = MessageComposerContextImpl(),
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
dispatchers = coroutineDispatchers,
appScope = this
)
val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)

View File

@@ -52,4 +52,5 @@ internal fun aMessageEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
origin = null
)

View File

@@ -26,17 +26,18 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
@@ -82,7 +83,7 @@ class MessageComposerPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
@@ -115,11 +116,11 @@ class MessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(""))
val withEmptyMessageState = awaitItem()
assertThat(withEmptyMessageState.text).isEqualTo(StableCharSequence(""))
assertThat(withEmptyMessageState.text).isEqualTo("")
assertThat(withEmptyMessageState.isSendButtonVisible).isFalse()
}
}
@@ -136,7 +137,7 @@ class MessageComposerPresenterTest {
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(state.text).isEqualTo(A_MESSAGE)
assertThat(state.isSendButtonVisible).isTrue()
backToNormalMode(state, skipCount = 1)
}
@@ -153,7 +154,7 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
@@ -170,7 +171,7 @@ class MessageComposerPresenterTest {
state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
backToNormalMode(state)
}
@@ -185,11 +186,11 @@ class MessageComposerPresenterTest {
val initialState = awaitItem()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_MESSAGE))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE))
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
}
}
@@ -205,21 +206,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = anEditMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
@@ -236,21 +237,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID)
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
skipItems(1)
val withMessageState = awaitItem()
assertThat(withMessageState.mode).isEqualTo(mode)
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_MESSAGE))
assertThat(withMessageState.text).isEqualTo(A_MESSAGE)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.UpdateText(ANOTHER_MESSAGE))
val withEditedMessageState = awaitItem()
assertThat(withEditedMessageState.text).isEqualTo(StableCharSequence(ANOTHER_MESSAGE))
assertThat(withEditedMessageState.text).isEqualTo(ANOTHER_MESSAGE)
withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.editMessageCalls.first()).isEqualTo(ANOTHER_MESSAGE)
}
@@ -267,21 +268,21 @@ class MessageComposerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.text).isEqualTo(StableCharSequence(""))
assertThat(initialState.text).isEqualTo("")
val mode = aReplyMode()
initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode))
val state = awaitItem()
assertThat(state.mode).isEqualTo(mode)
assertThat(state.text).isEqualTo(StableCharSequence(""))
assertThat(state.text).isEqualTo("")
assertThat(state.isSendButtonVisible).isFalse()
initialState.eventSink.invoke(MessageComposerEvents.UpdateText(A_REPLY))
val withMessageState = awaitItem()
assertThat(withMessageState.text).isEqualTo(StableCharSequence(A_REPLY))
assertThat(withMessageState.text).isEqualTo(A_REPLY)
assertThat(withMessageState.isSendButtonVisible).isTrue()
withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY))
skipItems(1)
val messageSentState = awaitItem()
assertThat(messageSentState.text).isEqualTo(StableCharSequence(""))
assertThat(messageSentState.text).isEqualTo("")
assertThat(messageSentState.isSendButtonVisible).isFalse()
assertThat(fakeMatrixRoom.replyMessageParameter).isEqualTo(A_REPLY)
}
@@ -484,7 +485,7 @@ class MessageComposerPresenterTest {
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.text).isEqualTo("")
assertThat(normalState.isSendButtonVisible).isFalse()
}
@@ -503,14 +504,15 @@ class MessageComposerPresenterTest {
localMediaFactory,
MediaSender(mediaPreProcessor, room),
snackbarDispatcher,
FakeAnalyticsService()
FakeAnalyticsService(),
MessageComposerContextImpl(),
)
}
fun anEditMode(
eventId: EventId? = AN_EVENT_ID,
message: String = A_MESSAGE,
transactionId: String? = null,
transactionId: TransactionId? = null,
) = MessageComposerMode.Edit(eventId, message, transactionId)
fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, AN_EVENT_ID, A_MESSAGE)
fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE)

View File

@@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.tests.testutils.awaitWithLatch
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class TimelinePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -51,10 +54,7 @@ class TimelinePresenterTest {
@Test
fun `present - load more`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -73,10 +73,7 @@ class TimelinePresenterTest {
@Test
fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
val presenter = createTimelinePresenter()
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@@ -94,70 +91,112 @@ class TimelinePresenterTest {
@Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(0, anEventTimelineItem())
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem())))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Event(0, anEventTimelineItem())
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline()
val timelineItemsFactory = aTimelineItemsFactory().apply {
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)))
}
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
val timeline = FakeMatrixTimeline(
initialTimelineItems = listOf(
MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
)
)
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
// Wait for timeline items to be populated
skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - covers hasNewItems scenarios`() = runTest {
val timeline = FakeMatrixTimeline()
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.hasNewItems).isFalse()
assertThat(initialState.timelineItems.size).isEqualTo(0)
timeline.updateTimelineItems {
listOf(MatrixTimelineItem.Event(0, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(1)
timeline.updateTimelineItems { items ->
items + listOf(MatrixTimelineItem.Event(1, anEventTimelineItem(content = aMessageContent())))
}
skipItems(1)
assertThat(awaitItem().timelineItems.size).isEqualTo(2)
assertThat(awaitItem().hasNewItems).isTrue()
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
assertThat(awaitItem().hasNewItems).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun TestScope.createTimelinePresenter(
timeline: MatrixTimeline = FakeMatrixTimeline(),
timelineItemsFactory: TimelineItemsFactory = aTimelineItemsFactory()
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = FakeMatrixRoom(matrixTimeline = timeline),
dispatchers = testCoroutineDispatchers(),
appScope = this
)
}
}

View File

@@ -20,13 +20,13 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.impl.timeline.groups.computeGroupIdWith
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList
@@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value,
id = "0",
senderId = A_USER_ID,
senderAvatar = anAvatarData(),
senderDisplayName = "",
@@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(),
origin = null
)
private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() {
val result = sut.group(
listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem,
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "0"),
),
)
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem.copy(id = AN_EVENT_ID_2.value),
aGroupableItem.copy("0"),
aGroupableItem.copy(id = "1"),
).toImmutableList()
),
)
@@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo(
listOf(
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
@@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
),
aNonGroupableItem,
TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf(
aGroupableItem,
aGroupableItem,
@@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
)
)
}
@Test
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When
val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "2")
)
val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3"))
groupableItems.add(2, aGroupableItem.copy("4"))
groupableItems.add(aGroupableItem.copy("5"))
val actualGroupId = sut.group(groupableItems).first().identifier()
// Then
assertThat(actualGroupId).isEqualTo(expectedGroupId)
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.messages.test"
}
dependencies {
api(projects.features.messages.api)
}

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.features.messages.test
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.textcomposer.MessageComposerMode
class MessageComposerContextFake(
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
) : MessageComposerContext

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_onboarding_sign_in_manually">"Se connecter manuellement"</string>
<string name="screen_onboarding_sign_in_with_qr_code">"Se connecter avec un code QR"</string>
<string name="screen_onboarding_sign_up">"Créer un compte"</string>
<string name="screen_onboarding_subtitle">"Communiquer et collaborer en toute sécurité"</string>
<string name="screen_onboarding_welcome_message">"Bienvenue dans lElement le plus rapide de tous les temps. Surpuissant pour plus de vitesse et de simplicité."</string>
<string name="screen_onboarding_welcome_subtitle">"Bienvenue dans %1$s. Affiné pour plus de rapidité et de simplicité."</string>
<string name="screen_onboarding_welcome_title">"Soyez dans votre Element"</string>
</resources>

View File

@@ -44,6 +44,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.libraries.matrixui)
implementation(projects.features.logout.api)
implementation(projects.services.toolbox.api)

View File

@@ -22,12 +22,12 @@ import android.content.Context
import coil.Coil
import coil.annotation.ExperimentalCoilApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import javax.inject.Inject
@@ -44,6 +44,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val coroutineDispatchers: CoroutineDispatchers,
private val defaultCacheIndexProvider: DefaultCacheService,
private val okHttpClient: Provider<OkHttpClient>,
private val ftueState: FtueState,
) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache
@@ -57,6 +58,8 @@ class DefaultClearCacheUseCase @Inject constructor(
okHttpClient.get().cache?.delete()
// Clear app cache
context.cacheDir.deleteRecursively()
// Clear some settings
ftueState.reset()
// Ensure the app is restarted
defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
}

View File

@@ -2,13 +2,14 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Joindre une capture d\'écran"</string>
<string name="screen_bug_report_contact_me">"Vous pouvez me contacter si vous avez des questions complémentaires"</string>
<string name="screen_bug_report_contact_me_title">"Me contacter"</string>
<string name="screen_bug_report_edit_screenshot">"Modifier la capture d\'écran"</string>
<string name="screen_bug_report_editor_description">"S\'il vous plait, veuillez décrire le bogue. Qu\'avez-vous fait ? À quoi vous attendiez-vous ? Que s\'est-il réellement passé. Veuillez ajouter le plus de détails possible."</string>
<string name="screen_bug_report_editor_placeholder">"Décrire le bogue"</string>
<string name="screen_bug_report_editor_supporting">"Si possible, veuillez rédiger la description en anglais."</string>
<string name="screen_bug_report_include_crash_logs">"Envoyer des journaux dincident"</string>
<string name="screen_bug_report_include_logs">"Envoyer le journal pour nous aider"</string>
<string name="screen_bug_report_include_logs">"Autoriser à inclure les journaux techniques"</string>
<string name="screen_bug_report_include_screenshot">"Envoyer une capture décran"</string>
<string name="screen_bug_report_logs_description">"Pour vérifier que les choses fonctionnent comme prévu, les journaux seront envoyés avec votre message. Ceux-ci seront privées. Pour simplement envoyer votre message, désactivez ce paramètre."</string>
<string name="screen_bug_report_logs_description">"Pour vérifier que les choses fonctionnent comme prévu, des journaux techniques seront envoyés avec votre message. Pour lenvoyer sans ces journaux, désactivez ce paramètre."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s a planté la dernière fois qu\'il a été utilisé. Souhaitez-vous partager un rapport de crash avec nous ?"</string>
</resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_bug_report_attach_screenshot">"Attach screenshot"</string>
<string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions"</string>
<string name="screen_bug_report_contact_me">"You may contact me if you have any follow up questions."</string>
<string name="screen_bug_report_contact_me_title">"Contact me"</string>
<string name="screen_bug_report_edit_screenshot">"Edit screenshot"</string>
<string name="screen_bug_report_editor_description">"Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can."</string>

View File

@@ -5,10 +5,19 @@
<item quantity="other">"%1$d membres"</item>
</plurals>
<string name="screen_room_details_add_topic_title">"Définir un sujet"</string>
<string name="screen_room_details_already_a_member">"Déjà membre"</string>
<string name="screen_room_details_already_invited">"Déjà invité(e)"</string>
<string name="screen_room_details_edit_room_title">"Modifier le salon"</string>
<string name="screen_room_details_edition_error">"Une erreur inconnue sest produite et les informations nont pas pu être modifiées."</string>
<string name="screen_room_details_edition_error_title">"Impossible de mettre à jour le salon"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Les messages sont sécurisés par des cadenas numériques. Seuls vous et les destinataires possédez les clés uniques pour les déverrouiller."</string>
<string name="screen_room_details_encryption_enabled_title">"Chiffrement des messages activé"</string>
<string name="screen_room_details_invite_people_title">"Inviter des personnes"</string>
<string name="screen_room_details_notification_title">"Notifications"</string>
<string name="screen_room_details_room_name_label">"Nom du salon"</string>
<string name="screen_room_details_share_room_title">"Partager le salon"</string>
<string name="screen_room_details_updating_room">"Mise à jour du salon…"</string>
<string name="screen_room_member_list_pending_header_title">"En attente"</string>
<string name="screen_dm_details_block_alert_action">"Bloquer"</string>
<string name="screen_dm_details_block_alert_description">"Les utilisateurs bloqués ne pourront pas vous envoyer de messages et tous leurs messages seront masqués. Vous pouvez annuler cette action à tout moment."</string>
<string name="screen_dm_details_block_user">"Bloquer l\'utilisateur"</string>

View File

@@ -13,7 +13,12 @@
<string name="screen_room_details_edition_error_title">"Nepodarilo sa aktualizovať miestnosť"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie."</string>
<string name="screen_room_details_encryption_enabled_title">"Šifrovanie správ je zapnuté"</string>
<string name="screen_room_details_error_loading_notification_settings">"Pri načítaní nastavení oznámení došlo k chybe."</string>
<string name="screen_room_details_error_muting">"Nepodarilo sa stlmiť túto miestnosť, skúste to prosím znova."</string>
<string name="screen_room_details_error_unmuting">"Nepodarilo sa zrušiť stlmenie tejto miestnosti, skúste to prosím znova."</string>
<string name="screen_room_details_invite_people_title">"Pozvať ľudí"</string>
<string name="screen_room_details_notification_mode_custom">"Vlastné"</string>
<string name="screen_room_details_notification_mode_default">"Predvolené"</string>
<string name="screen_room_details_notification_title">"Oznámenia"</string>
<string name="screen_room_details_room_name_label">"Názov miestnosti"</string>
<string name="screen_room_details_share_room_title">"Zdieľať miestnosť"</string>

View File

@@ -12,7 +12,12 @@
<string name="screen_room_details_edition_error_title">"Unable to update room"</string>
<string name="screen_room_details_encryption_enabled_subtitle">"Messages are secured with locks. Only you and the recipients have the unique keys to unlock them."</string>
<string name="screen_room_details_encryption_enabled_title">"Message encryption enabled"</string>
<string name="screen_room_details_error_loading_notification_settings">"An error occurred when loading notification settings."</string>
<string name="screen_room_details_error_muting">"Failed muting this room, please try again."</string>
<string name="screen_room_details_error_unmuting">"Failed unmuting this room, please try again."</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_notification_mode_custom">"Custom"</string>
<string name="screen_room_details_notification_mode_default">"Default"</string>
<string name="screen_room_details_notification_title">"Notifications"</string>
<string name="screen_room_details_room_name_label">"Room name"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>

View File

@@ -2,6 +2,6 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_roomlist_a11y_create_message">"Créer une nouvelle conversation ou un nouveau salon"</string>
<string name="screen_roomlist_main_space_title">"Tous les chats"</string>
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Vérifiez que vous êtes bien autorisé à accéder à vos messages chiffrés."</string>
<string name="session_verification_banner_title">"Accédez à l\'historique de vos messages"</string>
<string name="session_verification_banner_message">"Il semblerait que vous utilisiez un nouvel appareil. Lancez la vérification avec un autre appareil pour accéder à vos messages chiffrés à lavenir."</string>
<string name="session_verification_banner_title">"Vérifier que cest bien vous"</string>
</resources>

View File

@@ -48,7 +48,7 @@ sqldelight = "1.5.5"
telephoto = "0.4.0"
# DI
dagger = "2.46.1"
dagger = "2.47"
anvil = "2.4.6"
# Auto service
@@ -65,7 +65,7 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
android_desugar = "com.android.tools:desugar_jdk_libs:2.0.3"
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:32.1.1"
google_firebase_bom = "com.google.firebase:firebase-bom:32.2.0"
# AndroidX
androidx_material = { module = "com.google.android.material:material", version.ref = "material" }
@@ -158,11 +158,12 @@ vanniktech_emoji = "com.vanniktech:emoji-google:0.16.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.1.0"
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.0"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.0"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"
sentry_android = "io.sentry:sentry-android:6.25.1"
sentry_android = "io.sentry:sentry-android:6.25.2"
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:42b2faa417c1e95f430bf8f6e379adba25ad5ef8"
# Di

View File

@@ -0,0 +1,173 @@
/*
* 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.atomic.atoms
import android.graphics.BlurMaskFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.theme.ElementTheme
@Composable
fun ElementLogoAtom(
size: ElementLogoAtomSize,
modifier: Modifier = Modifier,
) {
val outerSize = when (size) {
ElementLogoAtomSize.Large -> 158.dp
ElementLogoAtomSize.Medium -> 120.dp
}
val logoSize = when (size) {
ElementLogoAtomSize.Large -> 110.dp
ElementLogoAtomSize.Medium -> 83.5.dp
}
val cornerRadius = when (size) {
ElementLogoAtomSize.Large -> 44.dp
ElementLogoAtomSize.Medium -> 33.dp
}
val borderWidth = when (size) {
ElementLogoAtomSize.Large -> 1.dp
ElementLogoAtomSize.Medium -> 0.38.dp
}
val blur = if (isSystemInDarkTheme()) {
160.dp
} else {
24.dp
}
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (isSystemInDarkTheme()) {
Color.Black.copy(alpha = 0.4f)
} else {
Color(0x401B1D22)
}
val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
Box(
modifier = modifier
.size(outerSize)
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(outerSize)
.shapeShadow(
color = shadowColor,
cornerRadius = cornerRadius,
blurRadius = 32.dp,
offsetY = 8.dp,
)
)
Box(
Modifier
.clip(RoundedCornerShape(cornerRadius))
.size(outerSize)
.background(backgroundColor)
.blur(blur)
)
Image(
modifier = Modifier.size(logoSize),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
}
}
enum class ElementLogoAtomSize {
Medium,
Large
}
@Composable
@DayNightPreviews
internal fun ElementLogoAtomPreview() {
ElementPreview {
Box(
Modifier
.size(180.dp)
.background(ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center
) {
ElementLogoAtom(ElementLogoAtomSize.Large)
}
}
}
fun Modifier.shapeShadow(
color: Color = Color.Black,
cornerRadius: Dp = 0.dp,
offsetX: Dp = 0.dp,
offsetY: Dp = 0.dp,
blurRadius: Dp = 0.dp,
) = then(
drawBehind {
drawIntoCanvas { canvas ->
val path = Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
}
clipPath(path, ClipOp.Difference) {
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
if (blurRadius != 0.dp) {
frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL))
}
frameworkPaint.color = color.toArgb()
val leftPixel = offsetX.toPx()
val topPixel = offsetY.toPx()
val rightPixel = size.width + topPixel
val bottomPixel = size.height + leftPixel
canvas.drawRect(
left = leftPixel,
top = topPixel,
right = rightPixel,
bottom = bottomPixel,
paint = paint,
)
}
}
}
)

View File

@@ -0,0 +1,113 @@
/*
* 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.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun InfoListItemMolecule(
message: @Composable () -> Unit,
position: InfoListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
) {
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
InfoListItemPosition.Single -> RoundedCornerShape(radius)
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = backgroundColor,
shape = backgroundShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
icon()
message()
}
}
@DayNightPreviews
@Composable
fun InfoListItemMoleculePreview() {
ElementPreview {
val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoListItemMolecule(
message = { Text("A single item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Single,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A top item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Top,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A middle item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Middle,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A bottom item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
enum class InfoListItemPosition {
Top,
Middle,
Bottom,
Single,
}

View File

@@ -0,0 +1,86 @@
/*
* 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.atomic.molecules
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
@Composable
fun InfoListOrganism(
items: ImmutableList<InfoListItem>,
backgroundColor: Color,
modifier: Modifier = Modifier,
iconTint: Color = LocalContentColor.current,
textStyle: TextStyle = LocalTextStyle.current,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
for ((index, item) in items.withIndex()) {
val position = when {
items.size == 1 -> InfoListItemPosition.Single
index == 0 -> InfoListItemPosition.Top
index == items.size - 1 -> InfoListItemPosition.Bottom
else -> InfoListItemPosition.Middle
}
InfoListItemMolecule(
message = {
Text(
text = item.message,
style = textStyle,
color = ElementTheme.colors.textPrimary,
)
},
icon = {
if (item.iconId != null) {
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint)
} else if (item.iconVector != null) {
Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint)
} else {
item.iconComposable()
}
},
position = position,
backgroundColor = backgroundColor,
)
}
}
}
data class InfoListItem(
val message: String,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)

View File

@@ -41,12 +41,14 @@ import io.element.android.libraries.theme.ElementTheme
*
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
* @param modifier Classical modifier.
* @param contentAlignment horizontal alignment of the contents.
* @param footer optional footer.
* @param content main content.
*/
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
@@ -78,6 +80,7 @@ fun OnBoardingPage(
.weight(1f)
.padding(horizontal = 24.dp)
.fillMaxWidth(),
horizontalAlignment = contentAlignment,
) {
content()
}

View File

@@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="110dp"
android:height="110dp"
android:viewportWidth="110"
android:viewportHeight="110">
<path
android:pathData="M55,110C85.38,110 110,85.38 110,55C110,24.62 85.38,0 55,0C24.62,0 0,24.62 0,55C0,85.38 24.62,110 55,110Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M44.94,25.63C44.94,23.41 46.75,21.61 48.97,21.61C64.05,21.61 76.27,33.81 76.27,48.85C76.27,51.07 74.47,52.87 72.25,52.87C70.02,52.87 68.22,51.07 68.22,48.85C68.22,38.25 59.6,29.65 48.97,29.65C46.75,29.65 44.94,27.85 44.94,25.63Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M84.36,44.83C86.59,44.83 88.39,46.63 88.39,48.85C88.39,63.9 76.17,76.1 61.09,76.1C58.87,76.1 57.06,74.3 57.06,72.08C57.06,69.86 58.87,68.06 61.09,68.06C71.72,68.06 80.34,59.46 80.34,48.85C80.34,46.63 82.14,44.83 84.36,44.83Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M65.12,84.37C65.12,86.59 63.32,88.39 61.09,88.39C46.01,88.39 33.79,76.19 33.79,61.15C33.79,58.93 35.59,57.13 37.82,57.13C40.04,57.13 41.85,58.93 41.85,61.15C41.85,71.75 50.46,80.35 61.09,80.35C63.32,80.35 65.12,82.15 65.12,84.37Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M25.63,65.17C23.41,65.17 21.61,63.37 21.61,61.15C21.61,46.1 33.83,33.9 48.91,33.9C51.13,33.9 52.94,35.7 52.94,37.92C52.94,40.14 51.13,41.94 48.91,41.94C38.28,41.94 29.66,50.54 29.66,61.15C29.66,63.37 27.86,65.17 25.63,65.17Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@@ -14,18 +14,21 @@
* limitations under the License.
*/
package io.element.android.libraries.core.data
/**
* Wrapper for a CharSequence, which support mutation of the CharSequence.
*/
class StableCharSequence(val charSequence: CharSequence) {
private val hash = charSequence.toString().hashCode()
override fun hashCode() = hash
override fun equals(other: Any?) = other is StableCharSequence && other.hash == hash
override fun toString(): String = "StableCharSequence(\"$charSequence\")"
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
fun CharSequence.toStableCharSequence() = StableCharSequence(this)
android {
namespace = "io.element.android.libraries.maplibre.compose"
kotlinOptions {
freeCompilerArgs += "-Xexplicit-api=strict"
}
}
dependencies {
api(libs.maplibre)
api(libs.maplibre.ktx)
api(libs.maplibre.annotation)
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.Immutable
import com.mapbox.mapboxsdk.location.modes.CameraMode as InternalCameraMode
@Immutable
public enum class CameraMode {
NONE,
NONE_COMPASS,
NONE_GPS,
TRACKING,
TRACKING_COMPASS,
TRACKING_GPS,
TRACKING_GPS_NORTH;
@InternalCameraMode.Mode
internal fun toInternal(): Int = when (this) {
NONE -> InternalCameraMode.NONE
NONE_COMPASS -> InternalCameraMode.NONE_COMPASS
NONE_GPS -> InternalCameraMode.NONE_GPS
TRACKING -> InternalCameraMode.TRACKING
TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS
TRACKING_GPS -> InternalCameraMode.TRACKING_GPS
TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH
}
internal companion object {
fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) {
InternalCameraMode.NONE -> NONE
InternalCameraMode.NONE_COMPASS -> NONE_COMPASS
InternalCameraMode.NONE_GPS -> NONE_GPS
InternalCameraMode.TRACKING -> TRACKING
InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS
InternalCameraMode.TRACKING_GPS -> TRACKING_GPS
InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH
else -> error("Unknown camera mode: $mode")
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.Immutable
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_ANIMATION
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_API_GESTURE
import com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION
/**
* Enumerates the different reasons why the map camera started to move.
*
* Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener.
*
* [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed.
*
* [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this
* may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which
* case this library should be updated to include a new enum value for that constant.
*/
@Immutable
public enum class CameraMoveStartedReason(public val value: Int) {
UNKNOWN(-2),
NO_MOVEMENT_YET(-1),
GESTURE(REASON_API_GESTURE),
API_ANIMATION(REASON_API_ANIMATION),
DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION);
public companion object {
/**
* Converts from the Maps SDK [com.mapbox.mapboxsdk.maps.MapboxMap.OnCameraMoveStartedListener]
* constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such
* [CameraMoveStartedReason] for the given [value].
*
* See https://docs.maptiler.com/maplibre-gl-native-android/com.mapbox.mapboxsdk.maps/#oncameramovestartedlistener.
*/
public fun fromInt(value: Int): CameraMoveStartedReason {
return values().firstOrNull { it.value == value } ?: return UNKNOWN
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import android.location.Location
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.camera.CameraUpdateFactory
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Projection
import kotlinx.parcelize.Parcelize
/**
* Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver].
* [init] will be called when the [CameraPositionState] is first created to configure its
* initial state.
*/
@Composable
public inline fun rememberCameraPositionState(
key: String? = null,
crossinline init: CameraPositionState.() -> Unit = {}
): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) {
CameraPositionState().apply(init)
}
/**
* A state object that can be hoisted to control and observe the map's camera state.
* A [CameraPositionState] may only be used by a single [MapboxMap] composable at a time
* as it reflects instance state for a single view of a map.
*
* @param position the initial camera position
* @param cameraMode the initial camera mode
*/
public class CameraPositionState(
position: CameraPosition = CameraPosition.Builder().build(),
cameraMode: CameraMode = CameraMode.NONE,
) {
/**
* Whether the camera is currently moving or not. This includes any kind of movement:
* panning, zooming, or rotation.
*/
public var isMoving: Boolean by mutableStateOf(false)
internal set
/**
* The reason for the start of the most recent camera moment, or
* [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or
* [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK.
*/
public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf(
CameraMoveStartedReason.NO_MOVEMENT_YET
)
internal set
/**
* Returns the current [Projection] to be used for converting between screen
* coordinates and lat/lng.
*/
public val projection: Projection?
get() = map?.projection
/**
* Local source of truth for the current camera position.
* While [map] is non-null this reflects the current position of [map] as it changes.
* While [map] is null it reflects the last known map position, or the last value set by
* explicitly setting [position].
*/
internal var rawPosition by mutableStateOf(position)
/**
* Current position of the camera on the map.
*/
public var position: CameraPosition
get() = rawPosition
set(value) {
synchronized(lock) {
val map = map
if (map == null) {
rawPosition = value
} else {
map.moveCamera(CameraUpdateFactory.newCameraPosition(value))
}
}
}
/**
* Local source of truth for the current camera mode.
* While [map] is non-null this reflects the current camera mode as it changes.
* While [map] is null it reflects the last known camera mode, or the last value set by
* explicitly setting [cameraMode].
*/
internal var rawCameraMode by mutableStateOf(cameraMode)
/**
* Current tracking mode of the camera.
*/
public var cameraMode: CameraMode
get() = rawCameraMode
set(value) {
synchronized(lock) {
val map = map
if (map == null) {
rawCameraMode = value
} else {
map.locationComponent.cameraMode = value.toInternal()
}
}
}
/**
* The user's last available location.
*/
public var location: Location? by mutableStateOf(null)
internal set
// Used to perform side effects thread-safely.
// Guards all mutable properties that are not `by mutableStateOf`.
private val lock = Unit
// The map currently associated with this CameraPositionState.
// Guarded by `lock`.
private var map: MapboxMap? by mutableStateOf(null)
// The current map is set and cleared by side effect.
// There can be only one associated at a time.
internal fun setMap(map: MapboxMap?) {
synchronized(lock) {
if (this.map == null && map == null) return
if (this.map != null && map != null) {
error("CameraPositionState may only be associated with one MapboxMap at a time")
}
this.map = map
if (map == null) {
isMoving = false
} else {
map.moveCamera(CameraUpdateFactory.newCameraPosition(position))
map.locationComponent.cameraMode = cameraMode.toInternal()
}
}
}
public companion object {
/**
* The default saver implementation for [CameraPositionState].
*/
public val Saver: Saver<CameraPositionState, SaveableCameraPositionState> = Saver(
save = { SaveableCameraPositionState(it.position, it.cameraMode.toInternal()) },
restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) }
)
}
}
/** Provides the [CameraPositionState] used by the map. */
internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() }
/** The current [CameraPositionState] used by the map. */
public val currentCameraPositionState: CameraPositionState
@[MapboxMapComposable ReadOnlyComposable Composable]
get() = LocalCameraPositionState.current
@Parcelize
public data class SaveableCameraPositionState(
val position: CameraPosition,
val cameraMode: Int
) : Parcelable

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.Immutable
import com.mapbox.mapboxsdk.style.layers.Property
@Immutable
public enum class IconAnchor {
CENTER,
LEFT,
RIGHT,
TOP,
BOTTOM,
TOP_LEFT,
TOP_RIGHT,
BOTTOM_LEFT,
BOTTOM_RIGHT;
@Property.ICON_ANCHOR
internal fun toInternal(): String = when (this) {
CENTER -> Property.ICON_ANCHOR_CENTER
LEFT -> Property.ICON_ANCHOR_LEFT
RIGHT -> Property.ICON_ANCHOR_RIGHT
TOP -> Property.ICON_ANCHOR_TOP
BOTTOM -> Property.ICON_ANCHOR_BOTTOM
TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT
TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT
BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT
BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.AbstractApplier
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
internal interface MapNode {
fun onAttached() {}
fun onRemoved() {}
fun onCleared() {}
}
private object MapNodeRoot : MapNode
internal class MapApplier(
val map: MapboxMap,
val style: Style,
val symbolManager: SymbolManager,
) : AbstractApplier<MapNode>(MapNodeRoot) {
private val decorations = mutableListOf<MapNode>()
override fun onClear() {
symbolManager.deleteAll()
decorations.forEach { it.onCleared() }
decorations.clear()
}
override fun insertBottomUp(index: Int, instance: MapNode) {
decorations.add(index, instance)
instance.onAttached()
}
override fun insertTopDown(index: Int, instance: MapNode) {
// insertBottomUp is preferred
}
override fun move(from: Int, to: Int, count: Int) {
decorations.move(from, to, count)
}
override fun remove(index: Int, count: Int) {
repeat(count) {
decorations[index + it].onRemoved()
}
decorations.remove(index, count)
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
internal val DefaultMapLocationSettings = MapLocationSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapLocationSettings(
public val locationEnabled: Boolean = false,
)

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapSymbolManagerSettings(
public val iconAllowOverlap: Boolean = false,
)

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import android.view.Gravity
import androidx.compose.ui.graphics.Color
internal val DefaultMapUiSettings = MapUiSettings()
/**
* Data class for UI-related settings on the map.
*
* Note: Should not be a data class if in need of maintaining binary compatibility
* on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/
*/
public data class MapUiSettings(
public val compassEnabled: Boolean = true,
public val rotationGesturesEnabled: Boolean = true,
public val scrollGesturesEnabled: Boolean = true,
public val tiltGesturesEnabled: Boolean = true,
public val zoomGesturesEnabled: Boolean = true,
public val logoGravity: Int = Gravity.BOTTOM,
public val attributionGravity: Int = Gravity.BOTTOM,
public val attributionTintColor: Color = Color.Unspecified,
)

View File

@@ -0,0 +1,154 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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:Suppress("MatchingDeclarationName")
package io.element.android.libraries.maplibre.compose
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.currentComposer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions
import com.mapbox.mapboxsdk.location.LocationComponentOptions
import com.mapbox.mapboxsdk.location.OnCameraTrackingChangedListener
import com.mapbox.mapboxsdk.location.engine.LocationEngineRequest
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
private const val LOCATION_REQUEST_INTERVAL = 750L
internal class MapPropertiesNode(
val map: MapboxMap,
style: Style,
context: Context,
cameraPositionState: CameraPositionState,
) : MapNode {
init {
map.locationComponent.activateLocationComponent(
LocationComponentActivationOptions.Builder(context, style)
.locationComponentOptions(
LocationComponentOptions.builder(context)
.pulseEnabled(true)
.build()
)
.locationEngineRequest(
LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL)
.setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY)
.setFastestInterval(LOCATION_REQUEST_INTERVAL)
.build()
)
.build()
)
cameraPositionState.setMap(map)
}
var cameraPositionState = cameraPositionState
set(value) {
if (value == field) return
field.setMap(null)
field = value
value.setMap(map)
}
override fun onAttached() {
map.addOnCameraIdleListener {
cameraPositionState.isMoving = false
// addOnCameraIdleListener is only invoked when the camera position
// is changed via .animate(). To handle updating state when .move()
// is used, it's necessary to set the camera's position here as well
cameraPositionState.rawPosition = map.cameraPosition
// Updating user location on every camera move due to lack of a better location updates API.
cameraPositionState.location = map.locationComponent.lastKnownLocation
}
map.addOnCameraMoveCancelListener {
cameraPositionState.isMoving = false
}
map.addOnCameraMoveStartedListener {
cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it)
cameraPositionState.isMoving = true
}
map.addOnCameraMoveListener {
cameraPositionState.rawPosition = map.cameraPosition
// Updating user location on every camera move due to lack of a better location updates API.
cameraPositionState.location = map.locationComponent.lastKnownLocation
}
map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener {
override fun onCameraTrackingDismissed() {}
override fun onCameraTrackingChanged(currentMode: Int) {
cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode)
}
})
}
override fun onRemoved() {
cameraPositionState.setMap(null)
}
override fun onCleared() {
cameraPositionState.setMap(null)
}
}
/**
* Used to keep the primary map properties up to date. This should never leave the map composition.
*/
@SuppressLint("MissingPermission")
@Suppress("NOTHING_TO_INLINE")
@Composable
internal inline fun MapUpdater(
cameraPositionState: CameraPositionState,
mapLocationSettings: MapLocationSettings,
mapUiSettings: MapUiSettings,
mapSymbolManagerSettings: MapSymbolManagerSettings,
) {
val mapApplier = currentComposer.applier as MapApplier
val map = mapApplier.map
val style = mapApplier.style
val symbolManager = mapApplier.symbolManager
val context = LocalContext.current
ComposeNode<MapPropertiesNode, MapApplier>(
factory = {
MapPropertiesNode(
map = map,
style = style,
context = context,
cameraPositionState = cameraPositionState,
)
},
update = {
set(mapLocationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it }
set(mapUiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it }
set(mapUiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it }
set(mapUiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it }
set(mapUiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it }
set(mapUiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
set(mapUiSettings.logoGravity) { map.uiSettings.logoGravity = it }
set(mapUiSettings.attributionGravity) { map.uiSettings.attributionGravity = it }
set(mapUiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) }
set(mapSymbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it }
update(cameraPositionState) { this.cameraPositionState = it }
}
)
}

View File

@@ -0,0 +1,251 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import android.content.ComponentCallbacks
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composition
import androidx.compose.runtime.CompositionContext
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.mapbox.mapboxsdk.Mapbox
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.awaitCancellation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* A compose container for a MapLibre [MapView].
*
* Heavily inspired by https://github.com/googlemaps/android-maps-compose
*
* @param styleUri a URI where to asynchronously fetch a style for the map
* @param modifier Modifier to be applied to the MapboxMap
* @param images images added to the map's style to be later used with [Symbol]
* @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's
* camera state
* @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map
* @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings
* @param locationSettings the [MapLocationSettings] to be used for location settings
* @param content the content of the map
*/
@Composable
public fun MapboxMap(
styleUri: String,
modifier: Modifier = Modifier,
images: ImmutableMap<String, Int> = persistentMapOf(),
cameraPositionState: CameraPositionState = rememberCameraPositionState(),
uiSettings: MapUiSettings = DefaultMapUiSettings,
symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings,
locationSettings: MapLocationSettings = DefaultMapLocationSettings,
content: (@Composable @MapboxMapComposable () -> Unit)? = null,
) {
// When in preview, early return a Box with the received modifier preserving layout
if (LocalInspectionMode.current) {
@Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return.
Box(
modifier = modifier.background(Color.DarkGray)
) {
Text("[Map]", modifier = Modifier.align(Alignment.Center))
}
return
}
val context = LocalContext.current
val mapView = remember {
Mapbox.getInstance(context)
MapView(context)
}
@Suppress("ModifierReused")
AndroidView(modifier = modifier, factory = { mapView })
MapLifecycle(mapView)
// rememberUpdatedState and friends are used here to make these values observable to
// the subcomposition without providing a new content function each recomposition
val currentCameraPositionState by rememberUpdatedState(cameraPositionState)
val currentUiSettings by rememberUpdatedState(uiSettings)
val currentMapLocationSettings by rememberUpdatedState(locationSettings)
val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings)
val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
LaunchedEffect(styleUri, images) {
disposingComposition {
parentComposition.newComposition(
context = context,
mapView = mapView,
styleUri = styleUri,
images = images,
) {
MapUpdater(
cameraPositionState = currentCameraPositionState,
mapUiSettings = currentUiSettings,
mapLocationSettings = currentMapLocationSettings,
mapSymbolManagerSettings = currentSymbolManagerSettings,
)
CompositionLocalProvider(
LocalCameraPositionState provides cameraPositionState,
) {
currentContent?.invoke()
}
}
}
}
}
private suspend inline fun disposingComposition(factory: () -> Composition) {
val composition = factory()
try {
awaitCancellation()
} finally {
composition.dispose()
}
}
private suspend inline fun CompositionContext.newComposition(
context: Context,
mapView: MapView,
styleUri: String,
images: ImmutableMap<String, Int>,
noinline content: @Composable () -> Unit
): Composition {
val map = mapView.awaitMap()
val style = map.awaitStyle(context, styleUri, images)
val symbolManager = SymbolManager(mapView, map, style)
return Composition(
MapApplier(map, style, symbolManager), this
).apply {
setContent(content)
}
}
private suspend inline fun MapView.awaitMap(): MapboxMap = suspendCoroutine { continuation ->
getMapAsync { map ->
continuation.resume(map)
}
}
private suspend inline fun MapboxMap.awaitStyle(
context: Context,
styleUri: String,
images: ImmutableMap<String, Int>,
): Style = suspendCoroutine { continuation ->
setStyle(
Style.Builder().apply {
fromUri(styleUri)
images.forEach { (id, drawableRes) ->
withImage(id, checkNotNull(context.getDrawable(drawableRes)) {
"Drawable resource $drawableRes with id $id not found"
})
}
}
) { style ->
continuation.resume(style)
}
}
/**
* Registers lifecycle observers to the local [MapView].
*/
@Composable
private fun MapLifecycle(mapView: MapView) {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) }
DisposableEffect(context, lifecycle, mapView) {
val mapLifecycleObserver = mapView.lifecycleObserver(previousState)
val callbacks = mapView.componentCallbacks()
lifecycle.addObserver(mapLifecycleObserver)
context.registerComponentCallbacks(callbacks)
onDispose {
lifecycle.removeObserver(mapLifecycleObserver)
context.unregisterComponentCallbacks(callbacks)
}
}
DisposableEffect(mapView) {
onDispose {
mapView.onDestroy()
mapView.removeAllViews()
}
}
}
private fun MapView.lifecycleObserver(previousState: MutableState<Lifecycle.Event>): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
event.targetState
when (event) {
Lifecycle.Event.ON_CREATE -> {
// Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in
// this case the MapboxMap composable also doesn't leave the composition. So,
// recreating the map does not restore state properly which must be avoided.
if (previousState.value != Lifecycle.Event.ON_STOP) {
this.onCreate(Bundle())
}
}
Lifecycle.Event.ON_START -> this.onStart()
Lifecycle.Event.ON_RESUME -> this.onResume()
Lifecycle.Event.ON_PAUSE -> this.onPause()
Lifecycle.Event.ON_STOP -> this.onStop()
Lifecycle.Event.ON_DESTROY -> {
//handled in onDispose
}
else -> throw IllegalStateException()
}
previousState.value = event
}
private fun MapView.componentCallbacks(): ComponentCallbacks =
object : ComponentCallbacks {
override fun onConfigurationChanged(config: Configuration) {}
override fun onLowMemory() {
this@componentCallbacks.onLowMemory()
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.ComposableTargetMarker
/**
* An annotation that can be used to mark a composable function as being expected to be use in a
* composable function that is also marked or inferred to be marked as a [MapboxMapComposable].
*
* This will produce build warnings when [MapboxMapComposable] composable functions are used outside
* of a [MapboxMapComposable] content lambda, and vice versa.
*/
@Retention(AnnotationRetention.BINARY)
@ComposableTargetMarker(description = "MapLibre Map Composable")
@Target(
AnnotationTarget.FILE,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.TYPE,
AnnotationTarget.TYPE_PARAMETER,
)
public annotation class MapboxMapComposable

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright 2021 Google LLC
* Copied and adapted from android-maps-compose (https://github.com/googlemaps/android-maps-compose)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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.maplibre.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ComposeNode
import androidx.compose.runtime.currentComposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.plugins.annotation.Symbol
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
internal class SymbolNode(
val symbolManager: SymbolManager,
val symbol: Symbol,
) : MapNode {
override fun onRemoved() {
symbolManager.delete(symbol)
}
override fun onCleared() {
symbolManager.delete(symbol)
}
}
/**
* A state object that can be hoisted to control and observe the symbol state.
*
* @param position the initial symbol position
*/
public class SymbolState(
position: LatLng = LatLng(0.0, 0.0)
) {
/**
* Current position of the symbol.
*/
public var position: LatLng by mutableStateOf(position)
public companion object {
/**
* The default saver implementation for [SymbolState].
*/
public val Saver: Saver<SymbolState, LatLng> = Saver(
save = { it.position },
restore = { SymbolState(it) }
)
}
}
@Composable
public fun rememberSymbolState(
key: String? = null,
position: LatLng = LatLng(0.0, 0.0)
): SymbolState = rememberSaveable(key = key, saver = SymbolState.Saver) {
SymbolState(position)
}
/**
* A composable for a symbol on the map.
*
* @param iconId an id of an image from the current [Style]
* @param state the [SymbolState] to be used to control or observe the symbol
* state such as its position and info window
* @param iconAnchor the anchor for the symbol image
*/
@Composable
@MapboxMapComposable
public fun Symbol(
iconId: String,
state: SymbolState = rememberSymbolState(),
iconAnchor: IconAnchor? = null,
) {
val mapApplier = currentComposer.applier as MapApplier
val symbolManager = mapApplier.symbolManager
ComposeNode<SymbolNode, MapApplier>(
factory = {
SymbolNode(
symbolManager = symbolManager,
symbol = symbolManager.create(
SymbolOptions().apply {
withLatLng(state.position)
withIconImage(iconId)
iconAnchor?.let { withIconAnchor(it.toInternal()) }
}
),
)
},
update = {
update(state.position) {
this.symbol.latLng = it
symbolManager.update(this.symbol)
}
update(iconId) {
this.symbol.iconImage = it
symbolManager.update(this.symbol)
}
update(iconAnchor) {
this.symbol.iconAnchor = it?.toInternal()
symbolManager.update(this.symbol)
}
}
)
}

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