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

This commit is contained in:
David Langley 2023-08-30 15:02:59 +01:00
commit d3d4eedb1a
315 changed files with 3724 additions and 1216 deletions

View file

@ -38,7 +38,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug APK

View file

@ -62,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -40,7 +40,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite - name: Run code quality check suite

View file

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

View file

@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -32,7 +32,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar

View file

@ -34,7 +34,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.7.1 uses: gradle/gradle-build-action@v2.8.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -1,3 +1,44 @@
Changes in Element X v0.1.5 (2023-08-28)
========================================
Bugfixes 🐛
----------
- Fix crash when opening any room. ([#1160](https://github.com/vector-im/element-x-android/issues/1160))
Changes in Element X v0.1.4 (2023-08-28)
========================================
Features ✨
----------
- Allow cancelling media upload ([#769](https://github.com/vector-im/element-x-android/issues/769))
- Enable OIDC support. ([#1127](https://github.com/vector-im/element-x-android/issues/1127))
- Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/vector-im/element-x-android/issues/1149))
Bugfixes 🐛
----------
- Videos sent from the app were cropped in some cases. ([#862](https://github.com/vector-im/element-x-android/issues/862))
- Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/vector-im/element-x-android/issues/1033))
- Fix `TextButtons` being displayed in black. ([#1077](https://github.com/vector-im/element-x-android/issues/1077))
- Linkify links in HTML contents. ([#1079](https://github.com/vector-im/element-x-android/issues/1079))
- Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/vector-im/element-x-android/issues/1082))
- Fix rendering of inline elements in list items. ([#1090](https://github.com/vector-im/element-x-android/issues/1090))
- Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/vector-im/element-x-android/issues/1101))
- Make links in messages clickable again. ([#1111](https://github.com/vector-im/element-x-android/issues/1111))
- When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/vector-im/element-x-android/issues/1125))
- Only display verification prompt after initial sync is done. ([#1131](https://github.com/vector-im/element-x-android/issues/1131))
In development 🚧
----------------
- [Poll] Add feature flag in developer options ([#1064](https://github.com/vector-im/element-x-android/issues/1064))
- [Polls] Improve UI and render ended state ([#1113](https://github.com/vector-im/element-x-android/issues/1113))
Other changes
-------------
- Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/vector-im/element-x-android/issues/990))
- Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/vector-im/element-x-android/issues/1135))
Changes in Element X v0.1.2 (2023-08-16) Changes in Element X v0.1.2 (2023-08-16)
======================================== ========================================

View file

@ -81,7 +81,7 @@ If after your research you still have a question, ask at [#element-x-android:mat
## Copyright & License ## Copyright & License
Copyright (c) 2022 New Vector Ltd Copyright © New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in compliance with the License. You may obtain a copy of the License in the [LICENSE](LICENSE) file, or at: Licensed under the Apache License, Version 2.0 (the "License"); you may not use this work except in compliance with the License. You may obtain a copy of the License in the [LICENSE](LICENSE) file, or at:

View file

@ -27,9 +27,9 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import io.element.android.appnav.LoggedInFlowNode import io.element.android.appnav.LoggedInAppScopeFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.appnav.RootFlowNode import io.element.android.appnav.RootFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
@ -45,18 +45,17 @@ class MainNode(
buildContext: BuildContext, buildContext: BuildContext,
private val mainDaggerComponentOwner: MainDaggerComponentsOwner, private val mainDaggerComponentOwner: MainDaggerComponentsOwner,
plugins: List<Plugin>, plugins: List<Plugin>,
) : ) : ParentNode<MainNode.RootNavTarget>(
ParentNode<MainNode.RootNavTarget>(
navModel = PermanentNavModel( navModel = PermanentNavModel(
navTargets = setOf(RootNavTarget), navTargets = setOf(RootNavTarget),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
), ),
DaggerComponentOwner by mainDaggerComponentOwner { DaggerComponentOwner by mainDaggerComponentOwner {
private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback { private val loggedInFlowNodeCallback = object : LoggedInAppScopeFlowNode.LifecycleCallback {
override fun onFlowCreated(identifier: String, client: MatrixClient) { override fun onFlowCreated(identifier: String, client: MatrixClient) {
val component = bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(client).build() val component = bindings<SessionComponent.ParentBindings>().sessionComponentBuilder().client(client).build()
mainDaggerComponentOwner.addComponent(identifier, component) mainDaggerComponentOwner.addComponent(identifier, component)

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil.Coil
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.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import kotlinx.parcelize.Parcelize
/**
* `LoggedInAppScopeFlowNode` is a Node responsible to set up the Dagger
* [io.element.android.libraries.di.SessionScope]. It has only one child: [LoggedInFlowNode].
* This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode].
*/
@ContributesNode(AppScope::class)
class LoggedInAppScopeFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : ParentNode<LoggedInAppScopeFlowNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun onOpenBugReport()
}
@Parcelize
object NavTarget : Parcelable
interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(identifier: String, client: MatrixClient)
fun onFlowReleased(identifier: String, client: MatrixClient)
}
data class Inputs(
val matrixClient: MatrixClient
) : NodeInputs
private val inputs: Inputs = inputs()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
},
onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : LoggedInFlowNode.Callback {
override fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
}
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
return createNode<LoggedInFlowNode>(buildContext, nodeLifecycleCallbacks + callback)
}
suspend fun attachSession(): LoggedInFlowNode {
return waitForChildAttached { _ -> true }
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import coil.Coil
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -53,19 +52,15 @@ 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.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
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.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.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -76,7 +71,7 @@ import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
@ContributesNode(AppScope::class) @ContributesNode(SessionScope::class)
class LoggedInFlowNode @AssistedInject constructor( class LoggedInFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -91,6 +86,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager, private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState, private val ftueState: FtueState,
private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
@ -105,32 +101,18 @@ class LoggedInFlowNode @AssistedInject constructor(
fun onOpenBugReport() fun onOpenBugReport()
} }
interface LifecycleCallback : NodeLifecycleCallback { private val syncService = matrixClient.syncService()
fun onFlowCreated(identifier: String, client: MatrixClient)
fun onFlowReleased(identifier: String, client: MatrixClient)
}
data class Inputs(
val matrixClient: MatrixClient
) : NodeInputs
private val inputs: Inputs = inputs()
private val syncService = inputs.matrixClient.syncService()
private val loggedInFlowProcessor = LoggedInEventProcessor( private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher, snackbarDispatcher,
inputs.matrixClient.roomMembershipObserver(), matrixClient.roomMembershipObserver(),
inputs.matrixClient.sessionVerificationService(), matrixClient.sessionVerificationService(),
) )
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) } appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory)
appNavigationStateService.onNavigateToSession(id, inputs.matrixClient.sessionId)
// 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)
@ -146,7 +128,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
}, },
onDestroy = { onDestroy = {
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
@ -178,10 +159,10 @@ class LoggedInFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Permanent : NavTarget data object Permanent : NavTarget
@Parcelize @Parcelize
object RoomList : NavTarget data object RoomList : NavTarget
@Parcelize @Parcelize
data class Room( data class Room(
@ -190,19 +171,19 @@ class LoggedInFlowNode @AssistedInject constructor(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
object Settings : NavTarget data object Settings : NavTarget
@Parcelize @Parcelize
object CreateRoom : NavTarget data object CreateRoom : NavTarget
@Parcelize @Parcelize
object VerifySession : NavTarget data object VerifySession : NavTarget
@Parcelize @Parcelize
object InviteList : NavTarget data object InviteList : NavTarget
@Parcelize @Parcelize
object Ftue : NavTarget data object Ftue : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -351,4 +332,3 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.InviteList) backstack.push(NavTarget.InviteList)
} }
} }

View file

@ -64,7 +64,7 @@ class NotLoggedInFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object OnBoarding : NavTarget data object OnBoarding : NavTarget
@Parcelize @Parcelize
data class LoginFlow( data class LoginFlow(

View file

@ -72,15 +72,14 @@ class RootFlowNode @AssistedInject constructor(
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
) : ) : BackstackNode<RootFlowNode.NavTarget>(
BackstackNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
override fun onBuilt() { override fun onBuilt() {
matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap) matrixClientsHolder.restoreWithSavedState(buildContext.savedStateMap)
@ -170,10 +169,10 @@ class RootFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object SplashScreen : NavTarget data object SplashScreen : NavTarget
@Parcelize @Parcelize
object NotLoggedInFlow : NavTarget data object NotLoggedInFlow : NavTarget
@Parcelize @Parcelize
data class LoggedInFlow( data class LoggedInFlow(
@ -182,7 +181,7 @@ class RootFlowNode @AssistedInject constructor(
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
object BugReport : NavTarget data object BugReport : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -191,14 +190,14 @@ class RootFlowNode @AssistedInject constructor(
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen") Timber.w("Couldn't find any session, go through SplashScreen")
} }
val inputs = LoggedInFlowNode.Inputs(matrixClient) val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInFlowNode.Callback { val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
backstack.push(NavTarget.BugReport) backstack.push(NavTarget.BugReport)
} }
} }
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
createNode<LoggedInFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
} }
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext) NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.SplashScreen -> splashNode(buildContext)
@ -233,6 +232,7 @@ class RootFlowNode @AssistedInject constructor(
private suspend fun navigateTo(deeplinkData: DeeplinkData) { private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId) attachSession(deeplinkData.sessionId)
.attachSession()
.apply { .apply {
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> attachRoot() is DeeplinkData.Root -> attachRoot()
@ -246,7 +246,7 @@ class RootFlowNode @AssistedInject constructor(
oidcActionFlow.post(oidcAction) oidcActionFlow.post(oidcAction)
} }
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { private suspend fun attachSession(sessionId: SessionId): LoggedInAppScopeFlowNode {
//TODO handle multi-session //TODO handle multi-session
return waitForChildAttached { navTarget -> return waitForChildAttached { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId

View file

@ -17,5 +17,5 @@
package io.element.android.appnav.loggedin package io.element.android.appnav.loggedin
// sealed interface LoggedInEvents { // sealed interface LoggedInEvents {
// object MyEvent : LoggedInEvents // data object MyEvent : LoggedInEvents
// } // }

View file

@ -32,8 +32,8 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
sealed interface LoadingRoomState { sealed interface LoadingRoomState {
object Loading : LoadingRoomState data object Loading : LoadingRoomState
object Error : LoadingRoomState data object Error : LoadingRoomState
data class Loaded(val room: MatrixRoom) : LoadingRoomState data class Loaded(val room: MatrixRoom) : LoadingRoomState
} }

View file

@ -77,10 +77,10 @@ class RoomFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Loading : NavTarget data object Loading : NavTarget
@Parcelize @Parcelize
object Loaded : NavTarget data object Loaded : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {

View file

@ -152,10 +152,10 @@ class RoomLoadedFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Messages : NavTarget data object Messages : NavTarget
@Parcelize @Parcelize
object RoomDetails : NavTarget data object RoomDetails : NavTarget
@Parcelize @Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget data class RoomMemberDetails(val userId: UserId) : NavTarget

View file

@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript { buildscript {
dependencies { dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10")
classpath("com.google.gms:google-services:4.3.15") classpath("com.google.gms:google-services:4.3.15")
} }
} }

View file

@ -1 +0,0 @@
Timeline: sender names are now displayed in one single line.

View file

@ -1 +0,0 @@
[Poll] Add feature flag in developer options

View file

@ -1 +0,0 @@
Fix `TextButtons` being displayed in black.

View file

@ -1 +0,0 @@
Linkify links in HTML contents.

View file

@ -1 +0,0 @@
Fix bug reporter failing after not finding some log files.

View file

@ -1 +0,0 @@
Fix rendering of inline elements in list items.

View file

@ -1 +0,0 @@
Fix crash RuntimeException "No matching key found for the ciphertext in the stream"

View file

@ -1 +0,0 @@
Make links in messages clickable again.

View file

@ -1 +0,0 @@
When event has no id, just cancel parsing the latest room message for a room.

View file

@ -1 +0,0 @@
Enable OIDC support.

View file

@ -1 +0,0 @@
Only display verification prompt after initial sync is done.

1
changelog.d/1143.feature Normal file
View file

@ -0,0 +1 @@
Create poll.

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

@ -0,0 +1 @@
Bug reporter crashes when 'send logs' is disabled.

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

@ -0,0 +1 @@
Add missing link to the terms on the analytics setting screen.

1
changelog.d/1187.misc Normal file
View file

@ -0,0 +1 @@
Remove unnecessary year in copyright mention.

View file

@ -1 +0,0 @@
Allow cancelling media upload

View file

@ -1 +0,0 @@
Videos sent from the app were cropped in some cases.

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

@ -0,0 +1 @@
Make sure Snackbars are only displayed once.

View file

@ -1 +0,0 @@
Compound: add `ListItem` and `ListSectionHeader` components.

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and add OIDC support.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -0,0 +1,2 @@
Main changes in this version: bug fixes and add OIDC support.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -21,5 +21,6 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
data class AnalyticsPreferencesState( data class AnalyticsPreferencesState(
val applicationName: String, val applicationName: String,
val isEnabled: Boolean, val isEnabled: Boolean,
val policyUrl: String,
val eventSink: (AnalyticsOptInEvents) -> Unit, val eventSink: (AnalyticsOptInEvents) -> Unit,
) )

View file

@ -28,5 +28,6 @@ open class AnalyticsPreferencesStateProvider : PreviewParameterProvider<Analytic
fun aAnalyticsPreferencesState() = AnalyticsPreferencesState( fun aAnalyticsPreferencesState() = AnalyticsPreferencesState(
applicationName = "Element X", applicationName = "Element X",
isEnabled = false, isEnabled = false,
policyUrl = "https://element.io",
eventSink = {} eventSink = {}
) )

View file

@ -16,22 +16,21 @@
package io.element.android.features.analytics.api.preferences package io.element.android.features.analytics.api.preferences
import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.LINK_TAG
import io.element.android.libraries.designsystem.components.list.ListItemContent
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
import io.element.android.libraries.theme.LinkColor import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -43,40 +42,33 @@ fun AnalyticsPreferencesView(
state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled)) state.eventSink(AnalyticsOptInEvents.EnableAnalytics(isEnabled = isEnabled))
} }
val firstPart = stringResource(id = CommonStrings.screen_analytics_settings_help_us_improve, state.applicationName) val supportingText = stringResource(
val secondPart = buildAnnotatedStringWithColoredPart( id = CommonStrings.screen_analytics_settings_help_us_improve,
state.applicationName
)
val linkText = buildAnnotatedStringWithStyledPart(
CommonStrings.screen_analytics_settings_read_terms, CommonStrings.screen_analytics_settings_read_terms,
CommonStrings.screen_analytics_settings_read_terms_content_link CommonStrings.screen_analytics_settings_read_terms_content_link,
tagAndLink = LINK_TAG to state.policyUrl,
) )
val subtitle = "$firstPart\n\n$secondPart" Column(modifier) {
ListItem(
PreferenceSwitch( headlineContent = {
modifier = modifier, Text(stringResource(id = CommonStrings.screen_analytics_settings_share_data))
title = stringResource(id = CommonStrings.screen_analytics_settings_share_data), },
subtitle = subtitle, supportingContent = {
isChecked = state.isEnabled, Text(supportingText)
onCheckedChange = ::onEnabledChanged, },
switchAlignment = Alignment.Top, leadingContent = null,
) trailingContent = ListItemContent.Switch(
} checked = state.isEnabled,
),
@Composable onClick = {
fun buildAnnotatedStringWithColoredPart( onEnabledChanged(!state.isEnabled)
@StringRes fullTextRes: Int, }
@StringRes coloredTextRes: Int,
color: Color = LinkColor,
underline: Boolean = true,
) = buildAnnotatedString {
val coloredPart = stringResource(coloredTextRes)
val fullText = stringResource(fullTextRes, coloredPart)
val startIndex = fullText.indexOf(coloredPart)
append(fullText)
addStyle(
style = SpanStyle(
color = color,
textDecoration = if (underline) TextDecoration.Underline else null
), start = startIndex, end = startIndex + coloredPart.length
) )
ListSupportingText(annotatedString = linkText)
}
} }
@Preview @Preview
@ -91,5 +83,7 @@ internal fun AnalyticsPreferencesViewDarkPreview(@PreviewParameter(AnalyticsPref
@Composable @Composable
private fun ContentToPreview(state: AnalyticsPreferencesState) { private fun ContentToPreview(state: AnalyticsPreferencesState) {
AnalyticsPreferencesView(state) AnalyticsPreferencesView(
state = state,
)
} }

View file

@ -50,6 +50,5 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.analytics.impl)
} }

View file

@ -18,7 +18,6 @@ package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -28,7 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Poll import androidx.compose.material.icons.filled.Poll
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
@ -37,7 +36,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -45,6 +43,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.features.analytics.api.Config
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.InfoListItem
@ -56,7 +55,6 @@ import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithSt
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
@ -98,6 +96,8 @@ fun AnalyticsOptInView(
) )
} }
private const val LINK_TAG = "link"
@Composable @Composable
private fun AnalyticsOptInHeader( private fun AnalyticsOptInHeader(
state: AnalyticsOptInState, state: AnalyticsOptInState,
@ -114,21 +114,29 @@ private fun AnalyticsOptInHeader(
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve), subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
iconImageVector = Icons.Filled.Poll iconImageVector = Icons.Filled.Poll
) )
Text( val text = buildAnnotatedStringWithStyledPart(
text = buildAnnotatedStringWithStyledPart(
R.string.screen_analytics_prompt_read_terms, R.string.screen_analytics_prompt_read_terms,
R.string.screen_analytics_prompt_read_terms_content_link, R.string.screen_analytics_prompt_read_terms_content_link,
color = Color.Unspecified, color = Color.Unspecified,
underline = false, underline = false,
bold = true, bold = true,
), tagAndLink = LINK_TAG to Config.POLICY_LINK,
)
ClickableText(
text = text,
onClick = {
text
.getStringAnnotations(LINK_TAG, it, it)
.firstOrNull()
?.let { _ -> onClickTerms() }
},
modifier = Modifier modifier = Modifier
.clip(shape = RoundedCornerShape(8.dp))
.clickable { onClickTerms() }
.padding(8.dp), .padding(8.dp),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular
textAlign = TextAlign.Center, .copy(
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center,
)
) )
} }
} }

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.Config
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesPresenter
import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState import io.element.android.features.analytics.api.preferences.AnalyticsPreferencesState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
@ -51,6 +52,7 @@ class DefaultAnalyticsPreferencesPresenter @Inject constructor(
return AnalyticsPreferencesState( return AnalyticsPreferencesState(
applicationName = buildMeta.applicationName, applicationName = buildMeta.applicationName,
isEnabled = isEnabled.value, isEnabled = isEnabled.value,
policyUrl = Config.POLICY_LINK,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -5,6 +5,6 @@
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Nutzerbedingungen %1$s lesen."</string> <string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string> <string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Du kannst dies jederzeit deaktivieren"</string> <string name="screen_analytics_prompt_settings">"Du kannst dies jederzeit deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string> <string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string>
<string name="screen_analytics_prompt_title">"Hilf uns, %1$s zu verbessern"</string> <string name="screen_analytics_prompt_title">"Hilf uns, %1$s zu verbessern"</string>
</resources> </resources>

View file

@ -21,8 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test 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.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -21,8 +21,8 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test 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.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -39,6 +39,7 @@ class AnalyticsPreferencesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isEnabled).isTrue() assertThat(initialState.isEnabled).isTrue()
assertThat(initialState.policyUrl).isNotEmpty()
} }
} }

View file

@ -60,7 +60,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.features.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)

View file

@ -63,10 +63,10 @@ class ConfigureRoomFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Root : NavTarget data object Root : NavTarget
@Parcelize @Parcelize
object ConfigureRoom : NavTarget data object ConfigureRoom : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

@ -54,10 +54,10 @@ class CreateRoomFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Root : NavTarget data object Root : NavTarget
@Parcelize @Parcelize
object NewRoom : NavTarget data object NewRoom : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {

View file

@ -27,5 +27,5 @@ sealed interface ConfigureRoomEvents {
data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents
data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents
data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents
object CancelCreateRoom : ConfigureRoomEvents data object CancelCreateRoom : ConfigureRoomEvents
} }

View file

@ -20,5 +20,5 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
sealed interface CreateRoomRootEvents { sealed interface CreateRoomRootEvents {
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CancelStartDM : CreateRoomRootEvents data object CancelStartDM : CreateRoomRootEvents
} }

View file

@ -22,7 +22,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
@ -38,6 +37,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic

View file

@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CreatedRoom import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
@ -35,6 +34,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.ftue.api) api(projects.features.ftue.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -49,7 +50,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.services.analytics.test)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -34,6 +34,7 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
import io.element.android.features.ftue.impl.state.DefaultFtueState 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.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode import io.element.android.features.ftue.impl.welcome.WelcomeNode
@ -41,6 +42,7 @@ import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -50,7 +52,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(SessionScope::class)
class FtueFlowNode @AssistedInject constructor( class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
@ -69,13 +71,16 @@ class FtueFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Placeholder : NavTarget data object Placeholder : NavTarget
@Parcelize @Parcelize
object WelcomeScreen : NavTarget data object MigrationScreen : NavTarget
@Parcelize @Parcelize
object AnalyticsOptIn : NavTarget data object WelcomeScreen : NavTarget
@Parcelize
data object AnalyticsOptIn : NavTarget
} }
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull() private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
@ -102,6 +107,14 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.Placeholder -> { NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext) createNode<PlaceholderNode>(buildContext)
} }
NavTarget.MigrationScreen -> {
val callback = object : MigrationScreenNode.Callback {
override fun onMigrationFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<MigrationScreenNode>(buildContext, listOf(callback))
}
NavTarget.WelcomeScreen -> { NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback { val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() { override fun onContinueClicked() {
@ -117,12 +130,15 @@ class FtueFlowNode @AssistedInject constructor(
} }
} }
private suspend fun moveToNextStep() { private fun moveToNextStep() {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
is FtueStep.WelcomeScreen -> { FtueStep.MigrationScreen -> {
backstack.newRoot(NavTarget.MigrationScreen)
}
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen) backstack.newRoot(NavTarget.WelcomeScreen)
} }
is FtueStep.AnalyticsOptIn -> { FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn) backstack.replace(NavTarget.AnalyticsOptIn)
} }
null -> callback?.onFtueFlowFinished() null -> callback?.onFtueFlowFinished()

