Merge branch 'develop' into feature/fga/better_media_handling

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,8 @@ appId: ${APP_ID}
- inputText: ${PASSWORD} - inputText: ${PASSWORD}
- pressKey: Enter - pressKey: Enter
- tapOn: "Continue" - tapOn: "Continue"
- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
- tapOn: "Continue"
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml - runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now" - tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml - 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.core)
implementation(libs.androidx.corektx) implementation(libs.androidx.corektx)
implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.androidx.preference) implementation(libs.androidx.preference)

View file

@ -32,6 +32,18 @@
android:theme="@style/Theme.ElementX" android:theme="@style/Theme.ElementX"
tools:targetApi="33"> 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 <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"

View file

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

View file

@ -19,6 +19,8 @@ package io.element.android.appnav
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope 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.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode 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.createroom.api.CreateRoomEntryPoint
import io.element.android.features.invitelist.api.InviteListEntryPoint import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor 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.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint 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.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler 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.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager 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 io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -81,14 +81,14 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomListEntryPoint: RoomListEntryPoint, private val roomListEntryPoint: RoomListEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint, private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val analyticsOptInEntryPoint: AnalyticsEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint, private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint, private val inviteListEntryPoint: InviteListEntryPoint,
private val analyticsService: AnalyticsService, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -99,19 +99,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins = plugins 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 { interface Callback : Plugin {
fun onOpenBugReport() = Unit fun onOpenBugReport() = Unit
} }
@ -136,7 +123,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
observeAnalyticsState()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) } 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 // TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE) appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope) loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) {
backstack.push(NavTarget.Ftue)
}
}, },
onResume = { onResume = {
syncService.startSync() syncService.startSync()
@ -209,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget object InviteList : NavTarget
@Parcelize @Parcelize
object AnalyticsOptIn : NavTarget object Ftue : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -306,8 +297,13 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.AnalyticsOptIn -> { NavTarget.Ftue -> {
analyticsOptInEntryPoint.createNode(this, buildContext) ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
backstack.pop()
}
}).build()
} }
} }
} }
@ -335,7 +331,11 @@ class LoggedInFlowNode @AssistedInject constructor(
transitionHandler = rememberDefaultTransitionHandler(), 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.di.MatrixClientsHolder
import io.element.android.appnav.intent.IntentResolver import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent 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.RootPresenter
import io.element.android.appnav.root.RootView 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.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow 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.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler 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.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.UUID
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class RootFlowNode @AssistedInject constructor( class RootFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val authenticationService: MatrixAuthenticationService, private val authenticationService: MatrixAuthenticationService,
private val cacheService: CacheService, private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixClientsHolder: MatrixClientsHolder, private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
private val loginUserStory: LoginUserStory,
) : ) :
BackstackNode<RootFlowNode.NavTarget>( BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -91,26 +83,25 @@ class RootFlowNode @AssistedInject constructor(
) { ) {
override fun onBuilt() { override fun onBuilt() {
matrixClientsHolder.restore(buildContext.savedStateMap) matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt() super.onBuilt()
observeLoggedInState() observeNavState()
} }
override fun onSaveInstanceState(state: MutableSavedStateMap) { override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state) super.onSaveInstanceState(state)
matrixClientsHolder.save(state) matrixClientsHolder.saveIntoSavedState(state)
navStateFlowFactory.saveIntoSavedState(state)
} }
private fun observeLoggedInState() { private fun observeNavState() {
combine( navStateFlowFactory.create(buildContext.savedStateMap)
cacheService.onClearedCacheEventFlow(), .distinctUntilChanged()
isUserLoggedInFlow(), .onEach { navState ->
) { _, isLoggedIn -> isLoggedIn } Timber.v("navState=$navState")
.onEach { isLoggedIn -> if (navState.isLoggedIn) {
Timber.v("isLoggedIn=$isLoggedIn")
if (isLoggedIn) {
tryToRestoreLatestSession( tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it) }, onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() } onFailure = { switchToNotLoggedInFlow() }
) )
} else { } else {
@ -120,19 +111,8 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
private fun switchToLoggedInFlow(sessionId: SessionId) { backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
}
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
} }
private fun switchToNotLoggedInFlow() { private fun switchToNotLoggedInFlow() {
@ -145,14 +125,8 @@ class RootFlowNode @AssistedInject constructor(
onFailure: () -> Unit = {}, onFailure: () -> Unit = {},
onSuccess: (SessionId) -> Unit = {}, onSuccess: (SessionId) -> Unit = {},
) { ) {
// If the session is already known it'll be restored by the node hierarchy matrixClientsHolder.getOrRestore(sessionId)
if (matrixClientsHolder.knowSession(sessionId)) { .onSuccess {
Timber.v("Session $sessionId already alive, no need to restore.")
return
}
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
Timber.v("Succeed to restore session $sessionId") Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId) onSuccess(sessionId)
} }
@ -204,7 +178,7 @@ class RootFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class LoggedInFlow( data class LoggedInFlow(
val sessionId: SessionId, val sessionId: SessionId,
val navId: UUID = UUID.randomUUID(), val navId: Int
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -278,11 +252,5 @@ class RootFlowNode @AssistedInject constructor(
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId 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.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap 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.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.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey" 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>() private val sessionIdsToMatrixClient = ConcurrentHashMap<SessionId, MatrixClient>()
private val restoreMutex = Mutex()
fun add(matrixClient: MatrixClient) {
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
fun removeAll() { fun removeAll() {
sessionIdsToMatrixClient.clear() sessionIdsToMatrixClient.clear()
@ -44,16 +49,21 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
sessionIdsToMatrixClient.remove(sessionId) sessionIdsToMatrixClient.remove(sessionId)
} }
fun isEmpty(): Boolean = sessionIdsToMatrixClient.isEmpty()
fun knowSession(sessionId: SessionId): Boolean = sessionIdsToMatrixClient.containsKey(sessionId)
fun getOrNull(sessionId: SessionId): MatrixClient? { fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId] 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") @Suppress("UNCHECKED_CAST")
fun restore(state: SavedStateMap?) { fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state") Timber.d("Restore state")
if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also { if (state == null || sessionIdsToMatrixClient.isNotEmpty()) return Unit.also {
Timber.w("Restore with non-empty map") 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. // 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 { runBlocking {
sessionIds.forEach { sessionId -> sessionIds.forEach { sessionId ->
Timber.d("Restore matrix session: $sessionId") restore(sessionId)
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
add(matrixClient)
}
.onFailure {
Timber.e("Fail to restore session")
}
} }
} }
} }
fun save(state: MutableSavedStateMap) { fun saveIntoSavedState(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray() val sessionKeys = sessionIdsToMatrixClient.keys.toTypedArray()
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}") Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys state[SAVE_INSTANCE_KEY] = sessionKeys
} }
private suspend fun restore(sessionId: SessionId): Result<MatrixClient> {
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
.onFailure {
Timber.e("Fail to restore session")
}
}
} }

View file

@ -32,11 +32,11 @@ import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.NodeLifecycleCallback 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.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
@ -92,9 +92,9 @@ class RoomFlowNode @AssistedInject constructor(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { isLoaded -> .onEach { isLoaded ->
if (isLoaded) { if (isLoaded) {
backstack.safeRoot(NavTarget.Loaded) backstack.newRoot(NavTarget.Loaded)
} else { } else {
backstack.safeRoot(NavTarget.Loading) backstack.newRoot(NavTarget.Loading)
} }
}.launchIn(lifecycleScope) }.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.architecture.childNode
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.Rule
import org.junit.Test import org.junit.Test
@ -82,7 +82,7 @@ class RoomFlowNodeTest {
plugins = plugins, plugins = plugins,
messagesEntryPoint = messagesEntryPoint, messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = NoopAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
roomMembershipObserver = RoomMembershipObserver() roomMembershipObserver = RoomMembershipObserver()
) )

View file

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

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

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

View file

@ -16,12 +16,11 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding 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.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule 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.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.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.designsystem.utils.LogCompositions
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable @Composable
fun AnalyticsOptInView( fun AnalyticsOptInView(
@ -69,6 +71,16 @@ fun AnalyticsOptInView(
) { ) {
LogCompositions(tag = "Analytics", msg = "Root") LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink val eventSink = state.eventSink
fun onTermsAccepted() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
}
fun onTermsDeclined() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
}
BackHandler(onBack = ::onTermsDeclined)
HeaderFooterPage( HeaderFooterPage(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@ -76,7 +88,13 @@ fun AnalyticsOptInView(
.imePadding(), .imePadding(),
header = { AnalyticsOptInHeader(state, onClickTerms) }, header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() }, content = { AnalyticsOptInContent() },
footer = { AnalyticsOptInFooter(eventSink) }) footer = {
AnalyticsOptInFooter(
onTermsAccepted = ::onTermsAccepted,
onTermsDeclined = ::onTermsDeclined,
)
}
)
} }
@Composable @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 @Composable
private fun AnalyticsOptInContent( private fun AnalyticsOptInContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -125,80 +156,45 @@ private fun AnalyticsOptInContent(
verticalBias = -0.4f verticalBias = -0.4f
) )
) { ) {
Column( InfoListOrganism(
verticalArrangement = Arrangement.spacedBy(4.dp) items = persistentListOf(
) { InfoListItem(
AnalyticsOptInContentRow( message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
text = stringResource(id = R.string.screen_analytics_prompt_data_usage), iconComposable = { CheckIcon() },
idx = 0 ),
) InfoListItem(
AnalyticsOptInContentRow( message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing), iconComposable = { CheckIcon() },
idx = 1 ),
) InfoListItem(
AnalyticsOptInContentRow( message = stringResource(id = R.string.screen_analytics_prompt_settings),
text = stringResource(id = R.string.screen_analytics_prompt_settings), iconComposable = { CheckIcon() },
idx = 2 ),
) ),
} textStyle = ElementTheme.typography.fontBodyMdMedium,
} iconTint = ElementTheme.colors.textPrimary,
} backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
@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,
) )
} }
} }
@Composable @Composable
private fun AnalyticsOptInFooter( private fun AnalyticsOptInFooter(
eventSink: (AnalyticsOptInEvents) -> Unit, onTermsAccepted: () -> Unit,
onTermsDeclined: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ButtonColumnMolecule( ButtonColumnMolecule(
modifier = modifier, modifier = modifier,
) { ) {
Button( Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) }, onClick = onTermsAccepted,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(text = stringResource(id = CommonStrings.action_ok)) Text(text = stringResource(id = CommonStrings.action_ok))
} }
TextButton( TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) }, onClick = onTermsDeclined,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(text = stringResource(id = CommonStrings.action_not_now)) Text(text = stringResource(id = CommonStrings.action_not_now))

