Merge branch 'develop' into feature/fga/better_timeline_scroll
This commit is contained in:
commit
92db07ff83
156 changed files with 3806 additions and 539 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
7
.github/workflows/nightlyReports.yml
vendored
7
.github/workflows/nightlyReports.yml
vendored
|
|
@ -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()
|
||||||
|
|
|
||||||
6
.github/workflows/quality.yml
vendored
6
.github/workflows/quality.yml
vendored
|
|
@ -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: |
|
||||||
|
|
|
||||||
14
.github/workflows/tests.yml
vendored
14
.github/workflows/tests.yml
vendored
|
|
@ -40,8 +40,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 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()
|
||||||
|
|
|
||||||
1
.github/workflows/triage-labelled.yml
vendored
1
.github/workflows/triage-labelled.yml
vendored
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
appId: ${APP_ID}
|
||||||
|
---
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "welcome_screen-title"
|
||||||
|
timeout: 10_000
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ 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.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 +259,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 +288,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)) {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
27
features/ftue/api/build.gradle.kts
Normal file
27
features/ftue/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.api.state
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
interface FtueState {
|
||||||
|
val shouldDisplayFlow: StateFlow<Boolean>
|
||||||
|
}
|
||||||
55
features/ftue/impl/build.gradle.kts
Normal file
55
features/ftue/impl/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* 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())
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
/*
|
||||||
|
* 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.fontHeadingLgBold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.screen_welcome_subtitle),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
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 = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
interface WelcomeScreenState {
|
||||||
|
fun isWelcomeScreenNeeded(): Boolean
|
||||||
|
fun setWelcomeScreenShown()
|
||||||
|
}
|
||||||
9
features/ftue/impl/src/main/res/values/localazy.xml
Normal file
9
features/ftue/impl/src/main/res/values/localazy.xml
Normal 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 won’t be available in this update."</string>
|
||||||
|
<string name="screen_welcome_bullet_3">"We’d 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">"Here’s what you need to know:"</string>
|
||||||
|
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
|
||||||
|
</resources>
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.ftue.impl.welcome.state
|
||||||
|
|
||||||
|
class FakeWelcomeState : WelcomeScreenState {
|
||||||
|
|
||||||
|
private var isWelcomeScreenNeeded = true
|
||||||
|
|
||||||
|
override fun isWelcomeScreenNeeded(): Boolean {
|
||||||
|
return isWelcomeScreenNeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWelcomeScreenShown() {
|
||||||
|
isWelcomeScreenNeeded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 l‘invitation à 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 |
|
|
@ -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 d’Element."</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 d’e-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 l’app 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>
|
||||||
|
|
|
||||||
|
|
@ -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">"You’re about to sign in to %s"</string>
|
<string name="screen_account_provider_signin_title">"You’re 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">"You’re about to create an account on %s"</string>
|
<string name="screen_account_provider_signup_title">"You’re 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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ""),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ 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
|
||||||
|
|
@ -103,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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ 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
|
||||||
|
|
@ -54,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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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">"L’historique des messages n’est 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 l’utilisateur"</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 n‘avez 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,6 +569,7 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
27
features/messages/test/build.gradle.kts
Normal file
27
features/messages/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 l’Element 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>
|
||||||
|
|
|
||||||
|
|
@ -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 d’incident"</string>
|
<string name="screen_bug_report_include_crash_logs">"Envoyer des journaux d’incident"</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 l’envoyer 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 s’est produite et les informations n’ont 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 à l’avenir."</string>
|
||||||
<string name="session_verification_banner_title">"Accédez à l\'historique de vos messages"</string>
|
<string name="session_verification_banner_title">"Vérifier que c’est bien vous"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
/*
|
||||||
|
* 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(170.dp)
|
||||||
|
.background(ElementTheme.colors.bgSubtlePrimary))
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.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 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(item.message, style = textStyle) },
|
||||||
|
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 = {},
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -31,9 +31,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import kotlin.time.Duration
|
|
||||||
|
|
||||||
interface MatrixClient : Closeable {
|
interface MatrixClient : Closeable {
|
||||||
val sessionId: SessionId
|
val sessionId: SessionId
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2022 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.api.core
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class TransactionId(val value: String) : Serializable {
|
||||||
|
override fun toString(): String = value
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
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.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
|
|
@ -70,7 +71,7 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun sendMessage(message: String): Result<Unit>
|
suspend fun sendMessage(message: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit>
|
suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit>
|
||||||
|
|
||||||
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
|
suspend fun replyMessage(eventId: EventId, message: String): Result<Unit>
|
||||||
|
|
||||||
|
|
@ -88,9 +89,9 @@ interface MatrixRoom : Closeable {
|
||||||
|
|
||||||
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
|
suspend fun forwardEvent(eventId: EventId, roomIds: List<RoomId>): Result<Unit>
|
||||||
|
|
||||||
suspend fun retrySendMessage(transactionId: String): Result<Unit>
|
suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit>
|
||||||
|
|
||||||
suspend fun cancelSend(transactionId: String): Result<Unit>
|
suspend fun cancelSend(transactionId: TransactionId): Result<Unit>
|
||||||
|
|
||||||
suspend fun leave(): Result<Unit>
|
suspend fun leave(): Result<Unit>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,14 @@
|
||||||
package io.element.android.libraries.matrix.api.timeline
|
package io.element.android.libraries.matrix.api.timeline
|
||||||
|
|
||||||
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.timeline.item.event.EventTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
|
|
||||||
sealed interface MatrixTimelineItem {
|
sealed interface MatrixTimelineItem {
|
||||||
data class Event(val uniqueId: Long, val event: EventTimelineItem) : MatrixTimelineItem {
|
data class Event(val uniqueId: Long, val event: EventTimelineItem) : MatrixTimelineItem {
|
||||||
val eventId: EventId? = event.eventId
|
val eventId: EventId? = event.eventId
|
||||||
val transactionId: String? = event.transactionId
|
val transactionId: TransactionId? = event.transactionId
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Virtual(val uniqueId: Long, val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
data class Virtual(val uniqueId: Long, val virtual: VirtualTimelineItem) : MatrixTimelineItem
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,13 @@
|
||||||
package io.element.android.libraries.matrix.api.timeline.item.event
|
package io.element.android.libraries.matrix.api.timeline.item.event
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
data class EventTimelineItem(
|
data class EventTimelineItem(
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
val transactionId: String?,
|
val transactionId: TransactionId?,
|
||||||
val isEditable: Boolean,
|
val isEditable: Boolean,
|
||||||
val isLocal: Boolean,
|
val isLocal: Boolean,
|
||||||
val isOwn: Boolean,
|
val isOwn: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem {
|
||||||
|
|
||||||
object ReadMarker : VirtualTimelineItem
|
object ReadMarker : VirtualTimelineItem
|
||||||
|
|
||||||
|
object EncryptedHistoryBanner : VirtualTimelineItem
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,4 +41,8 @@ dependencies {
|
||||||
implementation("net.java.dev.jna:jna:5.13.0@aar")
|
implementation("net.java.dev.jna:jna:5.13.0@aar")
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ class RustMatrixClient constructor(
|
||||||
coroutineDispatchers = dispatchers,
|
coroutineDispatchers = dispatchers,
|
||||||
systemClock = clock,
|
systemClock = clock,
|
||||||
roomContentForwarder = roomContentForwarder,
|
roomContentForwarder = roomContentForwarder,
|
||||||
|
sessionData = sessionStore.getSession(sessionId.value)!!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||||
import org.matrix.rustcomponents.sdk.Session
|
import org.matrix.rustcomponents.sdk.Session
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
|
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
|
||||||
|
|
||||||
|
|
@ -208,4 +209,5 @@ private fun Session.toSessionData() = SessionData(
|
||||||
refreshToken = refreshToken,
|
refreshToken = refreshToken,
|
||||||
homeserverUrl = homeserverUrl,
|
homeserverUrl = homeserverUrl,
|
||||||
slidingSyncProxy = slidingSyncProxy,
|
slidingSyncProxy = slidingSyncProxy,
|
||||||
|
loginTimestamp = Date(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
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.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
|
|
@ -41,6 +42,8 @@ import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||||
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
|
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
|
||||||
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
|
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -72,6 +75,7 @@ class RustMatrixRoom(
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val systemClock: SystemClock,
|
private val systemClock: SystemClock,
|
||||||
private val roomContentForwarder: RoomContentForwarder,
|
private val roomContentForwarder: RoomContentForwarder,
|
||||||
|
private val sessionData: SessionData,
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
override val roomId = RoomId(innerRoom.id())
|
override val roomId = RoomId(innerRoom.id())
|
||||||
|
|
@ -90,7 +94,8 @@ class RustMatrixRoom(
|
||||||
matrixRoom = this,
|
matrixRoom = this,
|
||||||
innerRoom = innerRoom,
|
innerRoom = innerRoom,
|
||||||
roomCoroutineScope = roomCoroutineScope,
|
roomCoroutineScope = roomCoroutineScope,
|
||||||
dispatcher = roomDispatcher
|
dispatcher = roomDispatcher,
|
||||||
|
lastLoginTimestamp = sessionData.loginTimestamp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,10 +223,10 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> = withContext(roomDispatcher) {
|
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> = withContext(roomDispatcher) {
|
||||||
if (originalEventId != null) {
|
if (originalEventId != null) {
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId)
|
innerRoom.edit(/* TODO use content */ message, originalEventId.value, transactionId?.value)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|
@ -326,17 +331,17 @@ class RustMatrixRoom(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> =
|
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> =
|
||||||
withContext(roomDispatcher) {
|
withContext(roomDispatcher) {
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.retrySend(transactionId)
|
innerRoom.retrySend(transactionId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cancelSend(transactionId: String): Result<Unit> =
|
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> =
|
||||||
withContext(roomDispatcher) {
|
withContext(roomDispatcher) {
|
||||||
runCatching {
|
runCatching {
|
||||||
innerRoom.cancelSend(transactionId)
|
innerRoom.cancelSend(transactionId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,23 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
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.MatrixTimelineItem
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.getAndUpdate
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.sample
|
import kotlinx.coroutines.flow.sample
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.BackPaginationStatus
|
import org.matrix.rustcomponents.sdk.BackPaginationStatus
|
||||||
|
|
@ -43,6 +47,7 @@ import org.matrix.rustcomponents.sdk.TimelineDiff
|
||||||
import org.matrix.rustcomponents.sdk.TimelineItem
|
import org.matrix.rustcomponents.sdk.TimelineItem
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
private const val INITIAL_MAX_SIZE = 50
|
private const val INITIAL_MAX_SIZE = 50
|
||||||
|
|
||||||
|
|
@ -51,6 +56,7 @@ class RustMatrixTimeline(
|
||||||
private val matrixRoom: MatrixRoom,
|
private val matrixRoom: MatrixRoom,
|
||||||
private val innerRoom: Room,
|
private val innerRoom: Room,
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val lastLoginTimestamp: Date?,
|
||||||
) : MatrixTimeline {
|
) : MatrixTimeline {
|
||||||
|
|
||||||
private val initLatch = CompletableDeferred<Unit>()
|
private val initLatch = CompletableDeferred<Unit>()
|
||||||
|
|
@ -63,6 +69,12 @@ class RustMatrixTimeline(
|
||||||
MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)
|
MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
|
||||||
|
lastLoginTimestamp = lastLoginTimestamp,
|
||||||
|
isRoomEncrypted = matrixRoom.isEncrypted,
|
||||||
|
paginationStateFlow = _paginationState,
|
||||||
|
)
|
||||||
|
|
||||||
private val timelineItemFactory = MatrixTimelineItemMapper(
|
private val timelineItemFactory = MatrixTimelineItemMapper(
|
||||||
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
fetchDetailsForEvent = this::fetchDetailsForEvent,
|
||||||
roomCoroutineScope = roomCoroutineScope,
|
roomCoroutineScope = roomCoroutineScope,
|
||||||
|
|
@ -81,8 +93,11 @@ class RustMatrixTimeline(
|
||||||
|
|
||||||
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
|
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
|
||||||
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.sample(50)
|
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.sample(50)
|
||||||
|
.mapLatest { items ->
|
||||||
|
encryptedHistoryPostProcessor.process(items)
|
||||||
|
}
|
||||||
|
|
||||||
internal suspend fun postItems(items: List<TimelineItem>) {
|
internal suspend fun postItems(items: List<TimelineItem>) {
|
||||||
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
|
||||||
|
|
@ -100,6 +115,12 @@ class RustMatrixTimeline(
|
||||||
|
|
||||||
internal fun postPaginationStatus(status: BackPaginationStatus) {
|
internal fun postPaginationStatus(status: BackPaginationStatus) {
|
||||||
_paginationState.getAndUpdate { currentPaginationState ->
|
_paginationState.getAndUpdate { currentPaginationState ->
|
||||||
|
if (hasEncryptionHistoryBanner()) {
|
||||||
|
return@getAndUpdate currentPaginationState.copy(
|
||||||
|
isBackPaginating = false,
|
||||||
|
hasMoreToLoadBackwards = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
when (status) {
|
when (status) {
|
||||||
BackPaginationStatus.IDLE -> {
|
BackPaginationStatus.IDLE -> {
|
||||||
currentPaginationState.copy(
|
currentPaginationState.copy(
|
||||||
|
|
@ -159,4 +180,10 @@ class RustMatrixTimeline(
|
||||||
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
|
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
|
||||||
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
|
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasEncryptionHistoryBanner(): Boolean {
|
||||||
|
val firstItem = _timelineItems.value.firstOrNull()
|
||||||
|
return firstItem is MatrixTimelineItem.Virtual
|
||||||
|
&& firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||||
|
|
||||||
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.TimelineItemEventOrigin
|
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||||
|
|
@ -36,7 +37,7 @@ class EventTimelineItemMapper(private val contentMapper: TimelineEventContentMap
|
||||||
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
|
fun map(eventTimelineItem: RustEventTimelineItem): EventTimelineItem = eventTimelineItem.use {
|
||||||
EventTimelineItem(
|
EventTimelineItem(
|
||||||
eventId = it.eventId()?.let(::EventId),
|
eventId = it.eventId()?.let(::EventId),
|
||||||
transactionId = it.transactionId(),
|
transactionId = it.transactionId()?.let(::TransactionId),
|
||||||
isEditable = it.isEditable(),
|
isEditable = it.isEditable(),
|
||||||
isLocal = it.isLocal(),
|
isLocal = it.isLocal(),
|
||||||
isOwn = it.isOwn(),
|
isOwn = it.isOwn(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2023 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.getAndUpdate
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class TimelineEncryptedHistoryPostProcessor(
|
||||||
|
private val lastLoginTimestamp: Date?,
|
||||||
|
private val isRoomEncrypted: Boolean,
|
||||||
|
private val paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||||
|
if (!isRoomEncrypted || lastLoginTimestamp == null) return items
|
||||||
|
|
||||||
|
val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items)
|
||||||
|
// Disable back pagination
|
||||||
|
val wasFiltered = filteredItems !== items
|
||||||
|
if (wasFiltered) {
|
||||||
|
paginationStateFlow.getAndUpdate {
|
||||||
|
it.copy(
|
||||||
|
isBackPaginating = false,
|
||||||
|
hasMoreToLoadBackwards = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredItems
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||||
|
var lastEncryptedHistoryBannerIndex = -1
|
||||||
|
for ((i, item) in list.withIndex()) {
|
||||||
|
if (isItemEncryptionHistory(item)) {
|
||||||
|
lastEncryptedHistoryBannerIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (lastEncryptedHistoryBannerIndex >= 0) {
|
||||||
|
val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList()
|
||||||
|
sublist.add(0, MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||||
|
sublist
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean {
|
||||||
|
if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false
|
||||||
|
return timestamp <= lastLoginTimestamp!!.time
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class TimelineEncryptedHistoryPostProcessorTest {
|
||||||
|
|
||||||
|
private val defaultLastLoginTimestamp = Date(1689061264L)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given an unencrypted room, nothing is done`() {
|
||||||
|
val processor = createPostProcessor(isRoomEncrypted = false)
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem())
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given a null lastLoginTimestamp, nothing is done`() {
|
||||||
|
val processor = createPostProcessor(lastLoginTimestamp = null)
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem())
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given an empty list, nothing is done`() {
|
||||||
|
val processor = createPostProcessor()
|
||||||
|
val items = emptyList<MatrixTimelineItem>()
|
||||||
|
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given a list with no items before lastLoginTimestamp, nothing is done`() {
|
||||||
|
val processor = createPostProcessor()
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items)).isSameInstanceAs(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() {
|
||||||
|
val processor = createPostProcessor()
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time))
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items))
|
||||||
|
.isEqualTo(listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() {
|
||||||
|
val processor = createPostProcessor()
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1))
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items)).isEqualTo(
|
||||||
|
listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() {
|
||||||
|
val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
|
||||||
|
val processor = createPostProcessor(paginationStateFlow = paginationStateFlow)
|
||||||
|
val items = listOf(
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)),
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)),
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)),
|
||||||
|
)
|
||||||
|
assertThat(processor.process(items)).isEqualTo(
|
||||||
|
listOf(
|
||||||
|
MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner),
|
||||||
|
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPostProcessor(
|
||||||
|
lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
|
||||||
|
isRoomEncrypted: Boolean = true,
|
||||||
|
paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState> =
|
||||||
|
MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
|
||||||
|
) = TimelineEncryptedHistoryPostProcessor(
|
||||||
|
lastLoginTimestamp = lastLoginTimestamp,
|
||||||
|
isRoomEncrypted = isRoomEncrypted,
|
||||||
|
paginationStateFlow = paginationStateFlow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.core.SpaceId
|
import io.element.android.libraries.matrix.api.core.SpaceId
|
||||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||||
|
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 java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@ val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
|
||||||
val A_THREAD_ID = ThreadId("\$aThreadId")
|
val A_THREAD_ID = ThreadId("\$aThreadId")
|
||||||
val AN_EVENT_ID = EventId("\$anEventId")
|
val AN_EVENT_ID = EventId("\$anEventId")
|
||||||
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
val AN_EVENT_ID_2 = EventId("\$anEventId2")
|
||||||
const val A_TRANSACTION_ID = "aTransactionId"
|
val A_TRANSACTION_ID = TransactionId("aTransactionId")
|
||||||
const val A_UNIQUE_ID = "aUniqueId"
|
const val A_UNIQUE_ID = "aUniqueId"
|
||||||
|
|
||||||
const val A_ROOM_NAME = "A room name"
|
const val A_ROOM_NAME = "A room name"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
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.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
|
@ -164,17 +165,17 @@ class FakeMatrixRoom(
|
||||||
return toggleReactionResult
|
return toggleReactionResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun retrySendMessage(transactionId: String): Result<Unit> {
|
override suspend fun retrySendMessage(transactionId: TransactionId): Result<Unit> {
|
||||||
retrySendMessageCount++
|
retrySendMessageCount++
|
||||||
return retrySendMessageResult
|
return retrySendMessageResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cancelSend(transactionId: String): Result<Unit> {
|
override suspend fun cancelSend(transactionId: TransactionId): Result<Unit> {
|
||||||
cancelSendCount++
|
cancelSendCount++
|
||||||
return cancelSendResult
|
return cancelSendResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun editMessage(originalEventId: EventId?, transactionId: String?, message: String): Result<Unit> {
|
override suspend fun editMessage(originalEventId: EventId?, transactionId: TransactionId?, message: String): Result<Unit> {
|
||||||
editMessageCalls += message
|
editMessageCalls += message
|
||||||
return Result.success(Unit)
|
return Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.test.room
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
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.room.RoomSummary
|
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
|
||||||
|
|
@ -91,7 +92,7 @@ fun aRoomMessage(
|
||||||
|
|
||||||
fun anEventTimelineItem(
|
fun anEventTimelineItem(
|
||||||
eventId: EventId = AN_EVENT_ID,
|
eventId: EventId = AN_EVENT_ID,
|
||||||
transactionId: String? = null,
|
transactionId: TransactionId? = null,
|
||||||
isEditable: Boolean = false,
|
isEditable: Boolean = false,
|
||||||
isLocal: Boolean = false,
|
isLocal: Boolean = false,
|
||||||
isOwn: Boolean = false,
|
isOwn: Boolean = false,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ dependencies {
|
||||||
implementation(libs.dagger)
|
implementation(libs.dagger)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.lifecycle.process)
|
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.network.retrofit)
|
implementation(libs.network.retrofit)
|
||||||
implementation(libs.serialization.json)
|
implementation(libs.serialization.json)
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
|
||||||
import io.element.android.libraries.push.api.store.PushDataStore
|
import io.element.android.libraries.push.api.store.PushDataStore
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.services.appnavstate.api.NavigationState
|
||||||
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
|
|
||||||
import io.element.android.services.appnavstate.api.AppNavigationState
|
|
||||||
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.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -65,7 +63,6 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||||
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
|
||||||
*/
|
*/
|
||||||
private val notificationState by lazy { createInitialNotificationState() }
|
private val notificationState by lazy { createInitialNotificationState() }
|
||||||
private var currentAppNavigationState: AppNavigationState? = null
|
|
||||||
private val firstThrottler = FirstThrottler(200)
|
private val firstThrottler = FirstThrottler(200)
|
||||||
|
|
||||||
// TODO EAx add a setting per user for this
|
// TODO EAx add a setting per user for this
|
||||||
|
|
@ -74,26 +71,25 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||||
init {
|
init {
|
||||||
// Observe application state
|
// Observe application state
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
appNavigationStateService.appNavigationStateFlow
|
appNavigationStateService.appNavigationState
|
||||||
.collect { onAppNavigationStateChange(it) }
|
.collect { onAppNavigationStateChange(it.navigationState) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onAppNavigationStateChange(appNavigationState: AppNavigationState) {
|
private fun onAppNavigationStateChange(navigationState: NavigationState) {
|
||||||
currentAppNavigationState = appNavigationState
|
when (navigationState) {
|
||||||
when (appNavigationState) {
|
NavigationState.Root -> {}
|
||||||
AppNavigationState.Root -> {}
|
is NavigationState.Session -> {}
|
||||||
is AppNavigationState.Session -> {}
|
is NavigationState.Space -> {}
|
||||||
is AppNavigationState.Space -> {}
|
is NavigationState.Room -> {
|
||||||
is AppNavigationState.Room -> {
|
|
||||||
// Cleanup notification for current room
|
// Cleanup notification for current room
|
||||||
clearMessagesForRoom(appNavigationState.parentSpace.parentSession.sessionId, appNavigationState.roomId)
|
clearMessagesForRoom(navigationState.parentSpace.parentSession.sessionId, navigationState.roomId)
|
||||||
}
|
}
|
||||||
is AppNavigationState.Thread -> {
|
is NavigationState.Thread -> {
|
||||||
onEnteringThread(
|
onEnteringThread(
|
||||||
appNavigationState.parentRoom.parentSpace.parentSession.sessionId,
|
navigationState.parentRoom.parentSpace.parentSession.sessionId,
|
||||||
appNavigationState.parentRoom.roomId,
|
navigationState.parentRoom.roomId,
|
||||||
appNavigationState.threadId
|
navigationState.threadId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +221,7 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||||
private suspend fun refreshNotificationDrawerBg() {
|
private suspend fun refreshNotificationDrawerBg() {
|
||||||
Timber.v("refreshNotificationDrawerBg()")
|
Timber.v("refreshNotificationDrawerBg()")
|
||||||
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
|
||||||
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
|
notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also {
|
||||||
queuedEvents.clearAndAdd(it.onlyKeptEvents())
|
queuedEvents.clearAndAdd(it.onlyKeptEvents())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,8 +271,4 @@ class DefaultNotificationDrawerManager @Inject constructor(
|
||||||
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
|
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
|
|
||||||
return resolvedEvent.shouldIgnoreEventInRoom(currentAppNavigationState)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue