Merge branch 'develop' of https://github.com/vector-im/element-x-android into feature/dla_custom_reactions_layout

This commit is contained in:
David Langley 2023-07-18 21:20:05 +01:00
commit 7f84aef543
330 changed files with 4873 additions and 1263 deletions

View file

@ -34,10 +34,12 @@ 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
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
uses: actions/upload-artifact@v3
@ -45,7 +47,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

@ -35,6 +35,7 @@ jobs:
run: |
./gradlew assembleNightly appDistributionUploadNightly $CI_GRADLE_ARG_PROPERTIES
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}

View file

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

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

@ -58,6 +58,9 @@ if [[ -z ${REPO} ]]; then
exit 1
fi
echo "Deleting previous screenshots"
./gradlew removeOldSnapshots --stacktrace -PpreDexEnable=false --max-workers 4 --warn
echo "Record screenshots"
./gradlew recordPaparazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn

View file

@ -33,7 +33,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' }}

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

@ -16,7 +16,6 @@
package io.element.android.appnav.loggedin
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@ -32,14 +31,12 @@ fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val activity = LocalContext.current as? Activity
val context = LocalContext.current
PermissionsView(
state = state.permissionsState,
modifier = modifier,
openSystemSettings = {
activity?.let { openAppSettingsPage(it) }
}
openSystemSettings = context::openAppSettingsPage
)
}

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

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

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

42
docs/maps.md Normal file
View file