View file

@ -67,4 +67,8 @@ class FakeAnalyticsService(
override fun trackError(throwable: Throwable) { override fun trackError(throwable: Throwable) {
} }
override suspend fun reset() {
didAskUserConsentFlow.value = false
}
} }

View file

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

View file

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

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.api.state
import kotlinx.coroutines.flow.StateFlow
interface FtueState {
val shouldDisplayFlow: StateFlow<Boolean>
suspend fun reset()
}

View file

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

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : FtueEntryPoint.NodeBuilder {
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,154 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
) : BackstackNode<FtueFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Placeholder : NavTarget
@Parcelize
object WelcomeScreen : NavTarget
@Parcelize
object AnalyticsOptIn : NavTarget
}
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(onCreate = {
lifecycleScope.launch { moveToNextStep() }
})
analyticsService.didAskUserConsent()
.drop(1) // We only care about consent passing from not asked to asked state
.onEach { didAskUserConsent ->
if (didAskUserConsent) {
lifecycleScope.launch { moveToNextStep() }
}
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
ftueState.setWelcomeScreenShown()
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
}
}
private suspend fun moveToNextStep() {
when (ftueState.getNextStep()) {
is FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
is FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
null -> callback?.onFtueFlowFinished()
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
@ContributesNode(AppScope::class)
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
}
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
override fun onBackPressed() {
// No-op
}
}

View file

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

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.welcome
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class WelcomeNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val buildMeta: BuildMeta,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinueClicked()
}
private fun onContinueClicked() {
plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() }
}
@Composable
override fun View(modifier: Modifier) {
WelcomeView(
applicationName = buildMeta.applicationName,
onContinueClicked = ::onContinueClicked,
modifier = modifier
)
}
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.welcome
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddComment
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun WelcomeView(
applicationName: String,
modifier: Modifier = Modifier,
onContinueClicked: () -> Unit,
) {
BackHandler(onBack = onContinueClicked)
OnBoardingPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(78.dp))
ElementLogoAtom(size = ElementLogoAtomSize.Medium)
Spacer(modifier = Modifier.height(32.dp))
Text(
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
text = stringResource(R.string.screen_welcome_title, applicationName),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_welcome_subtitle),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
InfoListOrganism(
items = listItems(),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.iconSecondary,
backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(32.dp))
}
},
footer = {
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
Text(text = stringResource(CommonStrings.action_continue))
}
Spacer(modifier = Modifier.height(32.dp))
}
)
}
@Composable
private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_1),
iconVector = Icons.Outlined.NewReleases,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2),
iconVector = Icons.Outlined.Lock,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_3),
iconVector = Icons.Outlined.AddComment,
),
)
@DayNightPreviews
@Composable
internal fun WelcomeViewPreview() {
ElementPreview {
WelcomeView(applicationName = "Element X", onContinueClicked = {})
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.welcome.state
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidWelcomeScreenState @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
) : WelcomeScreenState {
companion object {
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
}
override fun isWelcomeScreenNeeded(): Boolean {
return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
}
override fun setWelcomeScreenShown() {
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
}
override fun reset() {
sharedPreferences.edit {
remove(IS_WELCOME_SCREEN_SHOWN)
}
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.welcome.state
interface WelcomeScreenState {
fun isWelcomeScreenNeeded(): Boolean
fun setWelcomeScreenShown()
fun reset()
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string>
<string name="screen_welcome_subtitle">"Heres what you need to know:"</string>
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
</resources>

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFtueStateTests {
@Test
fun `given any check being false, should display flow is true`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope)
assertThat(state.shouldDisplayFlow.value).isTrue()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
val steps = mutableListOf<FtueStep?>()
// First step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Second step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
// Final step (null)
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.WelcomeScreen,
FtueStep.AnalyticsOptIn,
null, // Final state
)
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
state.setWelcomeScreenShown()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
private fun createState(
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService()
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.welcome.state
class FakeWelcomeState : WelcomeScreenState {
private var isWelcomeScreenNeeded = true
override fun isWelcomeScreenNeeded(): Boolean {
return isWelcomeScreenNeeded
}
override fun setWelcomeScreenShown() {
isWelcomeScreenNeeded = false
}
override fun reset() {
isWelcomeScreenNeeded = true
}
}

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.textcomposer.MessageComposerMode
import javax.inject.Inject
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class MessageComposerContextImpl @Inject constructor() : MessageComposerContext {
override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal(""))
internal set
}

View file

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

View file

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

View file

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

View file

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

View file

@ -22,21 +22,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter 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.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.ui.room.canSendEventAsState import io.element.android.libraries.matrix.ui.room.canSendEventAsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
private const val backPaginationEventLimit = 20 private const val backPaginationEventLimit = 20
@ -45,42 +48,52 @@ private const val backPaginationPageSize = 50
class TimelinePresenter @Inject constructor( class TimelinePresenter @Inject constructor(
private val timelineItemsFactory: TimelineItemsFactory, private val timelineItemsFactory: TimelineItemsFactory,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val timeline = room.timeline private val timeline = room.timeline
@Composable @Composable
override fun present(): TimelineState { override fun present(): TimelineState {
val localCoroutineScope = rememberCoroutineScope() val localScope = rememberCoroutineScope()
val highlightedEventId: MutableState<EventId?> = rememberSaveable { val highlightedEventId: MutableState<EventId?> = rememberSaveable {
mutableStateOf(null) mutableStateOf(null)
} }
var lastReadMarkerIndex by rememberSaveable { mutableStateOf(Int.MAX_VALUE) } val lastReadReceiptIndex = rememberSaveable { mutableStateOf(Int.MAX_VALUE) }
var lastReadMarkerId by rememberSaveable { mutableStateOf<EventId?>(null) } val lastReadReceiptId = rememberSaveable { mutableStateOf<EventId?>(null) }
val timelineItems by timelineItemsFactory.collectItemsAsState() val timelineItems by timelineItemsFactory.collectItemsAsState()
val paginationState by timeline.paginationState.collectAsState() val paginationState by timeline.paginationState.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendEventAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value) 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) { fun handleEvents(event: TimelineEvents) {
when (event) { when (event) {
TimelineEvents.LoadMore -> localCoroutineScope.paginateBackwards() TimelineEvents.LoadMore -> localScope.paginateBackwards()
is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId is TimelineEvents.SetHighlightedEvent -> highlightedEventId.value = event.eventId
is TimelineEvents.OnScrollFinished -> { is TimelineEvents.OnScrollFinished -> {
// Get last valid EventId seen by the user, as the first index might refer to a Virtual item if (event.firstIndex == 0) {
val eventId = getLastEventIdBeforeOrAt(event.firstIndex, timelineItems) ?: return hasNewItems.value = false
if (event.firstIndex <= lastReadMarkerIndex && eventId != lastReadMarkerId) {
lastReadMarkerIndex = event.firstIndex
lastReadMarkerId = eventId
localCoroutineScope.sendReadReceipt(eventId)
} }
appScope.sendReadReceiptIfNeeded(
firstVisibleIndex = event.firstIndex,
timelineItems = timelineItems,
lastReadReceiptIndex = lastReadReceiptIndex,
lastReadReceiptId = lastReadReceiptId
)
} }
} }
} }
LaunchedEffect(timelineItems.size) {
computeHasNewItems(timelineItems, prevMostRecentItemId, hasNewItems)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
timeline timeline
.timelineItems .timelineItems
@ -98,10 +111,49 @@ class TimelinePresenter @Inject constructor(
canReply = userHasPermissionToSendMessage, canReply = userHasPermissionToSendMessage,
paginationState = paginationState, paginationState = paginationState,
timelineItems = timelineItems, timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
eventSink = ::handleEvents 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? { private fun getLastEventIdBeforeOrAt(index: Int, items: ImmutableList<TimelineItem>): EventId? {
for (item in items.subList(index, items.count())) { for (item in items.subList(index, items.count())) {
if (item is TimelineItem.Event) { if (item is TimelineItem.Event) {
@ -114,8 +166,4 @@ class TimelinePresenter @Inject constructor(
private fun CoroutineScope.paginateBackwards() = launch { private fun CoroutineScope.paginateBackwards() = launch {
timeline.paginateBackwards(backPaginationEventLimit, backPaginationPageSize) 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 highlightedEventId: EventId?,
val canReply: Boolean, val canReply: Boolean,
val paginationState: MatrixTimeline.PaginationState, val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
val eventSink: (TimelineEvents) -> Unit val eventSink: (TimelineEvents) -> Unit
) )

View file

@ -27,11 +27,12 @@ import io.element.android.features.messages.impl.timeline.model.virtual.aTimelin
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline 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.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.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -44,7 +45,8 @@ fun aTimelineState(timelineItems: ImmutableList<TimelineItem> = persistentListOf
paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true), paginationState = MatrixTimeline.PaginationState(isBackPaginating = false, hasMoreToLoadBackwards = true),
highlightedEventId = null, highlightedEventId = null,
canReply = true, canReply = true,
eventSink = {} hasNewItems = false,
eventSink = {},
) )
internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> { internal fun aTimelineItemList(content: TimelineItemEventContent): ImmutableList<TimelineItem> {
@ -102,7 +104,7 @@ fun aTimelineItemDaySeparator(): TimelineItem.Virtual {
internal fun aTimelineItemEvent( internal fun aTimelineItemEvent(
eventId: EventId = EventId("\$" + Random.nextInt().toString()), eventId: EventId = EventId("\$" + Random.nextInt().toString()),
transactionId: String? = null, transactionId: TransactionId? = null,
isMine: Boolean = false, isMine: Boolean = false,
content: TimelineItemEventContent = aTimelineItemTextContent(), content: TimelineItemEventContent = aTimelineItemTextContent(),
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None, groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.None,
@ -126,6 +128,7 @@ internal fun aTimelineItemEvent(
localSendState = sendState, localSendState = sendState,
inReplyTo = inReplyTo, inReplyTo = inReplyTo,
debugInfo = debugInfo, debugInfo = debugInfo,
origin = null
) )
} }
@ -152,13 +155,14 @@ internal fun aTimelineItemDebugInfo(
model, originalJson, latestEditedJson model, originalJson, latestEditedJson
) )
fun aGroupedEvents(): TimelineItem.GroupedEvents { fun aGroupedEvents(id: Long = 0): TimelineItem.GroupedEvents {
val event = aTimelineItemEvent( val event = aTimelineItemEvent(
isMine = true, isMine = true,
content = aTimelineItemStateEventContent(), content = aTimelineItemStateEventContent(),
groupPosition = TimelineItemGroupPosition.None groupPosition = TimelineItemGroupPosition.None
) )
return TimelineItem.GroupedEvents( return TimelineItem.GroupedEvents(
id = id.toString(),
events = listOf( events = listOf(
event, event,
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.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -48,7 +49,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -71,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
@ -100,13 +99,6 @@ fun TimelineView(
// TODO implement this logic once we have support to 'jump to event X' in sliding sync // 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) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
@ -147,8 +139,8 @@ fun TimelineView(
TimelineScrollHelper( TimelineScrollHelper(
lazyListState = lazyListState, lazyListState = lazyListState,
timelineItems = state.timelineItems, hasNewItems = state.hasNewItems,
onScrollFinishedAt = ::onScrollFinishedAt, onScrollFinishedAt = ::onScrollFinishedAt
) )
} }
} }
@ -244,63 +236,66 @@ fun TimelineItemRow(
} }
@Composable @Composable
internal fun BoxScope.TimelineScrollHelper( private fun BoxScope.TimelineScrollHelper(
lazyListState: LazyListState, lazyListState: LazyListState,
timelineItems: ImmutableList<TimelineItem>, hasNewItems: Boolean,
onScrollFinishedAt: (Int) -> Unit = {}, onScrollFinishedAt: (Int) -> Unit,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val firstVisibleItemIndex by remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }
val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } } val isScrollFinished by remember { derivedStateOf { !lazyListState.isScrollInProgress } }
val shouldAutoScrollToBottom by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 2 } } val canAutoScroll by remember { derivedStateOf { lazyListState.firstVisibleItemIndex < 3 } }
val showScrollToBottomButton by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } }
LaunchedEffect(timelineItems, firstVisibleItemIndex) { LaunchedEffect(canAutoScroll, hasNewItems) {
if (!isScrollFinished) return@LaunchedEffect val shouldAutoScroll = isScrollFinished && canAutoScroll && hasNewItems
if (shouldAutoScroll) {
// Auto-scroll when new timeline items appear
if (shouldAutoScrollToBottom) {
coroutineScope.launch { coroutineScope.launch {
lazyListState.animateScrollToItem(0) lazyListState.animateScrollToItem(0)
} }
} }
} }
LaunchedEffect(isScrollFinished) {
if (!isScrollFinished) return@LaunchedEffect
// Notify the parent composable about the first visible item index when scrolling finishes LaunchedEffect(isScrollFinished) {
onScrollFinishedAt(firstVisibleItemIndex) 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) JumpToBottomButton(
AnimatedVisibility( // Use inverse of canAutoScroll otherwise we might briefly see the before the scroll animation is triggered
isVisible = !canAutoScroll,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 12.dp), .padding(end = 24.dp, bottom = 12.dp),
visible = showScrollToBottomButton || LocalInspectionMode.current, onClick = {
enter = scaleIn(), coroutineScope.launch {
exit = scaleOut(), 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( FloatingActionButton(
onClick = { onClick = onClick,
coroutineScope.launch {
if (firstVisibleItemIndex > 10) {
lazyListState.scrollToItem(0)
} else {
lazyListState.animateScrollToItem(0)
}
}
},
elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp), elevation = FloatingActionButtonDefaults.elevation(4.dp, 4.dp, 4.dp, 4.dp),
shape = CircleShape, shape = CircleShape,
modifier = Modifier modifier = Modifier.size(36.dp),
.shadow(
elevation = 4.dp,
shape = CircleShape,
ambientColor = ElementTheme.materialColors.primary,
spotColor = ElementTheme.materialColors.primary,
)
.size(36.dp),
containerColor = ElementTheme.colors.bgSubtleSecondary, containerColor = ElementTheme.colors.bgSubtleSecondary,
contentColor = ElementTheme.colors.iconSecondary contentColor = ElementTheme.colors.iconSecondary
) { ) {

View file

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

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.virtual
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
.clip(MaterialTheme.shapes.small)
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
.background(ElementTheme.colors.bgInfoSubtle)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
@DayNightPreviews
@Composable
internal fun TimelineEncryptedHistoryBannerViewPreview() {
ElementTheme {
TimelineEncryptedHistoryBannerView()
}
}

View file

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

View file

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

View file

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

View file

@ -16,11 +16,22 @@
package io.element.android.features.messages.impl.timeline.groups 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.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 kotlinx.collections.immutable.toImmutableList
import javax.inject.Inject import javax.inject.Inject
@SingleIn(RoomScope::class)
class TimelineItemGrouper @Inject constructor() { 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]. * 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 // timelineItem cannot be grouped
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
// There is a pending group, create a TimelineItem.GroupedEvents if there is more than 1 Event in the pending group. // 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() currentGroup.clear()
} }
result.add(timelineItem) result.add(timelineItem)
} }
} }
if (currentGroup.isNotEmpty()) { if (currentGroup.isNotEmpty()) {
result.addGroup(currentGroup) result.addGroup(groupIds, currentGroup)
} }
return result 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. * Will add a group if there is more than 1 item, else add the item to the list.
*/ */
private fun MutableList<TimelineItem>.addGroup( 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 // Do not create a group with just 1 item, just add the item to the result
add(group.first()) add(groupOfItems.first())
} else { } else {
val groupId = groupIds.getOrPutGroupId(groupOfItems)
add( add(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
events = group.toImmutableList() id = groupId,
events = groupOfItems.toImmutableList()
) )
) )
} }
} }
private fun MutableMap<String, String>.getOrPutGroupId(timelineItems: List<TimelineItem>): String {
assert(timelineItems.isNotEmpty())
for (item in timelineItems) {
val itemIdentifier = item.identifier()
if (this.contains(itemIdentifier)) {
return this[itemIdentifier]!!
}
}
val timelineItem = timelineItems.first()
return computeGroupIdWith(timelineItem).also { groupId ->
this[timelineItem.identifier()] = groupId
}
}
@VisibleForTesting
internal fun computeGroupIdWith(timelineItem: TimelineItem): String = "${timelineItem.identifier()}_group"

View file

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

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.model.virtual
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,22 +23,25 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.fixtures.aTimelineItemsFactory
import io.element.android.features.messages.impl.timeline.TimelineEvents 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.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.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem 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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class TimelinePresenterTest { class TimelinePresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -51,10 +54,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - load more`() = runTest { fun `present - load more`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -73,10 +73,7 @@ class TimelinePresenterTest {
@Test @Test
fun `present - set highlighted event`() = runTest { fun `present - set highlighted event`() = runTest {
val presenter = TimelinePresenter( val presenter = createTimelinePresenter()
timelineItemsFactory = aTimelineItemsFactory(),
room = FakeMatrixRoom(),
)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -94,70 +91,112 @@ class TimelinePresenterTest {
@Test @Test
fun `present - on scroll finished send read receipt if an event is before the index`() = runTest { fun `present - on scroll finished send read receipt if an event is before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) MatrixTimelineItem.Event(0, anEventTimelineItem())
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(1) assertThat(timeline.sendReadReceiptCount).isEqualTo(1)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest { fun `present - on scroll finished will not send read receipt no event is before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Event(0, anEventTimelineItem()))) MatrixTimelineItem.Event(0, anEventTimelineItem())
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
} }
} }
@Test @Test
fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest { fun `present - on scroll finished will not send read receipt only virtual events exist before the index`() = runTest {
val timeline = FakeMatrixTimeline() val timeline = FakeMatrixTimeline(
val timelineItemsFactory = aTimelineItemsFactory().apply { initialTimelineItems = listOf(
replaceWith(listOf(MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker))) MatrixTimelineItem.Virtual(0, VirtualTimelineItem.ReadMarker)
} )
val room = FakeMatrixRoom(matrixTimeline = timeline)
val presenter = TimelinePresenter(
timelineItemsFactory = timelineItemsFactory,
room = room,
) )
val presenter = createTimelinePresenter(timeline)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
assertThat(timeline.sendReadReceiptCount).isEqualTo(0) assertThat(timeline.sendReadReceiptCount).isEqualTo(0)
val initialState = awaitItem() val initialState = awaitItem()
// Wait for timeline items to be populated
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) skipItems(1)
awaitWithLatch { latch ->
timeline.sendReadReceiptLatch = latch
initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0))
}
assertThat(timeline.sendReadReceiptCount).isEqualTo(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.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions 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.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.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateEventContent 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.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
import io.element.android.libraries.designsystem.components.avatar.anAvatarData 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.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
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.A_USER_ID
import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo import io.element.android.libraries.matrix.test.room.aTimelineItemDebugInfo
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -36,7 +36,7 @@ class TimelineItemGrouperTest {
private val sut = TimelineItemGrouper() private val sut = TimelineItemGrouper()
private val aGroupableItem = TimelineItem.Event( private val aGroupableItem = TimelineItem.Event(
id = AN_EVENT_ID.value, id = "0",
senderId = A_USER_ID, senderId = A_USER_ID,
senderAvatar = anAvatarData(), senderAvatar = anAvatarData(),
senderDisplayName = "", senderDisplayName = "",
@ -45,6 +45,7 @@ class TimelineItemGrouperTest {
localSendState = LocalEventSendState.Sent(AN_EVENT_ID), localSendState = LocalEventSendState.Sent(AN_EVENT_ID),
inReplyTo = null, inReplyTo = null,
debugInfo = aTimelineItemDebugInfo(), debugInfo = aTimelineItemDebugInfo(),
origin = null
) )
private val aNonGroupableItem = aMessageEvent() private val aNonGroupableItem = aMessageEvent()
private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today")) private val aNonGroupableItemNoEvent = TimelineItem.Virtual("virtual", aTimelineItemDaySeparatorModel("Today"))
@ -75,16 +76,17 @@ class TimelineItemGrouperTest {
fun `test groupables and ensure reordering`() { fun `test groupables and ensure reordering`() {
val result = sut.group( val result = sut.group(
listOf( listOf(
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = "1"),
aGroupableItem, aGroupableItem.copy(id = "0"),
), ),
) )
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem.copy("0"),
aGroupableItem.copy(id = AN_EVENT_ID_2.value), aGroupableItem.copy(id = "1"),
).toImmutableList() ).toImmutableList()
), ),
) )
@ -127,6 +129,7 @@ class TimelineItemGrouperTest {
assertThat(result).isEqualTo( assertThat(result).isEqualTo(
listOf( listOf(
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
@ -134,6 +137,7 @@ class TimelineItemGrouperTest {
), ),
aNonGroupableItem, aNonGroupableItem,
TimelineItem.GroupedEvents( TimelineItem.GroupedEvents(
computeGroupIdWith(aGroupableItem),
events = listOf( events = listOf(
aGroupableItem, aGroupableItem,
aGroupableItem, aGroupableItem,
@ -143,4 +147,20 @@ class TimelineItemGrouperTest {
) )
) )
} }
@Test
fun `when calling multiple time the method group over a growing list of groupable items, then groupId is stable`() {
// When
val groupableItems = mutableListOf(
aGroupableItem.copy(id = "1"),
aGroupableItem.copy(id = "2")
)
val expectedGroupId = sut.group(groupableItems).first().identifier()
groupableItems.add(0, aGroupableItem.copy("3"))
groupableItems.add(2, aGroupableItem.copy("4"))
groupableItems.add(aGroupableItem.copy("5"))
val actualGroupId = sut.group(groupableItems).first().identifier()
// Then
assertThat(actualGroupId).isEqualTo(expectedGroupId)
}
} }

View file

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

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.test
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.textcomposer.MessageComposerMode
class MessageComposerContextFake(
override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
) : MessageComposerContext

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,173 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.atoms
import android.graphics.BlurMaskFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.theme.ElementTheme
@Composable
fun ElementLogoAtom(
size: ElementLogoAtomSize,
modifier: Modifier = Modifier,
) {
val outerSize = when (size) {
ElementLogoAtomSize.Large -> 158.dp
ElementLogoAtomSize.Medium -> 120.dp
}
val logoSize = when (size) {
ElementLogoAtomSize.Large -> 110.dp
ElementLogoAtomSize.Medium -> 83.5.dp
}
val cornerRadius = when (size) {
ElementLogoAtomSize.Large -> 44.dp
ElementLogoAtomSize.Medium -> 33.dp
}
val borderWidth = when (size) {
ElementLogoAtomSize.Large -> 1.dp
ElementLogoAtomSize.Medium -> 0.38.dp
}
val blur = if (isSystemInDarkTheme()) {
160.dp
} else {
24.dp
}
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (isSystemInDarkTheme()) {
Color.Black.copy(alpha = 0.4f)
} else {
Color(0x401B1D22)
}
val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
Box(
modifier = modifier
.size(outerSize)
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(outerSize)
.shapeShadow(
color = shadowColor,
cornerRadius = cornerRadius,
blurRadius = 32.dp,
offsetY = 8.dp,
)
)
Box(
Modifier
.clip(RoundedCornerShape(cornerRadius))
.size(outerSize)
.background(backgroundColor)
.blur(blur)
)
Image(
modifier = Modifier.size(logoSize),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
}
}
enum class ElementLogoAtomSize {
Medium,
Large
}
@Composable
@DayNightPreviews
internal fun ElementLogoAtomPreview() {
ElementPreview {
Box(
Modifier
.size(180.dp)
.background(ElementTheme.colors.bgSubtlePrimary),
contentAlignment = Alignment.Center
) {
ElementLogoAtom(ElementLogoAtomSize.Large)
}
}
}
fun Modifier.shapeShadow(
color: Color = Color.Black,
cornerRadius: Dp = 0.dp,
offsetX: Dp = 0.dp,
offsetY: Dp = 0.dp,
blurRadius: Dp = 0.dp,
) = then(
drawBehind {
drawIntoCanvas { canvas ->
val path = Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
}
clipPath(path, ClipOp.Difference) {
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
if (blurRadius != 0.dp) {
frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL))
}
frameworkPaint.color = color.toArgb()
val leftPixel = offsetX.toPx()
val topPixel = offsetY.toPx()
val rightPixel = size.width + topPixel
val bottomPixel = size.height + leftPixel
canvas.drawRect(
left = leftPixel,
top = topPixel,
right = rightPixel,
bottom = bottomPixel,
paint = paint,
)
}
}
}
)

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun InfoListItemMolecule(
message: @Composable () -> Unit,
position: InfoListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
) {
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
InfoListItemPosition.Single -> RoundedCornerShape(radius)
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = backgroundColor,
shape = backgroundShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
icon()
message()
}
}
@DayNightPreviews
@Composable
fun InfoListItemMoleculePreview() {
ElementPreview {
val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoListItemMolecule(
message = { Text("A single item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Single,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A top item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Top,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A middle item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Middle,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A bottom item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
enum class InfoListItemPosition {
Top,
Middle,
Bottom,
Single,
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.atomic.molecules
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
@Composable
fun InfoListOrganism(
items: ImmutableList<InfoListItem>,
backgroundColor: Color,
modifier: Modifier = Modifier,
iconTint: Color = LocalContentColor.current,
textStyle: TextStyle = LocalTextStyle.current,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
for ((index, item) in items.withIndex()) {
val position = when {
items.size == 1 -> InfoListItemPosition.Single
index == 0 -> InfoListItemPosition.Top
index == items.size - 1 -> InfoListItemPosition.Bottom
else -> InfoListItemPosition.Middle
}
InfoListItemMolecule(
message = {
Text(
text = item.message,
style = textStyle,
color = ElementTheme.colors.textPrimary,
)
},
icon = {
if (item.iconId != null) {
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint)
} else if (item.iconVector != null) {
Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint)
} else {
item.iconComposable()
}
},
position = position,
backgroundColor = backgroundColor,
)
}
}
}
data class InfoListItem(
val message: String,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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