View file

@ -14,57 +14,40 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.poll.impl package io.element.android.features.ftue.impl.migration
import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class PollFlowNode @AssistedInject constructor( class MigrationScreenNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BackstackNode<PollFlowNode.NavTarget>( private val presenter: MigrationScreenPresenter,
backstack = BackStack( ) : Node(buildContext, plugins = plugins) {
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable { interface Callback : Plugin {
@Parcelize fun onMigrationFinished()
object Root : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { private fun onMigrationFinished() {
return when (navTarget) { plugins.filterIsInstance<Callback>().forEach { it.onMigrationFinished() }
NavTarget.Root -> {
createNode(buildContext)
}
}
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Children( val state = presenter.present()
navModel = backstack, MigrationScreenView(
modifier = modifier, state,
transitionHandler = rememberDefaultTransitionHandler(), onMigrationFinished = ::onMigrationFinished,
modifier = modifier
) )
} }
} }

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import javax.inject.Inject
class MigrationScreenPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val migrationScreenStore: MigrationScreenStore,
) : Presenter<MigrationScreenState> {
@Composable
override fun present(): MigrationScreenState {
val roomListState by matrixClient.roomListService.state.collectAsState()
if (roomListState == RoomListService.State.Running) {
LaunchedEffect(Unit) {
migrationScreenStore.setMigrationScreenShown(matrixClient.sessionId)
}
}
return MigrationScreenState(
isMigrating = roomListState != RoomListService.State.Running
)
}
}

View file

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

View file

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

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.pages.SunsetPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
@Composable
fun MigrationScreenView(
migrationState: MigrationScreenState,
onMigrationFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
if (migrationState.isMigrating.not()) {
LaunchedEffect(Unit) {
onMigrationFinished()
}
}
SunsetPage(
modifier = modifier,
isLoading = true,
title = stringResource(id = R.string.screen_migration_title),
subtitle = stringResource(id = R.string.screen_migration_message),
overallContent = {}
)
}
@DayNightPreviews
@Composable
internal fun MigrationViewPreview() = ElementPreview {
MigrationScreenView(
migrationState = MigrationScreenState(isMigrating = true),
onMigrationFinished = {})
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import android.content.SharedPreferences
import androidx.core.content.edit
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class SharedPrefsMigrationScreenStore @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
) : MigrationScreenStore {
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
return sharedPreferences.getBoolean(sessionId.toKey(), false).not()
}
override fun setMigrationScreenShown(sessionId: SessionId) {
sharedPreferences.edit().putBoolean(sessionId.toKey(), true).apply()
}
override fun reset() {
sharedPreferences.edit {
sharedPreferences.all.keys
.filter { it.startsWith(IS_MIGRATION_SCREEN_SHOWN_PREFIX) }
.forEach {
remove(it)
}
}
}
private fun SessionId.toKey(): String {
// Hash the sessionId to get rid of exotic char and take only the first 16 chars,
// The risk of collision is not high.
return IS_MIGRATION_SCREEN_SHOWN_PREFIX + value.hash().take(16)
}
companion object {
private const val IS_MIGRATION_SCREEN_SHOWN_PREFIX = "is_migration_screen_shown_"
}
}

View file

@ -19,8 +19,10 @@ package io.element.android.features.ftue.impl.state
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -30,11 +32,13 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import javax.inject.Inject import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor( class DefaultFtueState @Inject constructor(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState, private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val matrixClient: MatrixClient,
) : FtueState { ) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
@ -42,6 +46,7 @@ class DefaultFtueState @Inject constructor(
override suspend fun reset() { override suspend fun reset() {
welcomeScreenState.reset() welcomeScreenState.reset()
analyticsService.reset() analyticsService.reset()
migrationScreenStore.reset()
} }
init { init {
@ -52,7 +57,10 @@ class DefaultFtueState @Inject constructor(
fun getNextStep(currentStep: FtueStep? = null): FtueStep? = fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( null -> if (shouldDisplayMigrationScreen()) FtueStep.MigrationScreen else getNextStep(
FtueStep.MigrationScreen
)
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen FtueStep.WelcomeScreen
) )
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep( FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
@ -63,11 +71,16 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean { private fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(), shouldDisplayWelcomeScreen(),
needsAnalyticsOptIn() needsAnalyticsOptIn()
).any { it } ).any { it }
} }
private fun shouldDisplayMigrationScreen(): Boolean {
return migrationScreenStore.isMigrationScreenNeeded(matrixClient.sessionId)
}
private fun needsAnalyticsOptIn(): Boolean { private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking // We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() } return runBlocking { analyticsService.didAskUserConsent().first().not() }
@ -89,6 +102,7 @@ class DefaultFtueState @Inject constructor(
} }
sealed interface FtueStep { sealed interface FtueStep {
object WelcomeScreen : FtueStep data object MigrationScreen : FtueStep
object AnalyticsOptIn : FtueStep data object WelcomeScreen : FtueStep
data object AnalyticsOptIn : FtueStep
} }

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"Toto je jednorázový proces, děkujeme za čekání."</string>
<string name="screen_migration_title">"Nastavení vašeho účtu"</string>
<string name="screen_welcome_bullet_1">"Hovory, hlasování, vyhledávání a další budou přidány koncem tohoto roku."</string>
<string name="screen_welcome_bullet_2">"Historie zpráv šifrovaných místností nebude v této aktualizaci k dispozici."</string>
<string name="screen_welcome_bullet_3">"Rádi bychom se od vás dozvěděli, co si o tom myslíte, dejte nám vědět prostřednictvím stránky s nastavením."</string>
<string name="screen_welcome_button">"Jdeme na to!"</string>
<string name="screen_welcome_subtitle">"Zde je to, co potřebujete vědět:"</string>
<string name="screen_welcome_title">"Vítá vás %1$s!"</string>
</resources>

View file

@ -1,6 +1,8 @@
<?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_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string> <string name="screen_migration_message">"Dies ist ein einmaliger Vorgang, danke fürs Warten."</string>
<string name="screen_migration_title">"Dein Konto einrichten"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string> <string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string> <string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
<string name="screen_welcome_button">"Los geht\'s!"</string> <string name="screen_welcome_button">"Los geht\'s!"</string>

View file

@ -1,5 +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_migration_message">"Ce processus na besoin dêtre fait quune seule fois, merci de patienter."</string>
<string name="screen_migration_title">"Configuration de votre compte."</string>
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string> <string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string> <string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string>
<string name="screen_welcome_button">"Cest parti !"</string> <string name="screen_welcome_button">"Cest parti !"</string>

View file

@ -1,5 +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_migration_message">"Это одноразовый процесс, спасибо, что подождали."</string>
<string name="screen_migration_title">"Настройка учетной записи."</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string> <string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string> <string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -1,5 +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_migration_message">"Ide o jednorazový proces, ďakujeme za trpezlivosť."</string>
<string name="screen_migration_title">"Nastavenie vášho účtu."</string>
<string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string> <string name="screen_welcome_bullet_1">"Hovory, ankety, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string> <string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string> <string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>

View file

@ -1,4 +1,5 @@
<?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_migration_title">"設定您的帳號"</string>
<string name="screen_welcome_button">"開始吧!"</string> <string name="screen_welcome_button">"開始吧!"</string>
</resources> </resources>

View file

@ -1,5 +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_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string> <string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -17,11 +17,16 @@
package io.element.android.features.ftue.impl package io.element.android.features.ftue.impl
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.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.state.DefaultFtueState 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.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -45,12 +50,14 @@ class DefaultFtueStateTests {
fun `given all checks being true, should display flow is false`() = runTest { fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState() val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService) val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
welcomeState.setWelcomeScreenShown() welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.updateState() state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse() assertThat(state.shouldDisplayFlow.value).isFalse()
@ -63,16 +70,21 @@ class DefaultFtueStateTests {
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState() val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService) val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val steps = mutableListOf<FtueStep?>() val steps = mutableListOf<FtueStep?>()
// First step, welcome screen // First step, migration screen
steps.add(state.getNextStep(steps.lastOrNull()))
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
// Second step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown() welcomeState.setWelcomeScreenShown()
// Second step, analytics opt in // Third step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
@ -80,6 +92,7 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly( assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen, FtueStep.WelcomeScreen,
FtueStep.AnalyticsOptIn, FtueStep.AnalyticsOptIn,
null, // Final state null, // Final state
@ -93,7 +106,16 @@ class DefaultFtueStateTests {
fun `if a check for a step is true, start from the next one`() = runTest { fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService) val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown() state.setWelcomeScreenShown()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -108,7 +130,14 @@ class DefaultFtueStateTests {
private fun createState( private fun createState(
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(), welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService() analyticsService: AnalyticsService = FakeAnalyticsService(),
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState) migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
matrixClient: MatrixClient = FakeMatrixClient(),
) = DefaultFtueState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
matrixClient = matrixClient,
)
} }

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import io.element.android.libraries.matrix.api.core.SessionId
class InMemoryMigrationScreenStore : MigrationScreenStore {
private val store = mutableMapOf<SessionId, Boolean>()
override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean {
// If store does not have key return true, else return the opposite of the value
return store[sessionId]?.not() ?: true
}
override fun setMigrationScreenShown(sessionId: SessionId) {
store[sessionId] = true
}
override fun reset() {
store.clear()
}
}

View file

@ -0,0 +1,69 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.migration
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MigrationScreenPresenterTest {
@Test
fun `present - initial`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isMigrating).isTrue()
}
}
@Test
fun `present - migration end`() = runTest {
val matrixClient = FakeMatrixClient()
val migrationScreenStore = InMemoryMigrationScreenStore()
val presenter = createPresenter(matrixClient, migrationScreenStore)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isMigrating).isTrue()
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isTrue()
// Simulate room list loaded
(matrixClient.roomListService as FakeRoomListService).postState(RoomListService.State.Running)
val nextState = awaitItem()
assertThat(nextState.isMigrating).isFalse()
assertThat(migrationScreenStore.isMigrationScreenNeeded(A_SESSION_ID)).isFalse()
}
}
private fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
) = MigrationScreenPresenter(
matrixClient,
migrationScreenStore,
)
}

View file

@ -53,7 +53,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invitelist.test) testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.services.analytics.test)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -19,14 +19,12 @@ package io.element.android.features.invitelist.impl
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
sealed interface InviteListEvents { sealed interface InviteListEvents {
data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents data class AcceptInvite(val invite: InviteListInviteSummary) : InviteListEvents
data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents data class DeclineInvite(val invite: InviteListInviteSummary) : InviteListEvents
object ConfirmDeclineInvite: InviteListEvents data object ConfirmDeclineInvite: InviteListEvents
object CancelDeclineInvite: InviteListEvents data object CancelDeclineInvite: InviteListEvents
object DismissAcceptError: InviteListEvents
object DismissDeclineError: InviteListEvents
data object DismissAcceptError: InviteListEvents
data object DismissDeclineError: InviteListEvents
} }

View file

@ -32,6 +32,6 @@ data class InviteListState(
) )
sealed interface InviteDeclineConfirmationDialog { sealed interface InviteDeclineConfirmationDialog {
object Hidden : InviteDeclineConfirmationDialog data object Hidden : InviteDeclineConfirmationDialog
data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog data class Visible(val isDirect: Boolean, val name: String) : InviteDeclineConfirmationDialog
} }

View file

@ -20,7 +20,6 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.invitelist.api.SeenInvitesStore import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.features.invitelist.test.FakeSeenInvitesStore import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -44,6 +43,7 @@ import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveRoomEvent { sealed interface LeaveRoomEvent {
data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent data class ShowConfirmation(val roomId: RoomId) : LeaveRoomEvent
object HideConfirmation : LeaveRoomEvent data object HideConfirmation : LeaveRoomEvent
data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent data class LeaveRoom(val roomId: RoomId) : LeaveRoomEvent
object HideError : LeaveRoomEvent data object HideError : LeaveRoomEvent
} }

View file

@ -25,19 +25,19 @@ data class LeaveRoomState(
val eventSink: (LeaveRoomEvent) -> Unit = {}, val eventSink: (LeaveRoomEvent) -> Unit = {},
) { ) {
sealed interface Confirmation { sealed interface Confirmation {
object Hidden : Confirmation data object Hidden : Confirmation
data class Generic(val roomId: RoomId) : Confirmation data class Generic(val roomId: RoomId) : Confirmation
data class PrivateRoom(val roomId: RoomId) : Confirmation data class PrivateRoom(val roomId: RoomId) : Confirmation
data class LastUserInRoom(val roomId: RoomId) : Confirmation data class LastUserInRoom(val roomId: RoomId) : Confirmation
} }
sealed interface Progress { sealed interface Progress {
object Hidden : Progress data object Hidden : Progress
object Shown : Progress data object Shown : Progress
} }
sealed interface Error { sealed interface Error {
object Hidden : Error data object Hidden : Error
object Shown : Error data object Shown : Error
} }
} }

View file

@ -55,6 +55,6 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test) testImplementation(projects.features.messages.test)
} }

View file

@ -17,5 +17,5 @@
package io.element.android.features.location.impl.common.permissions package io.element.android.features.location.impl.common.permissions
sealed interface PermissionsEvents { sealed interface PermissionsEvents {
object RequestPermissions : PermissionsEvents data object RequestPermissions : PermissionsEvents
} }

View file

@ -22,9 +22,9 @@ data class PermissionsState(
val eventSink: (PermissionsEvents) -> Unit = {}, val eventSink: (PermissionsEvents) -> Unit = {},
) { ) {
sealed interface Permissions { sealed interface Permissions {
object AllGranted : Permissions data object AllGranted : Permissions
object SomeGranted : Permissions data object SomeGranted : Permissions
object NoneGranted : Permissions data object NoneGranted : Permissions
} }
val isAnyGranted: Boolean val isAnyGranted: Boolean

View file

@ -30,13 +30,9 @@ sealed interface SendLocationEvents {
) )
} }
object SwitchToMyLocationMode : SendLocationEvents data object SwitchToMyLocationMode : SendLocationEvents
data object SwitchToPinLocationMode : SendLocationEvents
object SwitchToPinLocationMode : SendLocationEvents data object DismissDialog : SendLocationEvents
data object RequestPermissions : SendLocationEvents
object DismissDialog : SendLocationEvents data object OpenAppSettings : SendLocationEvents
object RequestPermissions : SendLocationEvents
object OpenAppSettings : SendLocationEvents
} }

View file

@ -24,13 +24,13 @@ data class SendLocationState(
val eventSink: (SendLocationEvents) -> Unit = {}, val eventSink: (SendLocationEvents) -> Unit = {},
) { ) {
sealed interface Mode { sealed interface Mode {
object SenderLocation : Mode data object SenderLocation : Mode
object PinLocation : Mode data object PinLocation : Mode
} }
sealed interface Dialog { sealed interface Dialog {
object None : Dialog data object None : Dialog
object PermissionRationale : Dialog data object PermissionRationale : Dialog
object PermissionDenied : Dialog data object PermissionDenied : Dialog
} }
} }

View file

@ -17,11 +17,9 @@
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
sealed interface ShowLocationEvents { sealed interface ShowLocationEvents {
object Share : ShowLocationEvents data object Share : ShowLocationEvents
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
object DismissDialog : ShowLocationEvents data object DismissDialog : ShowLocationEvents
data object RequestPermissions : ShowLocationEvents
object RequestPermissions : ShowLocationEvents data object OpenAppSettings : ShowLocationEvents
object OpenAppSettings : ShowLocationEvents
} }

View file

@ -28,8 +28,8 @@ data class ShowLocationState(
val eventSink: (ShowLocationEvents) -> Unit, val eventSink: (ShowLocationEvents) -> Unit,
) { ) {
sealed interface Dialog { sealed interface Dialog {
object None : Dialog data object None : Dialog
object PermissionRationale : Dialog data object PermissionRationale : Dialog
object PermissionDenied : Dialog data object PermissionDenied : Dialog
} }
} }

View file

@ -21,7 +21,6 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
@ -34,6 +33,7 @@ import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation import io.element.android.libraries.matrix.test.room.SendLocationInvocation
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -17,6 +17,6 @@
package io.element.android.features.login.api.oidc package io.element.android.features.login.api.oidc
sealed interface OidcAction { sealed interface OidcAction {
object GoBack : OidcAction data object GoBack : OidcAction
data class Success(val url: String) : OidcAction data class Success(val url: String) : OidcAction
} }

View file