@ -0,0 +1,42 @@
# Use of maps
<!--- TOC -->
* [Overview](#overview)
* [Local development with MapTiler](#local-development-with-maptiler)
* [Making releasable builds with MapTiler](#making-releasable-builds-with-maptiler)
* [Using other map sources or MapTiler styles](#using-other-map-sources-or-maptiler-styles)
<!--- END -->
## Overview
Element Android uses [MapTiler](https://www.maptiler.com/) to provide map
imagery where required. MapTiler requires an API key, which we bake in to
the app at release time.
## Local development with MapTiler
If you're developing the application and want maps to render properly you can
sign up for the [MapTiler free tier](https://www.maptiler.com/cloud/pricing/).
Place your API key in `local.properties` with the key
`services.maptiler.apikey`, e.g.:
```properties
services.maptiler.apikey=abCd3fGhijK1mN0pQr5t
```
## Making releasable builds with MapTiler
To insert the MapTiler API key when building an APK, set the
`ELEMENT_ANDROID_MAPTILER_API_KEY` environment variable in your build
environment.
## Using other map sources or MapTiler styles
If you wish to use an alternative map provider, or custom MapTiler styles,
you can customise the functions in
`features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapUrls.kt`.
We've kept this file small and self contained to minimise the chances of merge
collisions in forks.

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

@ -14,14 +14,33 @@
* limitations under the License.
*/
import java.util.Properties
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
fun readLocalProperty(name: String) = Properties().apply {
try {
load(rootProject.file("local.properties").reader())
} catch (ignored: java.io.IOException) {
}
}[name]
android {
namespace = "io.element.android.features.location.api"
defaultConfig {
resValue(
type = "string",
name = "maptiler_api_key",
value = System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey") as? String
?: ""
)
}
}
dependencies {

View file

@ -34,9 +34,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import io.element.android.features.location.api.internal.AttributionPlacement
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.buildStaticMapsApiUrl
import io.element.android.features.location.api.internal.staticMapUrl
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toDp
@ -64,6 +63,7 @@ fun StaticMapView(
modifier = modifier,
contentAlignment = Alignment.Center
) {
val context = LocalContext.current
var retryHash by remember { mutableStateOf(0) }
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
@ -72,17 +72,16 @@ fun StaticMapView(
} else {
ImageRequest.Builder(LocalContext.current)
.data(
buildStaticMapsApiUrl(
staticMapUrl(
context = context,
lat = lat,
lon = lon,
desiredZoom = zoom,
zoom = zoom,
darkMode = darkMode,
attributionPlacement = AttributionPlacement.BottomLeft,
// Size the map based on DP rather than pixels, as otherwise the features and attribution
// end up being illegibly tiny on high density displays.
desiredWidth = constraints.maxWidth.toDp().value.toInt(),
desiredHeight = constraints.maxHeight.toDp().value.toInt(),
doubleScale = true,
width = constraints.maxWidth.toDp().value.toInt(),
height = constraints.maxHeight.toDp().value.toInt(),
)
)
.size(width = constraints.maxWidth, height = constraints.maxHeight)
@ -119,7 +118,6 @@ fun StaticMapView(
showProgress = painter.state is AsyncImagePainter.State.Loading,
contentDescription = contentDescription,
modifier = Modifier.size(width = maxWidth, height = maxHeight),
darkMode = darkMode,
onLoadMapClick = { retryHash++ }
)
}

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.
*/
package io.element.android.features.location.api.internal
import android.content.Context
import io.element.android.features.location.api.R
/**
* Provides the URL to an image that contains a statically-generated map of the given location.
*/
fun staticMapUrl(
context: Context,
lat: Double,
lon: Double,
zoom: Double,
width: Int,
height: Int,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/static/${lon},${lat},${zoom}/${width}x${height}@2x.webp?key=${context.apiKey}&attribution=bottomleft"
}
/**
* Provides the URL to a MapLibre style document, used for rendering dynamic maps.
*/
fun tileStyleUrl(
context: Context,
darkMode: Boolean,
): String {
return "${baseUrl(darkMode)}/style.json?key=${context.apiKey}"
}
private fun baseUrl(darkMode: Boolean) =
"https://api.maptiler.com/maps/" +
if (darkMode)
"dea61faf-292b-4774-9660-58fcef89a7f3"
else
"9bc819c8-e627-474a-a348-ec144fe3d810"
private val Context.apiKey: String
get() = getString(R.string.maptiler_api_key)

View file

@ -1,91 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import kotlin.math.roundToInt
private const val API_KEY = "fU3vlMsMn4Jb6dnEIFsx"
private const val BASE_URL = "https://api.maptiler.com"
private const val LIGHT_MAP_ID = "9bc819c8-e627-474a-a348-ec144fe3d810"
private const val DARK_MAP_ID = "dea61faf-292b-4774-9660-58fcef89a7f3"
private const val STATIC_MAP_FORMAT = "webp"
private const val STATIC_MAP_SCALE_2X = "@2x"
private const val STATIC_MAP_MAX_WIDTH_HEIGHT = 2048
private const val STATIC_MAP_MAX_ZOOM = 22.0
fun buildTileServerUrl(
darkMode: Boolean
): String = if (!darkMode) {
"$BASE_URL/maps/$LIGHT_MAP_ID/style.json?key=$API_KEY"
} else {
"$BASE_URL/maps/$DARK_MAP_ID/style.json?key=$API_KEY"
}
internal enum class AttributionPlacement(val value: String) {
BottomRight("bottomright"),
BottomLeft("bottomleft"),
TopLeft("topleft"),
TopRight("topright"),
Hidden("false"),
}
/**
* Builds a valid URL for maptiler.com static map api based on the given params.
*
* Coerces width and height to the API maximum of 2048 keeping the requested aspect ratio.
* Coerces zoom to the API maximum of 22.
*
* NB: This will throw if either width or height are <= 0. You need to handle this case upstream
* (hint: views can't have negative width or height but can have 0 width or height sometimes).
*/
internal fun buildStaticMapsApiUrl(
lat: Double,
lon: Double,
desiredZoom: Double,
desiredWidth: Int,
desiredHeight: Int,
darkMode: Boolean,
doubleScale: Boolean,
attributionPlacement: AttributionPlacement,
): String {
require(desiredWidth > 0 && desiredHeight > 0) {
"Width ($desiredHeight) and height ($desiredHeight) must be > 0"
}
require(desiredZoom >= 0) { "Zoom ($desiredZoom) must be >= 0" }
val zoom = desiredZoom.coerceAtMost(STATIC_MAP_MAX_ZOOM) // API will error if outside 0-22 range.
val width: Int
val height: Int
if (desiredWidth <= STATIC_MAP_MAX_WIDTH_HEIGHT && desiredHeight <= STATIC_MAP_MAX_WIDTH_HEIGHT) {
width = desiredWidth
height = desiredHeight
} else {
val aspectRatio = desiredWidth.toDouble() / desiredHeight.toDouble()
if (desiredWidth >= desiredHeight) {
width = desiredWidth.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
height = (width / aspectRatio).roundToInt()
} else {
height = desiredHeight.coerceAtMost(STATIC_MAP_MAX_WIDTH_HEIGHT)
width = (height * aspectRatio).roundToInt()
}
}
val mapId = if (darkMode) DARK_MAP_ID else LIGHT_MAP_ID
val scaleSuffix = if (doubleScale) STATIC_MAP_SCALE_2X else ""
return "$BASE_URL/maps/$mapId/static/${lon},${lat},${zoom}/${width}x${height}${scaleSuffix}.$STATIC_MAP_FORMAT" +
"?key=$API_KEY&attribution=${attributionPlacement.value}"
}

View file

@ -38,7 +38,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@ -46,17 +45,13 @@ internal fun StaticMapPlaceholder(
showProgress: Boolean,
contentDescription: String?,
modifier: Modifier = Modifier,
darkMode: Boolean = !ElementTheme.isLightTheme,
onLoadMapClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(
id = if (darkMode) R.drawable.blurred_map_dark
else R.drawable.blurred_map_light
),
painter = painterResource(id = R.drawable.blurred_map),
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.FillBounds,

View file

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -1,117 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class BuildStaticMapsApiUrlTest {
@Test
fun `buildStaticMapsApiUrl builds light mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds dark mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = true,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/dea61faf-292b-4774-9660-58fcef89a7f3/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds double scale mode url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false,
doubleScale = true,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200@2x.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
@Test
fun `buildStaticMapsApiUrl builds no attribution url`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 1.2,
desiredWidth = 100,
desiredHeight = 200,
darkMode = false,
doubleScale = false,
attributionPlacement = AttributionPlacement.Hidden,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,1.2/100x200.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=false"
)
}
@Test
fun `buildStaticMapsApiUrl coerces zoom at 22 and width and height at max 2048 keeping aspect ratio`() {
assertThat(
buildStaticMapsApiUrl(
lat = 1.234,
lon = 5.678,
desiredZoom = 100.0,
desiredWidth = 8192,
desiredHeight = 4096,
darkMode = false,
doubleScale = false,
attributionPlacement = AttributionPlacement.BottomLeft,
)
).isEqualTo(
"https://api.maptiler.com/maps/9bc819c8-e627-474a-a348-ec144fe3d810/static/5.678,1.234,22.0/2048x1024.webp" +
"?key=fU3vlMsMn4Jb6dnEIFsx&attribution=bottomleft"
)
}
}

View file

@ -50,7 +50,7 @@ import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property.ICON_ANCHOR_BOTTOM
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.buildTileServerUrl
import io.element.android.features.location.api.internal.tileStyleUrl
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
@ -102,7 +102,7 @@ fun MapView(
isCompassEnabled = false
isRotateGesturesEnabled = false
}
map.setStyle(buildTileServerUrl(darkMode = darkMode)) { style ->
map.setStyle(tileStyleUrl(context, darkMode)) { style ->
mapRefs = MapRefs(
map = map,
symbolManager = SymbolManager(mapView, map, style).apply {

View file

@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.media.viewer.MediaViewerNode
import io.element.android.features.messages.impl.report.ReportMessageNode
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -224,6 +225,20 @@ class MessagesFlowNode @AssistedInject constructor(
)
backstack.push(navTarget)
}
is TimelineItemAudioContent -> {
val mediaSource = event.content.audioSource
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
name = event.content.body,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = mediaSource,
thumbnailSource = null,
)
backstack.push(navTarget)
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
location = event.content.location,

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -108,10 +109,10 @@ class MessagesPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value){
val roomName by produceState(initialValue = room.displayName, key1 = syncUpdateFlow.value) {
value = room.displayName
}
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value){
val roomAvatar by produceState(initialValue = room.avatarData(), key1 = syncUpdateFlow.value) {
value = room.avatarData()
}
var hasDismissedInviteDialog by rememberSaveable {
@ -250,28 +251,28 @@ class MessagesPresenter @AssistedInject constructor(
val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.mediaSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash,
)
is TimelineItemVideoContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Video,
blurHash = targetEvent.content.blurHash,
)
is TimelineItemFileContent -> AttachmentThumbnailInfo(
mediaSource = targetEvent.content.thumbnailSource,
thumbnailSource = targetEvent.content.thumbnailSource,
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is TimelineItemAudioContent -> AttachmentThumbnailInfo(
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Audio,
)
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = null,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,

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

@ -56,6 +56,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -246,8 +247,6 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
info = AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
textContent = stringResource(CommonStrings.common_shared_location),
mediaSource = null,
blurHash = null,
)
)
}
@ -258,9 +257,9 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash,
)
)
@ -272,7 +271,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = event.content.thumbnailSource,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.Video,
blurHash = event.content.blurHash,
@ -286,10 +285,21 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
mediaSource = null,
thumbnailSource = event.content.thumbnailSource,
textContent = textContent,
type = AttachmentThumbnailType.File,
blurHash = null
)
)
}
content = { ContentForBody(event.content.body) }
}
is TimelineItemAudioContent -> {
icon = {
AttachmentThumbnail(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
textContent = textContent,
type = AttachmentThumbnailType.Audio,
)
)
}

View file

@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,7 +48,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
@ -59,7 +59,9 @@ import io.element.android.features.messages.impl.media.helper.formatFileExtensio
import io.element.android.features.messages.impl.media.local.exoplayer.ExoPlayerWrapper
import io.element.android.features.messages.impl.media.local.pdf.PdfViewer
import io.element.android.features.messages.impl.media.local.pdf.rememberPdfViewerState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.R
@ -103,6 +105,7 @@ fun LocalMediaView(
zoomableState = zoomableState,
modifier = modifier
)
//TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
@ -215,6 +218,7 @@ fun MediaFileView(
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -226,12 +230,12 @@ fun MediaFileView(
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.Attachment,
imageVector = if (isAudio) Icons.Outlined.GraphicEq else Icons.Outlined.Attachment,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(-45f),
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {

View file

@ -29,7 +29,7 @@ data class MediaInfo(
) : Parcelable
fun anImageInfo(): MediaInfo = MediaInfo(
"an image file.jpg", MimeTypes.Jpeg, "4MB","jpg"
"an image file.jpg", MimeTypes.Jpeg, "4MB", "jpg"
)
fun aVideoInfo(): MediaInfo = MediaInfo(
@ -43,3 +43,7 @@ fun aPdfInfo(): MediaInfo = MediaInfo(
fun aFileInfo(): MediaInfo = MediaInfo(
"an apk file.apk", MimeTypes.Apk, "50MB", "apk"
)
fun anAudioInfo(): MediaInfo = MediaInfo(
"an audio file.mp3", MimeTypes.Mp3, "7MB", "mp3"
)

View file

@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.aFileInfo
import io.element.android.features.messages.impl.media.local.aPdfInfo
import io.element.android.features.messages.impl.media.local.aVideoInfo
import io.element.android.features.messages.impl.media.local.anAudioInfo
import io.element.android.features.messages.impl.media.local.anImageInfo
import io.element.android.libraries.architecture.Async
@ -59,7 +60,17 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
)
),
aMediaViewerState(
Async.Loading(),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
LocalMedia(Uri.EMPTY, anAudioInfo())
),
anAudioInfo(),
),
)
}

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
@ -94,15 +92,15 @@ class MessageComposerPresenter @Inject constructor(
val hasFocus = remember {
mutableStateOf(false)
}
val text: MutableState<StableCharSequence> = remember {
mutableStateOf(StableCharSequence(""))
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
LaunchedEffect(messageComposerContext.composerMode) {
when (val modeValue = messageComposerContext.composerMode) {
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent.toStableCharSequence()
is MessageComposerMode.Edit -> text.value = modeValue.defaultContent
else -> Unit
}
}
@ -120,9 +118,9 @@ 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()
text.value = ""
messageComposerContext.composerMode = MessageComposerMode.Normal("")
}
@ -189,11 +187,11 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendMessage(
text: String,
updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit,
textState: MutableState<StableCharSequence>
textState: MutableState<String>
) = launch {
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
textState.value = "".toStableCharSequence()
textState.value = ""
updateComposerMode(MessageComposerMode.Normal(""))
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(text)

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

@ -31,8 +31,8 @@ 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
@ -45,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> {
@ -127,6 +128,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState,
inReplyTo = inReplyTo,
debugInfo = debugInfo,
origin = null
)
}
@ -153,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,10 +49,8 @@ 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
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
@ -64,14 +63,13 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
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 +98,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 +138,8 @@ fun TimelineView(
TimelineScrollHelper(
lazyListState = lazyListState,
timelineItems = state.timelineItems,
onScrollFinishedAt = ::onScrollFinishedAt,
hasNewItems = state.hasNewItems,
onScrollFinishedAt = ::onScrollFinishedAt
)
}
}
@ -244,63 +235,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
) {
@ -313,20 +307,11 @@ internal fun BoxScope.TimelineScrollHelper(
}
}
@Preview
@DayNightPreviews
@Composable
fun TimelineViewLightPreview(
fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewLight { ContentToPreview(content) }
@Preview
@Composable
fun TimelineViewDarkPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemEventContent) {
) = ElementPreview {
val timelineItems = aTimelineItemList(content)
TimelineView(
state = aTimelineState(timelineItems),

View file

@ -51,8 +51,8 @@ import io.element.android.libraries.theme.ElementTheme
private val BUBBLE_RADIUS = 12.dp
private val BUBBLE_INCOMING_OFFSET = 16.dp
// Design says: The maximum width of a bubble is still 3/4 of the screen width
private const val BUBBLE_WIDTH_RATIO = 0.75f
// Design says: The maximum width of a bubble is still 3/4 of the screen width. But try with 85% now.
private const val BUBBLE_WIDTH_RATIO = 0.85f
@OptIn(ExperimentalFoundationApi::class)
@Composable

View file

@ -56,7 +56,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.constraintlayout.compose.ConstrainScope
import androidx.constraintlayout.compose.ConstraintLayout
@ -85,6 +84,7 @@ import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
@ -521,28 +521,29 @@ private fun ReplyToContent(
private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready) =
when (val type = inReplyTo.content.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,
)
is VideoMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Video,
blurHash = type.info?.blurhash,
)
is FileMessageType -> AttachmentThumbnailInfo(
mediaSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.File,
blurHash = null,
)
is LocationMessageType -> AttachmentThumbnailInfo(
mediaSource = null,
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Location,
blurHash = null,
)
is AudioMessageType -> AttachmentThumbnailInfo(
textContent = inReplyTo.content.body,
type = AttachmentThumbnailType.Audio,
)
else -> null
}

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,97 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
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
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineItemAudioView(
content: TimelineItemAudioContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
tint = ElementTheme.materialColors.primary,
modifier = Modifier
.size(16.dp),
)
}
Spacer(Modifier.width(8.dp))
Column {
Text(
text = content.body,
color = ElementTheme.materialColors.primary,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis
)
Text(
text = content.fileExtensionAndSize + extraPadding.getStr(12.sp),
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@DayNightPreviews
@Composable
internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioContentProvider::class) content: TimelineItemAudioContent) =
ElementPreview {
TimelineItemAudioView(
content,
extraPadding = noExtraPadding,
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -80,6 +81,11 @@ fun TimelineItemEventContentView(
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemAudioContent -> TimelineItemAudioView(
content = content,
extraPadding = extraPadding,
modifier = modifier
)
is TimelineItemStateContent -> TimelineItemStateView(
content = content,
modifier = modifier

View file

@ -22,14 +22,13 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.StaticMapView
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
@ -57,17 +56,10 @@ fun TimelineItemLocationView(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun TimelineItemLocationViewLightPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewLight { ContentToPreview(content) }
internal fun TimelineItemLocationViewPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreview {
TimelineItemLocationView(content)
}
@Preview
@Composable
internal fun TimelineItemLocationViewDarkPreview(@PreviewParameter(TimelineItemLocationContentProvider::class) content: TimelineItemLocationContent) =
ElementPreviewDark { ContentToPreview(content) }
@Composable
private fun ContentToPreview(content: TimelineItemLocationContent) {
TimelineItemLocationView(content)
}

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

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.event
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
@ -30,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.util.FileExtensionExtr
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -99,6 +101,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is AudioMessageType -> TimelineItemAudioContent(
body = messageType.body,
audioSource = messageType.source,
duration = messageType.info?.duration?.toMillis() ?: 0L,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
is FileMessageType -> TimelineItemFileContent(
body = messageType.body,
thumbnailSource = messageType.info?.thumbnailSource,

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

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.groups
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -52,6 +53,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemImageContent,
is TimelineItemFileContent,
is TimelineItemVideoContent,
is TimelineItemAudioContent,
is TimelineItemLocationContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false

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

@ -27,6 +27,7 @@ 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
@ -66,6 +67,7 @@ sealed interface TimelineItem {
val localSendState: LocalEventSendState?,
val inReplyTo: InReplyTo?,
val debugInfo: TimelineItemDebugInfo,
val origin: TimelineItemEventOrigin?,
) : TimelineItem {
val showSenderInformation = groupPosition.isNew() && !isMine
@ -81,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,33 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource
data class TimelineItemAudioContent(
val body: String,
val duration: Long,
val audioSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
) : TimelineItemEventContent {
val fileExtensionAndSize = formatFileExtensionAndSize(fileExtension, formattedFileSize)
override val type: String = "TimelineItemAudioContent"
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemAudioContentProvider : PreviewParameterProvider<TimelineItemAudioContent> {
override val values: Sequence<TimelineItemAudioContent>
get() = sequenceOf(
aTimelineItemAudioContent("A sound.mp3"),
aTimelineItemAudioContent("A bigger name sound.mp3"),
aTimelineItemAudioContent("An even bigger bigger bigger bigger bigger bigger bigger sound name which doesn't fit .mp3"),
)
}
fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAudioContent(
body = fileName,
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "mp3",
duration = 100,
audioSource = MediaSource(""),
)

View file

@ -26,7 +26,7 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
aTimelineItemEncryptedContent(),
aTimelineItemImageContent(),
aTimelineItemVideoContent(),
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger file name which doesn't fit.pdf"),
aTimelineItemLocationContent(),
aTimelineItemLocationContent("Location description"),

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineItemFileContent> {
override val values: Sequence<TimelineItemFileContent>
get() = sequenceOf(
aTimelineItemFileContent("A file.pdf"),
aTimelineItemFileContent(),
aTimelineItemFileContent("A bigger name file.pdf"),
aTimelineItemFileContent("An even bigger bigger bigger bigger bigger bigger bigger file name which doesn't fit .pdf"),
)
@ -31,7 +31,7 @@ open class TimelineItemFileContentProvider : PreviewParameterProvider<TimelineIt
fun aTimelineItemFileContent(fileName: String = "A file.pdf") = TimelineItemFileContent(
body = fileName,
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",

View file

@ -31,7 +31,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4",
thumbnailSource = MediaSource(url = ""),
thumbnailSource = null,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
duration = 100,

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

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
@ -50,6 +51,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
}
}
}

View file

@ -574,6 +574,8 @@ class MessagesPresenterTest {
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

@ -31,7 +31,6 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
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
@ -84,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)
@ -117,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()
}
}
@ -138,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)
}
@ -155,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)
}
@ -172,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)
}
@ -187,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()
}
}
@ -207,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)
}
@ -238,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)
}
@ -269,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)
}
@ -326,7 +325,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Image(
file = File("/some/path"),
info = ImageInfo(
imageInfo = ImageInfo(
width = null,
height = null,
mimetype = null,
@ -359,7 +358,7 @@ class MessageComposerPresenterTest {
Result.success(
MediaUploadInfo.Video(
file = File("/some/path"),
info = VideoInfo(
videoInfo = VideoInfo(
width = null,
height = null,
mimetype = null,
@ -486,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()
}

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

@ -16,7 +16,6 @@
package io.element.android.features.onboarding.impl
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCode
@ -33,17 +31,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.aliasButtonText
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
@ -85,10 +83,6 @@ fun OnBoardingView(
@Composable
private fun OnBoardingContent(modifier: Modifier = Modifier) {
// Note: having a night variant of R.drawable.onboarding_icon in the folder `drawable-night` is working
// at runtime, but is not in Android Studio Preview. So I prefer to handle this manually.
val isLight = ElementTheme.colors.isLight
val iconDrawableRes = if (isLight) R.drawable.onboarding_icon_light else R.drawable.onboarding_icon_dark
Box(
modifier = modifier.fillMaxSize(),
) {
@ -99,14 +93,9 @@ private fun OnBoardingContent(modifier: Modifier = Modifier) {
verticalBias = -0.4f
)
) {
// Dark and light icon does not have the same size, add padding to the smaller one
val imagePadding = if (isLight) 28.dp else 0.dp
Image(
modifier = Modifier
.size(278.dp)
.padding(imagePadding),
painter = painterResource(id = iconDrawableRes),
contentDescription = null,
ElementLogoAtom(
size = ElementLogoAtomSize.Large,
modifier = Modifier.padding(top = ElementLogoAtomSize.Large.shadowRadius / 2)
)
}
Box(
@ -200,17 +189,10 @@ private fun OnBoardingButtons(
}
}
@Preview
@DayNightPreviews
@Composable
internal fun OnBoardingScreenLightPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
internal fun OnBoardingScreenDarkPreview(@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: OnBoardingState) {
internal fun OnBoardingScreenPreview(
@PreviewParameter(OnBoardingStateProvider::class) state: OnBoardingState
) = ElementPreview {
OnBoardingView(state)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

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

@ -76,8 +76,7 @@ class RoomDetailsNode @AssistedInject constructor(
val permalinkResult = alias?.let { PermalinkBuilder.permalinkForRoomAlias(it) }
?: PermalinkBuilder.permalinkForRoomId(room.roomId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,
@ -91,8 +90,7 @@ class RoomDetailsNode @AssistedInject constructor(
private fun onShareMember(context: Context, member: RoomMember) {
val permalinkResult = PermalinkBuilder.permalinkForUser(member.userId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,

View file

@ -68,8 +68,7 @@ class RoomMemberDetailsNode @AssistedInject constructor(
fun onShareUser() {
val permalinkResult = PermalinkBuilder.permalinkForUser(inputs.roomMemberId)
permalinkResult.onSuccess { permalink ->
startSharePlainTextIntent(
context = context,
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(R.string.screen_room_details_share_room_title),
text = permalink,

View file

@ -605,7 +605,7 @@ class RoomDetailsEditPresenterTest {
Result.success(
MediaUploadInfo.AnyFile(
file = processedFile,
info = mockk(),
fileInfo = mockk(),
)
)
)

View file

@ -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,6 +158,7 @@ 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

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