@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.0" kotlin("plugin.serialization") version "1.9.10"
} }
android { android {

View file

@ -22,7 +22,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children 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.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
@ -33,6 +35,8 @@ import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
@ -51,6 +55,8 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -61,6 +67,7 @@ class LoginFlowNode @AssistedInject constructor(
private val customTabHandler: CustomTabHandler, private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource, private val accountProviderDataSource: AccountProviderDataSource,
private val defaultLoginUserStory: DefaultLoginUserStory, private val defaultLoginUserStory: DefaultLoginUserStory,
private val oidcActionFlow: OidcActionFlow,
) : BackstackNode<LoginFlowNode.NavTarget>( ) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.ConfirmAccountProvider, initialElement = NavTarget.ConfirmAccountProvider,
@ -78,23 +85,40 @@ class LoginFlowNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private var customChromeTabStarted = false
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
defaultLoginUserStory.setLoginFlowIsDone(false) defaultLoginUserStory.setLoginFlowIsDone(false)
lifecycle.subscribe(
onResume = {
if (customChromeTabStarted) {
customChromeTabStarted = false
// Workaround to detect that the Custom Chrome Tab has been closed
// If there is no coming OidcAction (that would end this Node),
// consider that the user has cancelled the login
// by pressing back or by closing the Custom Chrome Tab.
lifecycleScope.launch {
delay(5000)
oidcActionFlow.post(OidcAction.GoBack)
}
}
}
)
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object ConfirmAccountProvider : NavTarget data object ConfirmAccountProvider : NavTarget
@Parcelize @Parcelize
object ChangeAccountProvider : NavTarget data object ChangeAccountProvider : NavTarget
@Parcelize @Parcelize
object SearchAccountProvider : NavTarget data object SearchAccountProvider : NavTarget
@Parcelize @Parcelize
object LoginPassword : NavTarget data object LoginPassword : NavTarget
@Parcelize @Parcelize
data class WaitList(val loginFormState: LoginFormState) : NavTarget data class WaitList(val loginFormState: LoginFormState) : NavTarget
@ -113,7 +137,10 @@ class LoginFlowNode @AssistedInject constructor(
override fun onOidcDetails(oidcDetails: OidcDetails) { override fun onOidcDetails(oidcDetails: OidcDetails) {
if (customTabAvailabilityChecker.supportCustomTab()) { if (customTabAvailabilityChecker.supportCustomTab()) {
// In this case open a Chrome Custom tab // In this case open a Chrome Custom tab
activity?.let { customTabHandler.open(it, darkTheme, oidcDetails.url) } activity?.let {
customChromeTabStarted = true
customTabHandler.open(it, darkTheme, oidcDetails.url)
}
} else { } else {
// Fallback to WebView mode // Fallback to WebView mode
backstack.push(NavTarget.OidcView(oidcDetails)) backstack.push(NavTarget.OidcView(oidcDetails))

View file

@ -20,5 +20,5 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChangeServerEvents { sealed interface ChangeServerEvents {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
object ClearError : ChangeServerEvents data object ClearError : ChangeServerEvents
} }

View file

@ -27,7 +27,7 @@ sealed class ChangeServerError : Throwable() {
@Composable @Composable
fun message(): String = stringResource(messageId) fun message(): String = stringResource(messageId)
} }
object SlidingSyncAlert : ChangeServerError() data object SlidingSyncAlert : ChangeServerError()
companion object { companion object {
fun from(error: Throwable): ChangeServerError = when (error) { fun from(error: Throwable): ChangeServerError = when (error) {

View file

@ -19,7 +19,7 @@ package io.element.android.features.login.impl.oidc.webview
import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcAction
sealed interface OidcEvents { sealed interface OidcEvents {
object Cancel : OidcEvents data object Cancel : OidcEvents
data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents data class OidcActionEvent(val oidcAction: OidcAction): OidcEvents
object ClearError : OidcEvents data object ClearError : OidcEvents
} }

View file

@ -17,6 +17,6 @@
package io.element.android.features.login.impl.screens.confirmaccountprovider package io.element.android.features.login.impl.screens.confirmaccountprovider
sealed interface ConfirmAccountProviderEvents { sealed interface ConfirmAccountProviderEvents {
object Continue : ConfirmAccountProviderEvents data object Continue : ConfirmAccountProviderEvents
object ClearError : ConfirmAccountProviderEvents data object ClearError : ConfirmAccountProviderEvents
} }

View file

@ -68,9 +68,9 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { defaultOidcActionFlow.collect { oidcAction ->
defaultOidcActionFlow.collect { if (oidcAction != null) {
onOidcAction(it, loginFlowAction) onOidcAction(oidcAction, loginFlowAction)
} }
} }
} }
@ -113,10 +113,9 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
} }
private suspend fun onOidcAction( private suspend fun onOidcAction(
oidcAction: OidcAction?, oidcAction: OidcAction,
loginFlowAction: MutableState<Async<LoginFlow>>, loginFlowAction: MutableState<Async<LoginFlow>>,
) { ) {
oidcAction ?: return
loginFlowAction.value = Async.Loading() loginFlowAction.value = Async.Loading()
when (oidcAction) { when (oidcAction) {
OidcAction.GoBack -> { OidcAction.GoBack -> {

View file

@ -31,6 +31,6 @@ data class ConfirmAccountProviderState(
} }
sealed interface LoginFlow { sealed interface LoginFlow {
object PasswordLogin : LoginFlow data object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
} }

View file

@ -19,6 +19,6 @@ package io.element.android.features.login.impl.screens.loginpassword
sealed interface LoginPasswordEvents { sealed interface LoginPasswordEvents {
data class SetLogin(val login: String) : LoginPasswordEvents data class SetLogin(val login: String) : LoginPasswordEvents
data class SetPassword(val password: String) : LoginPasswordEvents data class SetPassword(val password: String) : LoginPasswordEvents
object Submit : LoginPasswordEvents data object Submit : LoginPasswordEvents
object ClearError : LoginPasswordEvents data object ClearError : LoginPasswordEvents
} }

View file

@ -17,7 +17,7 @@
package io.element.android.features.login.impl.screens.waitlistscreen package io.element.android.features.login.impl.screens.waitlistscreen
sealed interface WaitListEvents { sealed interface WaitListEvents {
object AttemptLogin : WaitListEvents data object AttemptLogin : WaitListEvents
object ClearError : WaitListEvents data object ClearError : WaitListEvents
object Continue : WaitListEvents data object Continue : WaitListEvents
} }

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