Merge remote-tracking branch 'origin/develop' into misc/cjs/invite-string-change

This commit is contained in:
Chris Smith 2023-06-23 14:10:17 +01:00
commit ffd73dbae3
506 changed files with 7944 additions and 2523 deletions

View file

@ -18,9 +18,7 @@ jobs:
if: ${{ github.repository == 'vector-im/element-x-android' }} if: ${{ github.repository == 'vector-im/element-x-android' }}
steps: steps:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: actions/checkout@v3 uses: nschloe/action-cached-lfs-checkout@v1.2.1
with:
lfs: 'true'
- name: Use JDK 17 - name: Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3

View file

@ -14,9 +14,9 @@ jobs:
steps: steps:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: actions/checkout@v3 uses: nschloe/action-cached-lfs-checkout@v1.2.1
with: with:
lfs: 'true' persist-credentials: false
- name: ☕️ Use JDK 17 - name: ☕️ Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
@ -30,5 +30,6 @@ jobs:
- name: Record screenshots - name: Record screenshots
run: "./.github/workflows/scripts/recordScreenshots.sh" run: "./.github/workflows/scripts/recordScreenshots.sh"
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }} GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}

View file

@ -16,13 +16,43 @@
# limitations under the License. # limitations under the License.
# #
if [[ -z ${GITHUB_TOKEN} ]]; then TOKEN=$GITHUB_TOKEN
echo "Missing GITHUB_TOKEN variable" REPO=$GITHUB_REPOSITORY
SHORT=t:,r:
LONG=token:,repo:
OPTS=$(getopt -a -n recordScreenshots --options $SHORT --longoptions $LONG -- "$@")
eval set -- "$OPTS"
while :
do
case "$1" in
-t | --token )
TOKEN="$2"
shift 2
;;
-r | --repo )
REPO="$2"
shift 2
;;
--)
shift;
break
;;
*)
echo "Unexpected option: $1"
help
;;
esac
done
if [[ -z ${TOKEN} ]]; then
echo "No token specified, either set the env var GITHUB_TOKEN or use the --token option"
exit 1 exit 1
fi fi
if [[ -z ${GITHUB_REPOSITORY} ]]; then if [[ -z ${REPO} ]]; then
echo "Missing GITHUB_REPOSITORY variable" echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option"
exit 1 exit 1
fi fi
@ -35,6 +65,8 @@ git config user.email "benoitm+elementbot@element.io"
git add -A git add -A
git commit -m "Update screenshots" git commit -m "Update screenshots"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Pushing changes" echo "Pushing changes"
git push "https://$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git" git push "https://$TOKEN@github.com/$REPO.git" $BRANCH:refs/heads/$BRANCH
echo "Done!" echo "Done!"

View file

@ -1,5 +1,6 @@
name: Sync Localazy name: Sync Localazy
on: on:
workflow_dispatch:
schedule: schedule:
# At 00:00 on every Monday UTC # At 00:00 on every Monday UTC
- cron: '0 0 * * 1' - cron: '0 0 * * 1'
@ -25,6 +26,7 @@ jobs:
- name: Create Pull Request for Strings - name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@v5 uses: peter-evans/create-pull-request@v5
with: with:
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
commit-message: Sync Strings from Localazy commit-message: Sync Strings from Localazy
title: Sync Strings title: Sync Strings
body: | body: |

View file

@ -22,12 +22,11 @@ jobs:
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- name: ⏬ Checkout with LFS - name: ⏬ Checkout with LFS
uses: actions/checkout@v3 uses: nschloe/action-cached-lfs-checkout@v1.2.1
with: with:
# Ensure we are building the branch and not the branch after being merged on develop # Ensure we are building the branch and not the branch after being merged on develop
# https://github.com/actions/checkout/issues/881 # https://github.com/actions/checkout/issues/881
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
lfs: 'true'
- name: ☕️ Use JDK 17 - name: ☕️ Use JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v3
with: with:
@ -81,8 +80,12 @@ jobs:
**/out/failures/ **/out/failures/
**/build/reports/tests/*UnitTest/ **/build/reports/tests/*UnitTest/
- name: 🔊 Publish results to Sonar (disabled) - name: 🔊 Publish results to Sonar
run: echo "This is now done only once a day, see nightlyReports.yml" env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
# https://github.com/codecov/codecov-action # https://github.com/codecov/codecov-action
- name: ☂️ Upload coverage reports to codecov - name: ☂️ Upload coverage reports to codecov

View file

@ -7,9 +7,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Validate name: Validate
steps: steps:
- uses: actions/checkout@v3 - uses: nschloe/action-cached-lfs-checkout@v1.2.1
with:
lfs: 'true'
- run: | - run: |
./tools/git/validate_lfs.sh ./tools/git/validate_lfs.sh

7
.idea/dictionaries/bmarty.xml generated Normal file
View file

@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="bmarty">
<words>
<w>homeserver</w>
</words>
</dictionary>
</component>

View file

@ -25,7 +25,7 @@ maestro test \
-e APP_ID=io.element.android.x.debug \ -e APP_ID=io.element.android.x.debug \
-e USERNAME=user \ -e USERNAME=user \
-e PASSWORD=123 \ -e PASSWORD=123 \
-e ROOM_NAME="my room" \ -e ROOM_NAME="MyRoom" \
.maestro/allTests.yaml .maestro/allTests.yaml
``` ```

View file

@ -3,4 +3,15 @@ appId: ${APP_ID}
- tapOn: - tapOn:
id: "login-change_server" id: "login-change_server"
- takeScreenshot: build/maestro/200-ChangeServer - takeScreenshot: build/maestro/200-ChangeServer
- tapOn: "Continue" - tapOn: "matrix.org"
- tapOn:
id: "login-change_server"
- tapOn: "Other"
- tapOn:
id: "change_server-server"
- inputText: "element"
- hideKeyboard
- tapOn: "element.io"
- tapOn: "Cancel"
- back
- back

View file

@ -5,6 +5,8 @@ appId: ${APP_ID}
- takeScreenshot: build/maestro/100-SignIn - takeScreenshot: build/maestro/100-SignIn
- runFlow: changeServer.yaml - runFlow: changeServer.yaml
- runFlow: ../assertions/assertLoginDisplayed.yaml - runFlow: ../assertions/assertLoginDisplayed.yaml
- tapOn:
id: "login-continue"
- tapOn: - tapOn:
id: "login-email_username" id: "login-email_username"
- inputText: ${USERNAME} - inputText: ${USERNAME}

View file

@ -1,5 +1,5 @@
appId: ${APP_ID} appId: ${APP_ID}
--- ---
- extendedWaitUntil: - extendedWaitUntil:
visible: "Welcome back!" visible: "Change account provider"
timeout: 10_000 timeout: 10_000

View file

@ -25,6 +25,6 @@ dependencies {
implementation(libs.anvil.compiler.utils) implementation(libs.anvil.compiler.utils)
implementation("com.squareup:kotlinpoet:1.14.2") implementation("com.squareup:kotlinpoet:1.14.2")
implementation(libs.dagger) implementation(libs.dagger)
compileOnly("com.google.auto.service:auto-service-annotations:1.1.0") compileOnly(libs.google.autoservice.annotations)
kapt("com.google.auto.service:auto-service:1.1.0") kapt(libs.google.autoservice)
} }

View file

@ -23,6 +23,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -32,6 +34,7 @@ import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.designsystem.theme.ElementTheme
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import timber.log.Timber import timber.log.Timber
@ -39,17 +42,28 @@ private val loggerTag = LoggerTag("MainActivity")
class MainActivity : NodeComponentActivity() { class MainActivity : NodeComponentActivity() {
lateinit var mainNode: MainNode private lateinit var mainNode: MainNode
private lateinit var appBindings: AppBindings
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}") Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val appBindings = bindings<AppBindings>() appBindings = bindings<AppBindings>()
appBindings.matrixClientsHolder().restore(savedInstanceState) appBindings.matrixClientsHolder().restore(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
ElementTheme { MainContent(appBindings)
}
}
@Composable
private fun MainContent(appBindings: AppBindings) {
ElementTheme {
CompositionLocalProvider(
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -62,6 +76,7 @@ class MainActivity : NodeComponentActivity() {
plugins = listOf( plugins = listOf(
object : NodeReadyObserver<MainNode> { object : NodeReadyObserver<MainNode> {
override fun init(node: MainNode) { override fun init(node: MainNode) {
Timber.tag(loggerTag.value).w("onMainNodeInit")
mainNode = node mainNode = node
mainNode.handleIntent(intent) mainNode.handleIntent(intent)
} }
@ -80,11 +95,17 @@ class MainActivity : NodeComponentActivity() {
* - a notification is clicked. * - a notification is clicked.
* - the app is going to background (<- this is strange) * - the app is going to background (<- this is strange)
*/ */
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
Timber.tag(loggerTag.value).w("onNewIntent") Timber.tag(loggerTag.value).w("onNewIntent")
intent ?: return // If the mainNode is not init yet, keep the intent for later.
mainNode.handleIntent(intent) // It can happen when the activity is killed by the system. The methods are called in this order :
// onCreate(savedInstanceState=true) -> onNewIntent -> onResume -> onMainNodeInit
if (::mainNode.isInitialized) {
mainNode.handleIntent(intent)
} else {
setIntent(intent)
}
} }
override fun onPause() { override fun onPause() {

View file

@ -57,24 +57,24 @@ class MainNode(
DaggerComponentOwner by mainDaggerComponentOwner { DaggerComponentOwner by mainDaggerComponentOwner {
private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback { private val loggedInFlowNodeCallback = object : LoggedInFlowNode.LifecycleCallback {
override fun onFlowCreated(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(client.sessionId.value, component) mainDaggerComponentOwner.addComponent(identifier, component)
} }
override fun onFlowReleased(client: MatrixClient) { override fun onFlowReleased(identifier: String, client: MatrixClient) {
mainDaggerComponentOwner.removeComponent(client.sessionId.value) mainDaggerComponentOwner.removeComponent(identifier)
} }
} }
private val roomFlowNodeCallback = object : RoomFlowNode.LifecycleCallback { private val roomFlowNodeCallback = object : RoomFlowNode.LifecycleCallback {
override fun onFlowCreated(room: MatrixRoom) { override fun onFlowCreated(identifier: String, room: MatrixRoom) {
val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build() val component = bindings<RoomComponent.ParentBindings>().roomComponentBuilder().room(room).build()
mainDaggerComponentOwner.addComponent(room.roomId.value, component) mainDaggerComponentOwner.addComponent(identifier, component)
} }
override fun onFlowReleased(room: MatrixRoom) { override fun onFlowReleased(identifier: String, room: MatrixRoom) {
mainDaggerComponentOwner.removeComponent(room.roomId.value) mainDaggerComponentOwner.removeComponent(identifier)
} }
} }

View file

@ -18,10 +18,12 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.appnav.di.MatrixClientsHolder import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesTo(AppScope::class) @ContributesTo(AppScope::class)
interface AppBindings { interface AppBindings {
fun matrixClientsHolder(): MatrixClientsHolder fun matrixClientsHolder(): MatrixClientsHolder
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
fun snackbarDispatcher(): SnackbarDispatcher
} }

View file

@ -38,6 +38,10 @@ class MainDaggerComponentsOwner @Inject constructor(@ApplicationContext context:
daggerComponents.remove(identifier) daggerComponents.remove(identifier)
} }
/**
* We expose the dagger components in the opposite order they arrived.
* So we pick the most recent component when searching with the [io.element.android.libraries.architecture.bindings] methods.
*/
override val daggerComponent: Any override val daggerComponent: Any
get() = daggerComponents.values.reversed() get() = daggerComponents.values.reversed()
} }

View file

@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.R
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext
class LoggedInEventProcessor @Inject constructor( class LoggedInEventProcessor @Inject constructor(
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,

View file

@ -64,9 +64,9 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -118,9 +118,9 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
interface LifecycleCallback : NodeLifecycleCallback { interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(client: MatrixClient) = Unit fun onFlowCreated(identifier: String, client: MatrixClient) = Unit
fun onFlowReleased(client: MatrixClient) = Unit fun onFlowReleased(identifier: String, client: MatrixClient) = Unit
} }
data class Inputs( data class Inputs(
@ -139,7 +139,7 @@ class LoggedInFlowNode @AssistedInject constructor(
observeAnalyticsState() observeAnalyticsState()
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(inputs.matrixClient) } plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory() val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory) Coil.setImageLoader(imageLoaderFactory)
inputs.matrixClient.startSync() inputs.matrixClient.startSync()
@ -151,7 +151,7 @@ class LoggedInFlowNode @AssistedInject constructor(
onDestroy = { onDestroy = {
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory() val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
Coil.setImageLoader(imageLoaderFactory) Coil.setImageLoader(imageLoaderFactory)
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.matrixClient) } plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
appNavigationStateService.onLeavingSpace(id) appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id) appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving() loggedInFlowProcessor.stopObserving()
@ -239,8 +239,13 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} else { } else {
val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>() val nodeLifecycleCallbacks = plugins<NodeLifecycleCallback>()
val callback = object : RoomFlowNode.Callback {
override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) }
}
}
val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement) val inputs = RoomFlowNode.Inputs(room, initialElement = navTarget.initialElement)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
} }
} }
NavTarget.Settings -> { NavTarget.Settings -> {

View file

@ -63,7 +63,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
object OnBoarding : NavTarget object OnBoarding : NavTarget
@Parcelize @Parcelize
object LoginFlow : NavTarget data class LoginFlow(
val isAccountCreation: Boolean,
) : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -71,11 +73,11 @@ class NotLoggedInFlowNode @AssistedInject constructor(
NavTarget.OnBoarding -> { NavTarget.OnBoarding -> {
val callback = object : OnBoardingEntryPoint.Callback { val callback = object : OnBoardingEntryPoint.Callback {
override fun onSignUp() { override fun onSignUp() {
//NOOP backstack.push(NavTarget.LoginFlow(isAccountCreation = true))
} }
override fun onSignIn() { override fun onSignIn() {
backstack.push(NavTarget.LoginFlow) backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
} }
} }
onBoardingEntryPoint onBoardingEntryPoint
@ -83,8 +85,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.LoginFlow -> { is NavTarget.LoginFlow -> {
loginEntryPoint.createNode(this, buildContext) loginEntryPoint.nodeBuilder(this, buildContext)
.params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
.build()
} }
} }
} }

View file

@ -39,6 +39,7 @@ 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.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
@ -67,9 +68,13 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins, plugins = plugins,
) { ) {
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
}
interface LifecycleCallback : NodeLifecycleCallback { interface LifecycleCallback : NodeLifecycleCallback {
fun onFlowCreated(room: MatrixRoom) = Unit fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
fun onFlowReleased(room: MatrixRoom) = Unit fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
} }
data class Inputs( data class Inputs(
@ -78,19 +83,20 @@ class RoomFlowNode @AssistedInject constructor(
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val callbacks = plugins.filterIsInstance<Callback>()
init { init {
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
Timber.v("OnCreate") Timber.v("OnCreate")
plugins<LifecycleCallback>().forEach { it.onFlowCreated(inputs.room) } plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.room) }
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers() fetchRoomMembers()
}, },
onDestroy = { onDestroy = {
Timber.v("OnDestroy") Timber.v("OnDestroy")
inputs.room.close() inputs.room.close()
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.room) } plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) }
appNavigationStateService.onLeavingRoom(id) appNavigationStateService.onLeavingRoom(id)
} }
) )
@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
override fun onUserDataClicked(userId: UserId) { override fun onUserDataClicked(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId)) backstack.push(NavTarget.RoomMemberDetails(userId))
} }
override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
}
} }
messagesEntryPoint.createNode(this, buildContext, callback) messagesEntryPoint.createNode(this, buildContext, callback)
} }

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

@ -0,0 +1 @@
Allow forawrding messages from one room to another

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

@ -0,0 +1 @@
Add menu to retry sending failed messages or delete their local echoes.

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

@ -0,0 +1 @@
Add option to report inappropriate content

View file

@ -14,12 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.root package io.element.android.features.analytics.api
sealed interface LoginRootEvents { object Config {
object RetryFetchServerInfo : LoginRootEvents const val POLICY_LINK = "https://element.io/cookie-policy"
data class SetLogin(val login: String) : LoginRootEvents
data class SetPassword(val password: String) : LoginRootEvents
object Submit : LoginRootEvents
object ClearError : LoginRootEvents
} }

View file

@ -41,6 +41,7 @@ dependencies {
api(projects.features.analytics.api) api(projects.features.analytics.api)
api(projects.services.analytics.api) api(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.browser)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)

View file

@ -16,14 +16,19 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import android.app.Activity
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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 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.analytics.api.Config
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
@ -33,12 +38,19 @@ class AnalyticsOptInNode @AssistedInject constructor(
private val presenter: AnalyticsOptInPresenter, private val presenter: AnalyticsOptInPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onClickTerms(activity: Activity, darkTheme: Boolean) {
activity.openUrlInChromeCustomTab(null, darkTheme, Config.POLICY_LINK)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = LocalContext.current as Activity
val isDark = MaterialTheme.colors.isLight.not()
val state = presenter.present() val state = presenter.present()
AnalyticsOptInView( AnalyticsOptInView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onClickTerms = { onClickTerms(activity, isDark) },
) )
} }
} }

View file

@ -16,47 +16,46 @@
package io.element.android.features.analytics.impl package io.element.android.features.analytics.impl
import android.graphics.Typeface import androidx.compose.foundation.background
import android.text.SpannableString import androidx.compose.foundation.clickable
import android.text.style.ForegroundColorSpan import androidx.compose.foundation.layout.Arrangement
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
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.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.filled.Poll
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.analytics.api.AnalyticsOptInEvents import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.LinkColor import io.element.android.libraries.designsystem.ElementTextStyles
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.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.theme.LocalColors
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.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.Text
@ -67,157 +66,148 @@ import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun AnalyticsOptInView( fun AnalyticsOptInView(
state: AnalyticsOptInState, state: AnalyticsOptInState,
onClickTerms: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LogCompositions(tag = "Analytics", msg = "Root") LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink val eventSink = state.eventSink
Box( HeaderFooterPage(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.systemBarsPadding() .systemBarsPadding()
.imePadding() .imePadding(),
header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() },
footer = { AnalyticsOptInFooter(eventSink) })
}
@Composable
private fun AnalyticsOptInHeader(
state: AnalyticsOptInState,
onClickTerms: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Column( IconTitleSubtitleMolecule(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
) { title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
Column(modifier = Modifier.weight(1f)) { subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
Image( iconImageVector = Icons.Filled.Poll
painterResource(id = R.drawable.element_logo_stars), )
contentDescription = null, Text(
modifier = Modifier text = buildAnnotatedStringWithStyledPart(
.align(Alignment.CenterHorizontally) R.string.screen_analytics_prompt_read_terms,
.padding(16.dp) R.string.screen_analytics_prompt_read_terms_content_link,
) color = Color.Unspecified,
Text( underline = false,
text = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName), bold = true,
modifier = Modifier ),
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 16.dp, vertical = 16.dp), .clip(shape = RoundedCornerShape(8.dp))
textAlign = TextAlign.Center, .clickable { onClickTerms() }
fontWeight = FontWeight.Bold, .padding(8.dp),
fontSize = 24.sp, style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center,
) color = MaterialTheme.colorScheme.secondary,
Text( )
text = stringResource(id = R.string.screen_analytics_prompt_help_us_improve, state.applicationName),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
Text(
text = buildAnnotatedStringWithColoredPart(
R.string.screen_analytics_prompt_read_terms,
R.string.screen_analytics_prompt_read_terms_content_link
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
textAlign = TextAlign.Center,
fontSize = 16.sp,
color = MaterialTheme.colorScheme.secondary,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_data_usage).toAnnotatedString(),
color = MaterialTheme.colorScheme.secondary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing).toAnnotatedString(),
color = MaterialTheme.colorScheme.secondary,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary)
Text(
text = stringResource(id = R.string.screen_analytics_prompt_settings),
color = MaterialTheme.colorScheme.secondary,
)
}
}
Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_enable))
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_not_now))
}
Spacer(Modifier.height(40.dp))
}
} }
} }
fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString { @Composable
append(this@toAnnotatedString) private fun AnalyticsOptInContent(
val spannable = SpannableString(this@toAnnotatedString) modifier: Modifier = Modifier,
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> ) {
val start = spannable.getSpanStart(span) Box(
val end = spannable.getSpanEnd(span) modifier = modifier.fillMaxSize(),
when (span) { contentAlignment = BiasAlignment(
is StyleSpan -> when (span.style) { horizontalBias = 0f,
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) verticalBias = -0.4f
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) )
Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end) ) {
} Column(
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) verticalArrangement = Arrangement.spacedBy(4.dp)
is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end) ) {
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
idx = 0
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
idx = 1
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_settings),
idx = 2
)
} }
} }
} }
@Composable @Composable
fun buildAnnotatedStringWithColoredPart( private fun AnalyticsOptInContentRow(
@StringRes fullTextRes: Int, text: String,
@StringRes coloredTextRes: Int, idx: Int,
color: Color = LinkColor, modifier: Modifier = Modifier,
underline: Boolean = true, ) {
) = buildAnnotatedString { val radius = 14.dp
val coloredPart = stringResource(coloredTextRes) val bgShape = when (idx) {
val fullText = stringResource(fullTextRes, coloredPart) 0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
val startIndex = fullText.indexOf(coloredPart) 2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
append(fullText) else -> RoundedCornerShape(0.dp)
addStyle( }
style = SpanStyle( Row(
color = color, modifier = modifier
textDecoration = if (underline) TextDecoration.Underline else null .fillMaxWidth()
), start = startIndex, end = startIndex + coloredPart.length .background(
) color = LocalColors.current.quinary,
shape = bgShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
) {
Icon(
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
// TODO Compound, this color is not yet in the theme
tint = Color(0xFF007A61)
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun AnalyticsOptInFooter(
eventSink: (AnalyticsOptInEvents) -> Unit,
modifier: Modifier = Modifier,
) {
ButtonColumnMolecule(
modifier = modifier,
) {
Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_ok))
}
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = StringR.string.action_not_now))
}
}
} }
@Preview @Preview
@ -234,5 +224,8 @@ fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider:
@Composable @Composable
private fun ContentToPreview(state: AnalyticsOptInState) { private fun ContentToPreview(state: AnalyticsOptInState) {
AnalyticsOptInView(state = state) AnalyticsOptInView(
state = state,
onClickTerms = {},
)
} }

View file

@ -1,57 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="120dp"
android:height="94dp"
android:viewportWidth="120"
android:viewportHeight="94">
<path
android:pathData="M60.396,4.958L60.604,4.958A44.521,44.521 0,0 1,105.125 49.479L105.125,49.479A44.521,44.521 0,0 1,60.604 94L60.396,94A44.521,44.521 0,0 1,15.875 49.479L15.875,49.479A44.521,44.521 0,0 1,60.396 4.958z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M53.228,26.676C53.228,24.958 54.623,23.566 56.344,23.566C67.82,23.566 77.123,32.847 77.123,44.296C77.123,46.014 75.727,47.406 74.006,47.406C72.285,47.406 70.889,46.014 70.889,44.296C70.889,36.282 64.377,29.785 56.344,29.785C54.623,29.785 53.228,28.393 53.228,26.676Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M67.772,72.282C67.772,73.999 66.377,75.391 64.655,75.391C53.18,75.391 43.877,66.11 43.877,54.661C43.877,52.944 45.272,51.552 46.994,51.552C48.715,51.552 50.111,52.944 50.111,54.661C50.111,62.675 56.623,69.172 64.655,69.172C66.377,69.172 67.772,70.564 67.772,72.282Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M37.644,56.734C35.922,56.734 34.527,55.342 34.527,53.625C34.527,42.176 43.83,32.895 55.305,32.895C57.027,32.895 58.422,34.287 58.422,36.004C58.422,37.722 57.027,39.114 55.305,39.114C47.272,39.114 40.76,45.611 40.76,53.625C40.76,55.342 39.365,56.734 37.644,56.734Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M83.356,42.223C85.078,42.223 86.473,43.615 86.473,45.332C86.473,56.781 77.17,66.063 65.695,66.063C63.973,66.063 62.578,64.671 62.578,62.953C62.578,61.236 63.973,59.844 65.695,59.844C73.728,59.844 80.24,53.347 80.24,45.332C80.24,43.615 81.635,42.223 83.356,42.223Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M39.571,77.305C40.181,77.305 40.683,76.856 40.769,76.227C41.7,69.687 42.587,68.79 48.918,68.076C49.56,68.001 50.041,67.478 50.041,66.87C50.041,66.251 49.57,65.75 48.929,65.664C42.63,64.843 41.817,64.043 40.769,57.502C40.662,56.873 40.181,56.435 39.571,56.435C38.972,56.435 38.47,56.873 38.374,57.513C37.454,64.053 36.566,64.95 30.235,65.664C29.594,65.739 29.123,66.251 29.123,66.87C29.123,67.478 29.583,67.99 30.235,68.076C36.534,68.951 37.315,69.697 38.374,76.238C38.491,76.867 38.983,77.305 39.571,77.305Z"
android:strokeWidth="1.5"
android:fillColor="#ffffff"
android:strokeColor="#0DBD8B"/>
<path
android:strokeWidth="1"
android:pathData="M82.194,35.392C82.697,35.392 83.111,35.019 83.182,34.494C83.949,29.044 84.682,28.297 89.905,27.701C90.434,27.639 90.831,27.203 90.831,26.697C90.831,26.181 90.443,25.763 89.913,25.692C84.717,25.007 84.046,24.34 83.182,18.89C83.093,18.365 82.697,18.001 82.194,18.001C81.7,18.001 81.285,18.365 81.205,18.899C80.447,24.349 79.714,25.096 74.491,25.692C73.962,25.754 73.574,26.181 73.574,26.697C73.574,27.203 73.953,27.63 74.491,27.701C79.688,28.43 80.332,29.053 81.205,34.503C81.302,35.028 81.708,35.392 82.194,35.392Z"
android:fillColor="#ffffff"
android:strokeColor="#0DBD8B"/>
<path
android:pathData="M113.846,18.87C114.174,18.87 114.444,18.631 114.49,18.296C114.991,14.807 115.468,14.329 118.873,13.948C119.218,13.908 119.477,13.63 119.477,13.305C119.477,12.975 119.224,12.708 118.879,12.662C115.491,12.224 115.054,11.797 114.49,8.309C114.433,7.973 114.174,7.74 113.846,7.74C113.524,7.74 113.254,7.973 113.202,8.315C112.707,11.803 112.23,12.281 108.825,12.662C108.48,12.702 108.227,12.975 108.227,13.305C108.227,13.63 108.474,13.903 108.825,13.948C112.213,14.415 112.633,14.813 113.202,18.301C113.265,18.637 113.53,18.87 113.846,18.87Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M107.169,9.131C107.354,9.131 107.506,8.997 107.531,8.808C107.813,6.846 108.081,6.577 109.997,6.363C110.191,6.34 110.336,6.183 110.336,6.001C110.336,5.815 110.194,5.665 110,5.639C108.094,5.393 107.849,5.153 107.531,3.191C107.499,3.002 107.354,2.871 107.169,2.871C106.988,2.871 106.836,3.002 106.807,3.194C106.529,5.156 106.26,5.425 104.345,5.639C104.151,5.662 104.008,5.815 104.008,6.001C104.008,6.183 104.147,6.337 104.345,6.363C106.25,6.625 106.486,6.849 106.807,8.811C106.842,9 106.991,9.131 107.169,9.131Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M108.575,24.435C108.8,24.435 108.986,24.271 109.018,24.04C109.362,21.642 109.69,21.314 112.031,21.052C112.268,21.024 112.446,20.833 112.446,20.61C112.446,20.383 112.272,20.199 112.035,20.167C109.706,19.866 109.405,19.573 109.018,17.175C108.978,16.944 108.8,16.783 108.575,16.783C108.353,16.783 108.167,16.944 108.132,17.179C107.792,19.577 107.464,19.905 105.123,20.167C104.885,20.195 104.711,20.383 104.711,20.61C104.711,20.833 104.881,21.02 105.123,21.052C107.452,21.372 107.74,21.646 108.132,24.044C108.175,24.275 108.357,24.435 108.575,24.435Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M6.197,15.392C6.504,15.392 6.758,15.168 6.801,14.853C7.27,11.583 7.718,11.135 10.91,10.778C11.233,10.74 11.476,10.479 11.476,10.175C11.476,9.865 11.239,9.615 10.915,9.572C7.739,9.161 7.329,8.761 6.801,5.491C6.747,5.176 6.504,4.958 6.197,4.958C5.895,4.958 5.642,5.176 5.593,5.496C5.129,8.767 4.682,9.215 1.49,9.572C1.166,9.609 0.929,9.865 0.929,10.175C0.929,10.479 1.161,10.735 1.49,10.778C4.666,11.215 5.059,11.589 5.593,14.859C5.652,15.174 5.9,15.392 6.197,15.392Z"
android:fillColor="#0DBD8B"/>
<path
android:pathData="M13.231,5.653C13.375,5.653 13.493,5.549 13.513,5.402C13.732,3.876 13.941,3.667 15.431,3.5C15.582,3.482 15.695,3.36 15.695,3.218C15.695,3.074 15.584,2.957 15.433,2.937C13.951,2.745 13.76,2.559 13.513,1.033C13.488,0.886 13.375,0.784 13.231,0.784C13.09,0.784 12.972,0.886 12.95,1.035C12.733,2.561 12.524,2.77 11.035,2.937C10.884,2.955 10.773,3.074 10.773,3.218C10.773,3.36 10.881,3.48 11.035,3.5C12.517,3.704 12.7,3.878 12.95,5.404C12.977,5.551 13.093,5.653 13.231,5.653Z"
android:strokeAlpha="0.4"
android:fillColor="#0DBD8B"
android:fillAlpha="0.4"/>
<path
android:pathData="M16.747,11.914C16.89,11.914 17.009,11.809 17.029,11.663C17.248,10.136 17.457,9.927 18.946,9.761C19.097,9.743 19.21,9.621 19.21,9.479C19.21,9.335 19.1,9.218 18.949,9.198C17.467,9.006 17.275,8.819 17.029,7.293C17.004,7.147 16.89,7.044 16.747,7.044C16.606,7.044 16.488,7.147 16.465,7.296C16.249,8.822 16.04,9.031 14.55,9.198C14.399,9.215 14.289,9.335 14.289,9.479C14.289,9.621 14.397,9.741 14.55,9.761C16.032,9.965 16.216,10.139 16.465,11.665C16.493,11.812 16.609,11.914 16.747,11.914Z"
android:strokeAlpha="0.4"
android:fillColor="#0DBD8B"
android:fillAlpha="0.4"/>
</vector>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Nezaznamenáváme ani neprofilujeme žádné údaje o účtu"</string>
<string name="screen_analytics_prompt_help_us_improve">"Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy."</string>
<string name="screen_analytics_prompt_read_terms">"Můžete si přečíst všechny naše podmínky %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"zde"</string>
<string name="screen_analytics_prompt_settings">"Tuto funkci můžete kdykoli vypnout"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Nesdílíme informace s třetími stranami"</string>
<string name="screen_analytics_prompt_title">"Pomozte vylepšit %1$s"</string>
</resources>

View file

@ -1,7 +1,6 @@
<?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_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string> <string name="screen_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string>
<string name="screen_analytics_prompt_help_us_improve">"Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string> <string name="screen_analytics_prompt_read_terms">"Sie können 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">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string> <string name="screen_analytics_prompt_settings">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string>

View file

@ -1,7 +1,6 @@
<?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_analytics_prompt_data_usage"><b>"Nu"</b>" înregistrăm sau profilăm datele contului"</string> <string name="screen_analytics_prompt_data_usage"><b>"Nu"</b>" înregistrăm sau profilăm datele contului"</string>
<string name="screen_analytics_prompt_help_us_improve">"Ajutați-ne să identificăm problemele și să îmbunătățim %1$s prin partajarea datelor de utilizare anonime."</string>
<string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string> <string name="screen_analytics_prompt_read_terms">"Puteți citi toate condițiile noastre %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"aici"</string> <string name="screen_analytics_prompt_read_terms_content_link">"aici"</string>
<string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string> <string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string>

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"We "<b>"don\'t"</b>" record or profile any account data"</string> <string name="screen_analytics_prompt_data_usage">"We won\'t record or profile any personal data"</string>
<string name="screen_analytics_prompt_help_us_improve">"Help us identify issues and improve %1$s by sharing anonymous usage data."</string> <string name="screen_analytics_prompt_help_us_improve">"Share anonymous usage data to help us identify issues."</string>
<string name="screen_analytics_prompt_read_terms">"You can read all our terms %1$s."</string> <string name="screen_analytics_prompt_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"here"</string> <string name="screen_analytics_prompt_read_terms_content_link">"here"</string>
<string name="screen_analytics_prompt_settings">"You can turn this off anytime in settings"</string> <string name="screen_analytics_prompt_settings">"You can turn this off anytime"</string>
<string name="screen_analytics_prompt_third_party_sharing">"We "<b>"don\'t"</b>" share information with third parties"</string> <string name="screen_analytics_prompt_third_party_sharing">"We won\'t share your data with third parties"</string>
<string name="screen_analytics_prompt_title">"Help improve %1$s"</string> <string name="screen_analytics_prompt_title">"Help improve %1$s"</string>
</resources> </resources>

View file

@ -20,6 +20,7 @@ import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.createroom.impl.userlist.UserListDataStore import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -36,7 +37,7 @@ class CreateRoomDataStore @Inject constructor(
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig()) private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
private var cachedAvatarUri: Uri? = null private var cachedAvatarUri: Uri? = null
set(value) { set(value) {
field?.path?.let { File(it) }?.delete() field?.path?.let { File(it) }?.safeDelete()
field = value field = value
} }

View file

@ -131,6 +131,6 @@ class ConfigureRoomPresenter @Inject constructor(
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow() val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
val byteArray = preprocessed.file.readBytes() val byteArray = preprocessed.file.readBytes()
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow() return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
} }
} }

View file

@ -1,8 +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_create_room_action_create_room">"Cameră nouă"</string> <string name="screen_create_room_action_create_room">"Cameră nouă"</string>
<string name="screen_create_room_action_invite_people">"Invitați persoane"</string> <string name="screen_create_room_action_invite_people">"Invitați prieteni în Element"</string>
<string name="screen_create_room_add_people_title">"Adaugați persoane"</string> <string name="screen_create_room_add_people_title">"Invitați persoane"</string>
<string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string> <string name="screen_create_room_error_creating_room">"A apărut o eroare la crearea camerei"</string>
<string name="screen_create_room_private_option_description">"Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior."</string> <string name="screen_create_room_private_option_description">"Mesajele din această cameră sunt criptate. Criptarea nu poate fi dezactivată ulterior."</string>
<string name="screen_create_room_private_option_title">"Cameră privată (doar pe bază de invitație)"</string> <string name="screen_create_room_private_option_title">"Cameră privată (doar pe bază de invitație)"</string>

View file

@ -21,7 +21,6 @@ import app.cash.molecule.RecompositionClock
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.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.ui.media.AvatarAction
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
@ -33,6 +32,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
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
@ -226,6 +226,7 @@ class ConfigureRoomPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
val stateAfterCreateRoom = awaitItem() val stateAfterCreateRoom = awaitItem()
assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java)
@ -234,7 +235,6 @@ class ConfigureRoomPresenterTests {
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java) assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java)
} }
} }

View file

@ -52,10 +52,11 @@ class CreateRoomRootPresenterTests {
fakeMatrixClient = FakeMatrixClient() fakeMatrixClient = FakeMatrixClient()
userRepository = FakeUserRepository() userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter( presenter = CreateRoomRootPresenter(
FakeUserListPresenterFactory(fakeUserListPresenter), presenterFactory =FakeUserListPresenterFactory(fakeUserListPresenter),
userRepository, userRepository= userRepository,
userListDataStore =
UserListDataStore(), UserListDataStore(),
fakeMatrixClient, matrixClient = fakeMatrixClient,
aBuildMeta(), aBuildMeta(),
) )
} }

View file

@ -5,4 +5,5 @@
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string> <string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string> <string name="screen_invites_decline_direct_chat_title">"Chat ablehnen"</string>
<string name="screen_invites_empty_list">"Keine Einladungen"</string> <string name="screen_invites_empty_list">"Keine Einladungen"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
</resources> </resources>

View file

@ -32,12 +32,11 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -48,7 +47,6 @@ class InviteListPresenterTests {
val invitesDataSource = FakeRoomSummaryDataSource() val invitesDataSource = FakeRoomSummaryDataSource()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -73,7 +71,6 @@ class InviteListPresenterTests {
val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -100,10 +97,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - includes sender details for room invites`() = runTest { fun `present - includes sender details for room invites`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -128,10 +123,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - shows confirm dialog for declining direct chat invites`() = runTest { fun `present - shows confirm dialog for declining direct chat invites`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -154,10 +147,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - shows confirm dialog for declining room invites`() = runTest { fun `present - shows confirm dialog for declining room invites`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -180,10 +171,8 @@ class InviteListPresenterTests {
@Test @Test
fun `present - hides confirm dialog when cancelling`() = runTest { fun `present - hides confirm dialog when cancelling`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
FakeSeenInvitesStore(), FakeSeenInvitesStore(),
@ -207,7 +196,6 @@ class InviteListPresenterTests {
fun `present - declines invite after confirming`() = runTest { fun `present - declines invite after confirming`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -234,7 +222,6 @@ class InviteListPresenterTests {
fun `present - declines invite after confirming and sets state on error`() = runTest { fun `present - declines invite after confirming and sets state on error`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -266,7 +253,6 @@ class InviteListPresenterTests {
fun `present - dismisses declining error state`() = runTest { fun `present - dismisses declining error state`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -299,7 +285,6 @@ class InviteListPresenterTests {
fun `present - accepts invites and sets state on success`() = runTest { fun `present - accepts invites and sets state on success`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -323,7 +308,6 @@ class InviteListPresenterTests {
fun `present - accepts invites and sets state on error`() = runTest { fun `present - accepts invites and sets state on error`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -349,7 +333,6 @@ class InviteListPresenterTests {
fun `present - dismisses accepting error state`() = runTest { fun `present - dismisses accepting error state`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation() val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
val client = FakeMatrixClient( val client = FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
@ -379,7 +362,6 @@ class InviteListPresenterTests {
val store = FakeSeenInvitesStore() val store = FakeSeenInvitesStore()
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
store, store,
@ -416,7 +398,6 @@ class InviteListPresenterTests {
store.publishRoomIds(setOf(A_ROOM_ID)) store.publishRoomIds(setOf(A_ROOM_ID))
val presenter = InviteListPresenter( val presenter = InviteListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
), ),
store, store,

View file

@ -213,5 +213,5 @@ private fun TestScope.createPresenter(
): LeaveRoomPresenter = LeaveRoomPresenterImpl( ): LeaveRoomPresenter = LeaveRoomPresenterImpl(
client = client, client = client,
roomMembershipObserver = roomMembershipObserver, roomMembershipObserver = roomMembershipObserver,
dispatchers = testCoroutineDispatchers(testScheduler, false), dispatchers = testCoroutineDispatchers(false),
) )

View file

@ -16,6 +16,19 @@
package io.element.android.features.login.api package io.element.android.features.login.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface LoginEntryPoint : SimpleFeatureEntryPoint interface LoginEntryPoint : FeatureEntryPoint {
data class Params(
val isAccountCreation: Boolean,
)
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun build(): Node
}
}

View file

@ -19,6 +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.8.21"
} }
android { android {
@ -39,13 +40,18 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources) implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
api(projects.features.login.api) api(projects.features.login.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
@ -55,6 +61,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.tests.testutils)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.login.impl
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.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.login.api.LoginEntryPoint import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -26,7 +27,19 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint { class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LoginEntryPoint.NodeBuilder {
return parentNode.createNode<LoginFlowNode>(buildContext) val plugins = ArrayList<Plugin>()
return object : LoginEntryPoint.NodeBuilder {
override fun params(params: LoginEntryPoint.Params): LoginEntryPoint.NodeBuilder {
plugins += LoginFlowNode.Inputs(isAccountCreation = params.isAccountCreation)
return this
}
override fun build(): Node {
return parentNode.createNode<LoginFlowNode>(buildContext, plugins)
}
}
} }
} }

View file

@ -20,7 +20,6 @@ import android.app.Activity
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
@ -29,17 +28,23 @@ 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 com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
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.impl.changeserver.ChangeServerNode 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
import io.element.android.features.login.impl.oidc.webview.OidcNode import io.element.android.features.login.impl.oidc.webview.OidcNode
import io.element.android.features.login.impl.root.LoginRootNode import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.searchaccountprovider.SearchAccountProviderNode
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.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.ElementTheme import io.element.android.libraries.designsystem.theme.ElementTheme
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
@ -51,9 +56,10 @@ class LoginFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker, private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
private val customTabHandler: CustomTabHandler, private val customTabHandler: CustomTabHandler,
private val accountProviderDataSource: AccountProviderDataSource,
) : BackstackNode<LoginFlowNode.NavTarget>( ) : BackstackNode<LoginFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.ConfirmAccountProvider,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -62,12 +68,24 @@ class LoginFlowNode @AssistedInject constructor(
private var activity: Activity? = null private var activity: Activity? = null
private var darkTheme: Boolean = false private var darkTheme: Boolean = false
data class Inputs(
val isAccountCreation: Boolean,
) : NodeInputs
private val inputs: Inputs = inputs()
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
object Root : NavTarget object ConfirmAccountProvider : NavTarget
@Parcelize @Parcelize
object ChangeServer : NavTarget object ChangeAccountProvider : NavTarget
@Parcelize
object SearchAccountProvider : NavTarget
@Parcelize
object LoginPassword : NavTarget
@Parcelize @Parcelize
data class OidcView(val oidcDetails: OidcDetails) : NavTarget data class OidcView(val oidcDetails: OidcDetails) : NavTarget
@ -75,12 +93,11 @@ class LoginFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Root -> { NavTarget.ConfirmAccountProvider -> {
val callback = object : LoginRootNode.Callback { val inputs = ConfirmAccountProviderNode.Inputs(
override fun onChangeHomeServer() { isAccountCreation = inputs.isAccountCreation
backstack.push(NavTarget.ChangeServer) )
} val callback = object : ConfirmAccountProviderNode.Callback {
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
@ -90,11 +107,44 @@ class LoginFlowNode @AssistedInject constructor(
backstack.push(NavTarget.OidcView(oidcDetails)) backstack.push(NavTarget.OidcView(oidcDetails))
} }
} }
}
createNode<LoginRootNode>(buildContext, plugins = listOf(callback))
}
NavTarget.ChangeServer -> createNode<ChangeServerNode>(buildContext) override fun onLoginPasswordNeeded() {
backstack.push(NavTarget.LoginPassword)
}
override fun onChangeAccountProvider() {
backstack.push(NavTarget.ChangeAccountProvider)
}
}
createNode<ConfirmAccountProviderNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.ChangeAccountProvider -> {
val callback = object : ChangeAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
backstack.singleTop(NavTarget.ConfirmAccountProvider)
}
override fun onOtherClicked() {
backstack.push(NavTarget.SearchAccountProvider)
}
}
createNode<ChangeAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.SearchAccountProvider -> {
val callback = object : SearchAccountProviderNode.Callback {
override fun onDone() {
// Go back to the Account Provider screen
backstack.singleTop(NavTarget.ConfirmAccountProvider)
}
}
createNode<SearchAccountProviderNode>(buildContext, plugins = listOf(callback))
}
NavTarget.LoginPassword -> {
createNode<LoginPasswordNode>(buildContext, plugins = listOf())
}
is NavTarget.OidcView -> { is NavTarget.OidcView -> {
val input = OidcNode.Inputs(navTarget.oidcDetails) val input = OidcNode.Inputs(navTarget.oidcDetails)
createNode<OidcNode>(buildContext, plugins = listOf(input)) createNode<OidcNode>(buildContext, plugins = listOf(input))
@ -109,6 +159,7 @@ class LoginFlowNode @AssistedInject constructor(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
activity = null activity = null
accountProviderDataSource.reset()
} }
} }
Children( Children(

View file

@ -0,0 +1,26 @@
/*
* 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.login.impl.accountprovider
data class AccountProvider constructor(
val title: String,
val subtitle: String? = null,
val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false,
val isValid: Boolean = false,
val supportSlidingSync: Boolean = false,
)

View file

@ -0,0 +1,45 @@
/*
* 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.login.impl.accountprovider
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
class AccountProviderDataSource @Inject constructor(
) {
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(
defaultAccountProvider
)
fun flow(): StateFlow<AccountProvider> {
return accountProvider.asStateFlow()
}
fun reset() {
accountProvider.tryEmit(defaultAccountProvider)
}
fun userSelection(data: AccountProvider) {
accountProvider.tryEmit(data)
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider>
get() = sequenceOf(
anAccountProvider(),
anAccountProvider().copy(subtitle = null),
anAccountProvider().copy(subtitle = null, title = "no.sliding.sync", supportSlidingSync = false),
anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false, supportSlidingSync = false),
anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
// Add other state here
)
}
fun anAccountProvider() = AccountProvider(
title = "matrix.org",
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,
isValid = true,
supportSlidingSync = true,
)

View file

@ -0,0 +1,132 @@
/*
* 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.login.impl.accountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun AccountProviderView(
item: AccountProvider,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Column(modifier = modifier
.fillMaxWidth()
.clickable { onClick() }) {
Divider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.isMatrixOrg) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
resourceId = R.drawable.ic_matrix,
tint = Color.Unspecified,
)
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = Icons.Filled.Search,
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = item.title,
style = ElementTextStyles.Regular.headline.copy(textAlign = TextAlign.Start),
color = MaterialTheme.colorScheme.primary,
)
if (item.isPublic) {
Icon(
modifier = Modifier
.padding(start = 10.dp)
.size(16.dp),
resourceId = R.drawable.ic_public,
contentDescription = null,
tint = Color.Unspecified,
)
}
}
if (item.subtitle != null) {
Text(
modifier = Modifier
.padding(start = 46.dp, bottom = 12.dp, end = 26.dp),
text = item.subtitle,
style = ElementTextStyles.Regular.subheadline.copy(textAlign = TextAlign.Start),
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
@Preview
@Composable
fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
ElementPreviewLight { ContentToPreview(item) }
@Preview
@Composable
fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
ElementPreviewDark { ContentToPreview(item) }
@Composable
private fun ContentToPreview(item: AccountProvider) {
AccountProviderView(
item = item,
onClick = { }
)
}

View file

@ -16,8 +16,9 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider
sealed interface ChangeServerEvents { sealed interface ChangeServerEvents {
data class SetServer(val server: String) : ChangeServerEvents data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
object Submit : ChangeServerEvents
object ClearError : ChangeServerEvents object ClearError : ChangeServerEvents
} }

View file

@ -21,8 +21,9 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.util.LoginConstants import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.execute
@ -33,44 +34,43 @@ import kotlinx.coroutines.launch
import java.net.URL import java.net.URL
import javax.inject.Inject import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(private val authenticationService: MatrixAuthenticationService) : Presenter<ChangeServerState> { class ChangeServerPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
) : Presenter<ChangeServerState> {
@Composable @Composable
override fun present(): ChangeServerState { override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
}
val changeServerAction: MutableState<Async<Unit>> = remember { val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
fun handleEvents(event: ChangeServerEvents) { fun handleEvents(event: ChangeServerEvents) {
when (event) { when (event) {
is ChangeServerEvents.SetServer -> { is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
homeserver.value = event.server
handleEvents(ChangeServerEvents.ClearError)
}
ChangeServerEvents.Submit -> {
localCoroutineScope.submit(homeserver, changeServerAction)
}
ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized
} }
} }
return ChangeServerState( return ChangeServerState(
homeserver = homeserver.value,
changeServerAction = changeServerAction.value, changeServerAction = changeServerAction.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.submit(homeserverUrl: MutableState<String>, changeServerAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.changeServer(
data: AccountProvider,
changeServerAction: MutableState<Async<Unit>>,
) = launch {
suspend { suspend {
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value val domain = tryOrNull { URL(data.title) }?.host ?: data.title
authenticationService.setHomeserver(domain).getOrThrow() authenticationService.setHomeserver(domain).map {
homeserverUrl.value = domain authenticationService.getHomeserverDetails().value!!
// Valid, remember user choice
accountProviderDataSource.userSelection(data)
}.getOrThrow()
}.execute(changeServerAction, errorMapping = ChangeServerError::from) }.execute(changeServerAction, errorMapping = ChangeServerError::from)
} }
} }

View file

@ -19,9 +19,6 @@ package io.element.android.features.login.impl.changeserver
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
data class ChangeServerState( data class ChangeServerState(
val homeserver: String,
val changeServerAction: Async<Unit>, val changeServerAction: Async<Unit>,
val eventSink: (ChangeServerEvents) -> Unit, val eventSink: (ChangeServerEvents) -> Unit
) { )
val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading)
}

View file

@ -17,26 +17,16 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.R
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> { open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
override val values: Sequence<ChangeServerState> override val values: Sequence<ChangeServerState>
get() = sequenceOf( get() = sequenceOf(
aChangeServerState(), aChangeServerState(),
aChangeServerState().copy(homeserver = "matrix.org"),
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Loading()),
aChangeServerState().copy(
homeserver = "invalid.org",
changeServerAction = Async.Failure(ChangeServerError.InlineErrorMessage(R.string.screen_change_server_error_invalid_homeserver))
),
aChangeServerState().copy(homeserver = "invalid.org", changeServerAction = Async.Failure(ChangeServerError.SlidingSyncAlert)),
aChangeServerState().copy(homeserver = "matrix.org", changeServerAction = Async.Success(Unit)),
) )
} }
fun aChangeServerState() = ChangeServerState( fun aChangeServerState() = ChangeServerState(
homeserver = "",
changeServerAction = Async.Uninitialized, changeServerAction = Async.Uninitialized,
eventSink = {} eventSink = {}
) )

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -16,276 +16,74 @@
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.changeserver
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.UrlAnnotation
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
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 androidx.compose.ui.unit.dp import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.LinkColor import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.form.textFieldState
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.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChangeServerView( fun ChangeServerView(
state: ChangeServerState, state: ChangeServerState,
onLearnMoreClicked: () -> Unit, onLearnMoreClicked: () -> Unit,
onBackPressed: () -> Unit, onDone: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onChangeServerSuccess: () -> Unit = {},
) { ) {
val eventSink = state.eventSink val eventSink = state.eventSink
val scrollState = rememberScrollState() when (state.changeServerAction) {
val isLoading by remember(state.changeServerAction) { is Async.Failure -> {
derivedStateOf { when (val error = state.changeServerAction.error) {
state.changeServerAction is Async.Loading is ChangeServerError.Error -> {
} ErrorDialog(
} modifier = modifier,
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage content = error.message(),
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert onDismiss = {
val focusManager = LocalFocusManager.current eventSink.invoke(ChangeServerEvents.ClearError)
}
fun submit() {
// Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true)
eventSink(ChangeServerEvents.Submit)
}
Scaffold(
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.verticalScroll(
state = scrollState,
)
.padding(horizontal = 16.dp)
) {
Spacer(Modifier.height(42.dp))
Box(
modifier = Modifier
.size(width = 70.dp, height = 70.dp)
.align(Alignment.CenterHorizontally)
.background(
color = LocalColors.current.quinary,
shape = RoundedCornerShape(14.dp)
)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 32.dp, height = 32.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = R.drawable.ic_homeserver,
contentDescription = "",
) )
} }
Spacer(modifier = Modifier.height(16.dp)) is ChangeServerError.SlidingSyncAlert -> {
Text( SlidingSyncNotSupportedDialog(
text = stringResource(id = R.string.screen_change_server_title), modifier = modifier,
modifier = Modifier onLearnMoreClicked = {
.fillMaxWidth() onLearnMoreClicked()
.align(Alignment.CenterHorizontally), eventSink.invoke(ChangeServerEvents.ClearError)
textAlign = TextAlign.Center, }, onDismiss = {
style = ElementTextStyles.Bold.title2, eventSink.invoke(ChangeServerEvents.ClearError)
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.screen_change_server_subtitle),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
textAlign = TextAlign.Center,
style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.secondary,
)
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.screen_change_server_form_header),
style = ElementTextStyles.Regular.formHeader,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
var homeserverFieldState by textFieldState(stateValue = state.homeserver)
TextField(
value = homeserverFieldState,
readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerServer)
.onTabOrEnterKeyFocusNext(focusManager),
onValueChange = {
homeserverFieldState = it
eventSink(ChangeServerEvents.SetServer(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { submit() }
),
singleLine = true,
maxLines = 1,
trailingIcon = if (homeserverFieldState.isNotEmpty()) {
{
IconButton(onClick = {
eventSink(ChangeServerEvents.SetServer(""))
}, enabled = !isLoading) {
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(StringR.string.action_clear))
}
}
} else null,
isError = invalidHomeserverError != null,
supportingText = {
if (invalidHomeserverError != null) {
Text(invalidHomeserverError.message(), color = MaterialTheme.colorScheme.error)
} else {
val footerMessage = stringResource(R.string.screen_change_server_form_notice, "")
val footerAction = stringResource(StringR.string.action_learn_more)
val footerText = buildAnnotatedString {
val defaultColor = MaterialTheme.colorScheme.tertiary
withStyle(ParagraphStyle(textAlign = TextAlign.Start)) {
withStyle(SpanStyle(color = defaultColor)) {
append(footerMessage)
}
val start = length
withStyle(SpanStyle(color = LinkColor)) {
append(footerAction)
}
addUrlAnnotation(UrlAnnotation(LoginConstants.SLIDING_SYNC_READ_MORE_URL), start, length)
}
}
ClickableLinkText(
text = footerText,
interactionSource = MutableInteractionSource(),
style = ElementTextStyles.Regular.caption1,
)
}
}
)
if (slidingSyncNotSupportedError != null) {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked()
eventSink(ChangeServerEvents.ClearError)
}, onDismiss = {
eventSink(ChangeServerEvents.ClearError)
}) })
} }
Spacer(Modifier.height(32.dp))
ButtonWithProgress(
text = stringResource(id = R.string.screen_change_server_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.changeServerContinue)
)
if (state.changeServerAction is Async.Success) {
onChangeServerSuccess()
}
} }
} }
is Async.Loading -> ProgressDialog()
is Async.Success -> LaunchedEffect(state.changeServerAction) {
onDone()
}
Async.Uninitialized -> Unit
} }
} }
@Composable
internal fun SlidingSyncNotSupportedDialog(onLearnMoreClicked: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more),
onSubmitClicked = onLearnMoreClicked,
onCancelClicked = onDismiss,
emphasizeSubmitButton = true,
title = stringResource(StringR.string.dialog_title_error),
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
)
}
@Preview @Preview
@Composable @Composable
internal fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable
private fun ContentToPreview(state: ChangeServerState) { private fun ContentToPreview(state: ChangeServerState) {
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {}) ChangeServerView(
state = state,
onLearnMoreClicked = {},
onDone = {},
)
} }

View file

@ -0,0 +1,42 @@
/*
* 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.login.impl.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.R as StringR
@Composable
internal fun SlidingSyncNotSupportedDialog(
onLearnMoreClicked: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ConfirmationDialog(
modifier = modifier,
onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more),
onSubmitClicked = onLearnMoreClicked,
onCancelClicked = onDismiss,
emphasizeSubmitButton = true,
title = stringResource(StringR.string.dialog_title_error),
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.error
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,7 +23,7 @@ import io.element.android.features.login.impl.R
import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.AuthenticationException
sealed class ChangeServerError : Throwable() { sealed class ChangeServerError : Throwable() {
data class InlineErrorMessage(@StringRes val messageId: Int) : ChangeServerError() { data class Error(@StringRes val messageId: Int) : ChangeServerError() {
@Composable @Composable
fun message(): String = stringResource(messageId) fun message(): String = stringResource(messageId)
} }
@ -32,7 +32,7 @@ sealed class ChangeServerError : Throwable() {
companion object { companion object {
fun from(error: Throwable): ChangeServerError = when (error) { fun from(error: Throwable): ChangeServerError = when (error) {
is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert
else -> InlineErrorMessage(R.string.screen_change_server_error_invalid_homeserver) else -> Error(R.string.screen_change_server_error_invalid_homeserver)
} }
} }
} }

View file

@ -16,19 +16,21 @@
package io.element.android.features.login.impl.error package io.element.android.features.login.impl.error
import androidx.annotation.StringRes
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
import io.element.android.libraries.matrix.api.auth.AuthErrorCode import io.element.android.libraries.matrix.api.auth.AuthErrorCode
import io.element.android.libraries.matrix.api.auth.AuthenticationException import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.errorCode import io.element.android.libraries.matrix.api.auth.errorCode
import io.element.android.libraries.ui.strings.R.string as StringR import io.element.android.libraries.ui.strings.R as StringR
@StringRes
fun loginError( fun loginError(
throwable: Throwable throwable: Throwable
): Int { ): Int {
val authException = throwable as? AuthenticationException ?: return StringR.error_unknown val authException = throwable as? AuthenticationException ?: return StringR.string.error_unknown
return when (authException.errorCode) { return when (authException.errorCode) {
AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials
AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account
AuthErrorCode.UNKNOWN -> StringR.error_unknown AuthErrorCode.UNKNOWN -> StringR.string.error_unknown
} }
} }

View file

@ -23,6 +23,7 @@ import android.net.Uri
import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsServiceConnection import androidx.browser.customtabs.CustomTabsServiceConnection
import androidx.browser.customtabs.CustomTabsSession import androidx.browser.customtabs.CustomTabsSession
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject import javax.inject.Inject

View file

@ -0,0 +1,26 @@
/*
* 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.login.impl.resolver
data class HomeserverData constructor(
// The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
val homeserverUrl: String,
// True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid
val isWellknownValid: Boolean,
// True if a wellknown file has been found and is valid and is claiming a sliding sync Url
val supportSlidingSync: Boolean,
)

View file

@ -0,0 +1,104 @@
/*
* 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.login.impl.resolver
import io.element.android.features.login.impl.resolver.network.WellknownRequest
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.Collections
import javax.inject.Inject
/**
* Resolve homeserver base on search terms.
*/
class HomeserverResolver @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val wellknownRequest: WellknownRequest,
) {
suspend fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
if (trimmedUserInput.length < 4) return@flow
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
withContext(dispatchers.io) {
list.map { url ->
async {
val wellKnown = tryOrNull {
withTimeout(5000) {
wellknownRequest.execute(url)
}
}
val isValid = wellKnown?.isValid().orFalse()
if (isValid) {
val supportSlidingSync = wellKnown?.supportSlidingSync().orFalse()
// Emit the list as soon as possible
currentList.add(
HomeserverData(
homeserverUrl = url,
isWellknownValid = true,
supportSlidingSync = supportSlidingSync
)
)
withContext(flowContext) {
emit(currentList.toList())
}
}
}
}.awaitAll()
}
// If list is empty, and the user has entered an URL, do not block the user.
if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) {
emit(
listOf(
HomeserverData(
homeserverUrl = trimmedUserInput,
isWellknownValid = false,
supportSlidingSync = false,
)
)
)
}
}
private fun getUrlCandidates(data: String): List<String> {
return buildList {
if (data.contains(".")) {
// TLD detected?
} else {
add("${data}.org")
add("${data}.com")
add("${data}.io")
}
// Always try what the user has entered
add(data)
}
}
}

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.login.impl.resolver.network
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultWellknownRequest @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : WellknownRequest {
/**
* Return the WellKnown data, if found.
* @param baseUrl for instance https://matrix.org
*/
override suspend fun execute(baseUrl: String): WellKnown {
val wellknownApi = retrofitFactory.create(baseUrl)
.create(WellknownAPI::class.java)
return wellknownApi.getWellKnown()
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.login.impl.resolver.network
import io.element.android.libraries.core.bool.orFalse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "m.homeserver": {
* "base_url": "https://matrix.org"
* },
* "m.identity_server": {
* "base_url": "https://vector.im"
* },
* "org.matrix.msc3575.proxy": {
* "url": "https://slidingsync.lab.matrix.org"
* }
* }
* </pre>
* .
*/
@Serializable
data class WellKnown(
@SerialName("m.homeserver")
val homeServer: WellKnownBaseConfig? = null,
@SerialName("m.identity_server")
val identityServer: WellKnownBaseConfig? = null,
@SerialName("org.matrix.msc3575.proxy")
val slidingSyncProxy: WellKnownSlidingSyncConfig? = null,
) {
fun isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}
fun supportSlidingSync(): Boolean {
return slidingSyncProxy?.url?.isNotBlank().orFalse()
}
}

View file

@ -0,0 +1,35 @@
/*
* 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.login.impl.resolver.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "base_url": "https://element.io"
* }
* </pre>
* .
*/
@Serializable
data class WellKnownBaseConfig(
@SerialName("base_url")
val baseURL: String? = null
)

View file

@ -0,0 +1,26 @@
/*
* 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.login.impl.resolver.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WellKnownSlidingSyncConfig(
@SerialName("url")
val url: String? = null,
)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver.network
import retrofit2.http.GET
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): WellKnown
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver.network
interface WellknownRequest {
/**
* Return the WellKnown data, or throw an error if not found.
* @param baseUrl for instance https://matrix.org
*/
suspend fun execute(baseUrl: String): WellKnown
}

View file

@ -1,171 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val defaultOidcActionFlow: DefaultOidcActionFlow,
) : Presenter<LoginRootState> {
@Composable
override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope()
val currentHomeServerDetails = authenticationService.getHomeserverDetails().collectAsState().value
val homeserver = currentHomeServerDetails?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL
val getHomeServerDetailsAction: MutableState<Async<MatrixHomeServerDetails>> = remember {
if (currentHomeServerDetails != null) {
mutableStateOf(Async.Success(currentHomeServerDetails))
} else {
mutableStateOf(Async.Uninitialized)
}
}
LaunchedEffect(Unit) {
if (currentHomeServerDetails == null) {
getHomeServerDetails(homeserver, getHomeServerDetailsAction)
}
}
val loggedInState: MutableState<LoggedInState> = remember {
mutableStateOf(LoggedInState.NotLoggedIn)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
LaunchedEffect(Unit) {
launch {
defaultOidcActionFlow.collect {
onOidcAction(it, loggedInState)
}
}
}
fun handleEvents(event: LoginRootEvents) {
when (event) {
LoginRootEvents.RetryFetchServerInfo -> localCoroutineScope.getHomeServerDetails(homeserver, getHomeServerDetailsAction)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> {
val homeServerDetails = getHomeServerDetailsAction.value.dataOrNull() ?: return
when {
homeServerDetails.supportsOidcLogin -> localCoroutineScope.submitOidc(loggedInState)
homeServerDetails.supportsPasswordLogin -> localCoroutineScope.submit(formState.value, loggedInState)
}
}
LoginRootEvents.ClearError -> loggedInState.value = LoggedInState.NotLoggedIn
}
}
return LoginRootState(
homeserverUrl = homeserver,
homeserverDetails = getHomeServerDetailsAction.value,
loggedInState = loggedInState.value,
formState = formState.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.getHomeServerDetails(
homeserver: String,
state: MutableState<Async<MatrixHomeServerDetails>>,
) = launch {
suspend {
authenticationService.setHomeserver(homeserver)
.map {
authenticationService.getHomeserverDetails().value!!
}
.getOrThrow()
}.execute(state)
}
private fun CoroutineScope.submitOidc(loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
authenticationService.getOidcUrl()
.onSuccess {
loggedInState.value = LoggedInState.OidcStarted(it)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<LoggedInState>) = launch {
loggedInState.value = LoggedInState.LoggingIn
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = LoggedInState.LoggedIn(sessionId)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
private suspend fun onOidcAction(oidcAction: OidcAction?, loggedInState: MutableState<LoggedInState>) {
oidcAction ?: return
loggedInState.value = LoggedInState.LoggingIn
when (oidcAction) {
OidcAction.GoBack -> {
authenticationService.cancelOidcLogin()
.onSuccess {
loggedInState.value = LoggedInState.NotLoggedIn
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
is OidcAction.Success -> {
authenticationService.loginWithOidc(oidcAction.url)
.onSuccess { sessionId ->
loggedInState.value = LoggedInState.LoggedIn(sessionId)
}
.onFailure { failure ->
loggedInState.value = LoggedInState.ErrorLoggingIn(failure)
}
}
}
defaultOidcActionFlow.reset()
}
}

View file

@ -1,76 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.core.SessionId
open class LoginRootStateProvider : PreviewParameterProvider<LoginRootState> {
override val values: Sequence<LoginRootState>
get() = sequenceOf(
aLoginRootState(),
aLoginRootState().copy(
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"some-custom-server.com",
supportsPasswordLogin = true,
supportsOidcLogin = false
)
)
),
aLoginRootState().copy(formState = LoginFormState("user", "pass")),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggingIn),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.ErrorLoggingIn(Throwable())),
aLoginRootState().copy(formState = LoginFormState("user", "pass"), loggedInState = LoggedInState.LoggedIn(SessionId("@user:domain"))),
// Oidc
aLoginRootState().copy(
homeserverUrl = "server-with-oidc.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"server-with-oidc.org",
supportsPasswordLogin = false,
supportsOidcLogin = true
)
)
),
// No password, no oidc support
aLoginRootState().copy(
homeserverUrl = "wrong.org",
homeserverDetails = Async.Success(
MatrixHomeServerDetails(
"wrong.org",
supportsPasswordLogin = false,
supportsOidcLogin = false
)
)
),
// Loading
aLoginRootState().copy(homeserverDetails = Async.Loading()),
//Error
aLoginRootState().copy(homeserverDetails = Async.Failure(Exception("An error occurred"))),
)
}
fun aLoginRootState() = LoginRootState(
homeserverUrl = "matrix.org",
homeserverDetails = Async.Success(MatrixHomeServerDetails("matrix.org", supportsPasswordLogin = true, supportsOidcLogin = false)),
loggedInState = LoggedInState.NotLoggedIn,
formState = LoginFormState.Default,
eventSink = {}
)

View file

@ -14,49 +14,52 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.changeserver package io.element.android.features.login.impl.screens.changeaccountprovider
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.core.plugin.plugins
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.impl.util.LoginConstants import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class ChangeServerNode @AssistedInject constructor( class ChangeAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: ChangeServerPresenter, private val presenter: ChangeAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private fun onSuccess() {
navigateUp() interface Callback : Plugin {
fun onDone()
fun onOtherClicked()
} }
private fun openLearnMorePage(context: Context) { private fun onDone() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL)) plugins<Callback>().forEach { it.onDone() }
tryOrNull { context.startActivity(intent) } }
private fun onOtherClicked() {
plugins<Callback>().forEach { it.onOtherClicked() }
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
val context = LocalContext.current val context = LocalContext.current
ChangeServerView( ChangeAccountProviderView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onChangeServerSuccess = this::onSuccess, onBackPressed = ::navigateUp,
onBackPressed = { navigateUp() },
onLearnMoreClicked = { openLearnMorePage(context) }, onLearnMoreClicked = { openLearnMorePage(context) },
onDone = ::onDone,
onOtherProviderClicked = ::onOtherClicked,
) )
} }
} }

View file

@ -0,0 +1,47 @@
/*
* 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.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class ChangeAccountProviderPresenter @Inject constructor(
private val changeServerPresenter: ChangeServerPresenter,
) : Presenter<ChangeAccountProviderState> {
@Composable
override fun present(): ChangeAccountProviderState {
val changeServerState = changeServerPresenter.present()
return ChangeAccountProviderState(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
supportSlidingSync = true,
)
),
changeServerState = changeServerState,
)
}
}

View file

@ -0,0 +1,26 @@
/*
* 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.login.impl.screens.changeaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerState
// Do not use default value, so no member get forgotten in the presenters.
data class ChangeAccountProviderState constructor(
val accountProviders: List<AccountProvider>,
val changeServerState: ChangeServerState,
)

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.login.impl.screens.changeaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
// Add other state here
)
}
fun aChangeAccountProviderState() = ChangeAccountProviderState(
accountProviders = listOf(
anAccountProvider()
),
changeServerState = aChangeServerState(),
)

View file

@ -0,0 +1,147 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun ChangeAccountProviderView(
state: ChangeAccountProviderState,
onBackPressed: () -> Unit,
onLearnMoreClicked: () -> Unit,
onDone: () -> Unit,
onOtherProviderClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Home,
iconTint = MaterialTheme.colorScheme.primary,
title = stringResource(id = R.string.screen_change_account_provider_title),
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
)
state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView(
item = alteredItem,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
}
)
}
// Other
AccountProviderView(
item = AccountProvider(
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClicked
)
Spacer(Modifier.height(32.dp))
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClicked = onLearnMoreClicked,
onDone = onDone,
)
}
}
}
@Preview
@Composable
fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ChangeAccountProviderState) {
ChangeAccountProviderView(
state = state,
onBackPressed = { },
onLearnMoreClicked = { },
onDone = { },
onOtherProviderClicked = { },
)
}

View file

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

View file

@ -0,0 +1,84 @@
/*
* 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.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
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.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class ConfirmAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ConfirmAccountProviderPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val isAccountCreation: Boolean,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
ConfirmAccountProviderPresenter.Params(
isAccountCreation = inputs.isAccountCreation,
)
)
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onChangeAccountProvider()
}
private fun onOidcDetails(data: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(data) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onChangeAccountProvider() {
plugins<Callback>().forEach { it.onChangeAccountProvider() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ConfirmAccountProviderView(
state = state,
modifier = modifier,
onOidcDetails = ::onOidcDetails,
onLoginPasswordNeeded = ::onLoginPasswordNeeded,
onChange = ::onChangeAccountProvider,
onLearnMoreClicked = { openLearnMorePage(context) },
)
}
}

View file

@ -0,0 +1,100 @@
/*
* 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.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL
class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
)
@AssistedFactory
interface Factory {
fun create(params: Params): ConfirmAccountProviderPresenter
}
@Composable
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow().collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState<Async<LoginFlow>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.title, loginFlowAction)
}
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation,
loginFlow = loginFlowAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(
homeserverUrl: String,
loginFlowAction: MutableState<Async<LoginFlow>>,
) = launch {
suspend {
val domain = tryOrNull { URL(homeserverUrl) }?.host ?: homeserverUrl
authenticationService.setHomeserver(domain).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
throw IllegalStateException("Unsupported login flow")
}
}.getOrThrow()
}.execute(loginFlowAction, errorMapping = ChangeServerError::from)
}
}

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.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
// Do not use default value, so no member get forgotten in the presenters.
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val isAccountCreation: Boolean,
val loginFlow: Async<LoginFlow>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
}
sealed interface LoginFlow {
object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
}

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.login.impl.screens.confirmaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.Async
open class ConfirmAccountProviderStateProvider : PreviewParameterProvider<ConfirmAccountProviderState> {
override val values: Sequence<ConfirmAccountProviderState>
get() = sequenceOf(
aConfirmAccountProviderState(),
// Add other state here
)
}
fun aConfirmAccountProviderState() = ConfirmAccountProviderState(
accountProvider = anAccountProvider(),
isAccountCreation = false,
loginFlow = Async.Uninitialized,
eventSink = {}
)

View file

@ -0,0 +1,163 @@
/*
* 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.login.impl.screens.confirmaccountprovider
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async
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.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onOidcDetails: (OidcDetails) -> Unit,
onLoginPasswordNeeded: () -> Unit,
onLearnMoreClicked: () -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginFlow) {
derivedStateOf {
state.loginFlow is Async.Loading
}
}
val eventSink = state.eventSink
HeaderFooterPage(
modifier = modifier,
header = {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp),
iconImageVector = Icons.Filled.AccountCircle,
title = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_title
} else {
R.string.screen_account_provider_signin_title
},
state.accountProvider.title
),
subTitle = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_subtitle
} else {
R.string.screen_account_provider_signin_subtitle
},
)
)
},
footer = {
ButtonColumnMolecule {
ButtonWithProgress(
text = stringResource(id = R.string.screen_account_provider_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
TextButton(
onClick = onChange,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginChangeServer)
) {
Text(text = stringResource(id = R.string.screen_account_provider_change))
}
}
}
) {
when (state.loginFlow) {
is Async.Failure -> {
when (val error = state.loginFlow.error) {
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onDismiss = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked()
eventSink(ConfirmAccountProviderEvents.ClearError)
}, onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
})
}
}
}
is Async.Loading -> Unit // The Continue button shows the loading state
is Async.Success -> {
when (val loginFlowState = state.loginFlow.state) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
}
}
Async.Uninitialized -> Unit
}
}
}
@Preview
@Composable
fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ConfirmAccountProviderState) {
ConfirmAccountProviderView(
state = state,
onOidcDetails = {},
onLoginPasswordNeeded = {},
onLearnMoreClicked = {},
onChange = {},
)
}

View file

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

View file

@ -0,0 +1,45 @@
/*
* 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.login.impl.screens.loginpassword
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class LoginPasswordNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LoginPasswordPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LoginPasswordView(
state = state,
modifier = modifier,
onBackPressed = ::navigateUp
)
}
}

View file

@ -0,0 +1,90 @@
/*
* 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.login.impl.screens.loginpassword
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
) : Presenter<LoginPasswordState> {
@Composable
override fun present(): LoginPasswordState {
val localCoroutineScope = rememberCoroutineScope()
val loginAction: MutableState<Async<SessionId>> = remember {
mutableStateOf(Async.Uninitialized)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
val accountProvider by accountProviderDataSource.flow().collectAsState()
fun handleEvents(event: LoginPasswordEvents) {
when (event) {
is LoginPasswordEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginPasswordEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginPasswordEvents.Submit -> {
localCoroutineScope.submit(formState.value, loginAction)
}
LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized
}
}
return LoginPasswordState(
accountProvider = accountProvider,
formState = formState.value,
loginAction = loginAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch {
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->
loggedInState.value = Async.Failure(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
}

View file

@ -14,36 +14,23 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.screens.loginpassword
import android.os.Parcelable import android.os.Parcelable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
data class LoginRootState( data class LoginPasswordState(
val homeserverUrl: String, val accountProvider: AccountProvider,
val homeserverDetails: Async<MatrixHomeServerDetails>,
val loggedInState: LoggedInState,
val formState: LoginFormState, val formState: LoginFormState,
val eventSink: (LoginRootEvents) -> Unit val loginAction: Async<SessionId>,
val eventSink: (LoginPasswordEvents) -> Unit
) { ) {
val supportPasswordLogin = (homeserverDetails as? Async.Success)?.state?.supportsPasswordLogin.orFalse()
val supportOidcLogin = (homeserverDetails as? Async.Success)?.state?.supportsOidcLogin.orFalse()
val submitEnabled: Boolean val submitEnabled: Boolean
get() = loggedInState !is LoggedInState.ErrorLoggingIn && get() = loginAction !is Async.Failure &&
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin) ((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
}
sealed interface LoggedInState {
object NotLoggedIn : LoggedInState
object LoggingIn : LoggedInState
data class OidcStarted(val oidcDetail: OidcDetails) : LoggedInState
data class ErrorLoggingIn(val failure: Throwable) : LoggedInState
data class LoggedIn(val sessionId: SessionId) : LoggedInState
} }
@Parcelize @Parcelize

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.libraries.architecture.Async
open class LoginPasswordStateProvider : PreviewParameterProvider<LoginPasswordState> {
override val values: Sequence<LoginPasswordState>
get() = sequenceOf(
aLoginPasswordState(),
// Loading
aLoginPasswordState().copy(loginAction = Async.Loading()),
// Error
aLoginPasswordState().copy(loginAction = Async.Failure(Exception("An error occurred"))),
)
}
fun aLoginPasswordState() = LoginPasswordState(
accountProvider = anAccountProvider(),
formState = LoginFormState.Default,
loginAction = Async.Uninitialized,
eventSink = {}
)

View file

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,15 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -30,31 +26,25 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -62,7 +52,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -70,8 +59,7 @@ import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.error.loginError import io.element.android.features.login.impl.error.loginError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
@ -86,23 +74,20 @@ import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.autofill import io.element.android.libraries.designsystem.theme.components.autofill
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun LoginRootView( fun LoginPasswordView(
state: LoginRootState, state: LoginPasswordState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onOidcDetails: (OidcDetails) -> Unit = {},
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
) { ) {
val isLoading by remember(state.loggedInState) { val isLoading by remember(state.loginAction) {
derivedStateOf { derivedStateOf {
state.loggedInState == LoggedInState.LoggingIn state.loginAction is Async.Loading
} }
} }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -111,10 +96,11 @@ fun LoginRootView(
// Clear focus to prevent keyboard issues with textfields // Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
state.eventSink(LoginRootEvents.Submit) state.eventSink(LoginPasswordEvents.Submit)
} }
Scaffold( Scaffold(
modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
@ -123,7 +109,7 @@ fun LoginRootView(
} }
) { padding -> ) { padding ->
Box( Box(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(padding) .padding(padding)
@ -136,142 +122,48 @@ fun LoginRootView(
.verticalScroll(state = scrollState) .verticalScroll(state = scrollState)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
Spacer(Modifier.height(16.dp))
// Title // Title
Text( IconTitleSubtitleMolecule(
text = stringResource(id = R.string.screen_login_title), modifier = Modifier.padding(top = 20.dp),
iconImageVector = Icons.Filled.AccountCircle,
title = stringResource(
id = R.string.screen_account_provider_signin_title,
state.accountProvider.title
),
subTitle = stringResource(id = R.string.screen_login_form_header)
)
Spacer(Modifier.height(32.dp))
LoginForm(state = state,
isLoading = isLoading,
onSubmit = ::submit
)
Spacer(Modifier.height(28.dp))
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled,
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth()
style = ElementTextStyles.Bold.title1, .testTag(TestTags.loginContinue)
color = MaterialTheme.colorScheme.primary,
) )
Spacer(Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
ChangeServerSection(
interactionEnabled = !isLoading,
homeserver = state.homeserverUrl,
onChangeServer = onChangeServer
)
Spacer(Modifier.height(32.dp))
when (state.homeserverDetails) {
Async.Uninitialized,
is Async.Loading -> AsyncLoading()
is Async.Failure -> AsyncFailure(
throwable = state.homeserverDetails.error,
onRetry = {
state.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
}
)
is Async.Success -> ServerDetailForm(state, isLoading, ::submit)
}
} }
when (val loggedInState = state.loggedInState) {
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail) if (state.loginAction is Async.Failure) {
else -> Unit LoginErrorDialog(error = state.loginAction.error, onDismiss = {
state.eventSink(LoginPasswordEvents.ClearError)
})
} }
} }
} }
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
LoginErrorDialog(error = state.loggedInState.failure, onDismiss = {
state.eventSink(LoginRootEvents.ClearError)
})
}
}
@Composable
fun ServerDetailForm(
state: LoginRootState,
isLoading: Boolean,
submit: () -> Unit,
modifier: Modifier = Modifier,
) {
when {
state.supportOidcLogin -> {
// Oidc, in this case, just display a Spacer and the submit button
Spacer(modifier.height(28.dp))
}
state.supportPasswordLogin -> {
LoginForm(state = state, isLoading = isLoading, onSubmit = submit, modifier = modifier)
}
else -> {
Text(modifier = modifier, text = "No supported login flow")
}
}
Spacer(Modifier.height(28.dp))
if (state.supportOidcLogin || state.supportPasswordLogin) {
// Submit
ButtonWithProgress(
text = stringResource(R.string.screen_login_submit),
showProgress = isLoading,
onClick = submit,
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
internal fun ChangeServerSection(
interactionEnabled: Boolean,
homeserver: String,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
Text(
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
text = stringResource(id = R.string.screen_login_server_header),
style = ElementTextStyles.Regular.formHeader,
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(TestTags.loginChangeServer)
.clickable {
if (interactionEnabled) {
onChangeServer()
}
},
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = homeserver,
style = ElementTextStyles.Bold.body,
textAlign = TextAlign.Start,
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 16.dp)
)
IconButton(
modifier = Modifier.size(24.dp),
onClick = {
if (interactionEnabled) {
onChangeServer()
}
}
) {
Icon(imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary)
}
Spacer(Modifier.width(8.dp))
}
}
} }
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
internal fun LoginForm( internal fun LoginForm(
state: LoginRootState, state: LoginPasswordState,
isLoading: Boolean, isLoading: Boolean,
onSubmit: () -> Unit, onSubmit: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -299,14 +191,14 @@ internal fun LoginForm(
.testTag(TestTags.loginEmailUsername) .testTag(TestTags.loginEmailUsername)
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = { .autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
loginFieldState = it loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it)) eventSink(LoginPasswordEvents.SetLogin(it))
}), }),
label = { label = {
Text(text = stringResource(R.string.screen_login_username_hint)) Text(text = stringResource(R.string.screen_login_username_hint))
}, },
onValueChange = { onValueChange = {
loginFieldState = it loginFieldState = it
eventSink(LoginRootEvents.SetLogin(it)) eventSink(LoginPasswordEvents.SetLogin(it))
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email, keyboardType = KeyboardType.Email,
@ -316,7 +208,6 @@ internal fun LoginForm(
focusManager.moveFocus(FocusDirection.Down) focusManager.moveFocus(FocusDirection.Down)
}), }),
singleLine = true, singleLine = true,
maxLines = 1,
trailingIcon = if (loginFieldState.isNotEmpty()) { trailingIcon = if (loginFieldState.isNotEmpty()) {
{ {
IconButton(onClick = { IconButton(onClick = {
@ -329,7 +220,7 @@ internal fun LoginForm(
) )
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
if (state.loggedInState is LoggedInState.LoggingIn) { if (state.loginAction is Async.Loading) {
// Ensure password is hidden when user submits the form // Ensure password is hidden when user submits the form
passwordVisible = false passwordVisible = false
} }
@ -343,11 +234,11 @@ internal fun LoginForm(
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = { .autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
passwordFieldState = it passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it)) eventSink(LoginPasswordEvents.SetPassword(it))
}), }),
onValueChange = { onValueChange = {
passwordFieldState = it passwordFieldState = it
eventSink(LoginRootEvents.SetPassword(it)) eventSink(LoginPasswordEvents.SetPassword(it))
}, },
label = { label = {
Text(text = stringResource(R.string.screen_login_password_hint)) Text(text = stringResource(R.string.screen_login_password_hint))
@ -371,7 +262,6 @@ internal fun LoginForm(
onDone = { onSubmit() } onDone = { onSubmit() }
), ),
singleLine = true, singleLine = true,
maxLines = 1,
) )
} }
} }
@ -386,17 +276,17 @@ internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
@Preview @Preview
@Composable @Composable
internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) = internal fun LoginPasswordViewLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewLight { ContentToPreview(state) } ElementPreviewLight { ContentToPreview(state) }
@Preview @Preview
@Composable @Composable
internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) = internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
ElementPreviewDark { ContentToPreview(state) } ElementPreviewDark { ContentToPreview(state) }
@Composable @Composable
private fun ContentToPreview(state: LoginRootState) { private fun ContentToPreview(state: LoginPasswordState) {
LoginRootView( LoginPasswordView(
state = state, state = state,
onBackPressed = {} onBackPressed = {}
) )

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.login.impl.screens.searchaccountprovider
sealed interface SearchAccountProviderEvents {
/**
* The user has typed something, expect to get a list of matching account provider results
* in the state.
*/
data class UserInput(val input: String) : SearchAccountProviderEvents
}

View file

@ -14,10 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.login.impl.root package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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
@ -25,38 +26,34 @@ import com.bumble.appyx.core.plugin.plugins
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.impl.util.openLearnMorePage
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class LoginRootNode @AssistedInject constructor( class SearchAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: LoginRootPresenter, private val presenter: SearchAccountProviderPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun onChangeHomeServer() fun onDone()
fun onOidcDetails(oidcDetails: OidcDetails)
} }
private fun onChangeHomeServer() { private fun onDone() {
plugins<Callback>().forEach { it.onChangeHomeServer() } plugins<Callback>().forEach { it.onDone() }
}
private fun onOidcDetails(oidcDetails: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LoginRootView( val context = LocalContext.current
SearchAccountProviderView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onChangeServer = ::onChangeHomeServer, onBackPressed = ::navigateUp,
onOidcDetails = ::onOidcDetails, onLearnMoreClicked = { openLearnMorePage(context) },
onBackPressed = ::navigateUp onDone = ::onDone,
) )
} }
} }

View file

@ -0,0 +1,85 @@
/*
* 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.login.impl.screens.searchaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
class SearchAccountProviderPresenter @Inject constructor(
private val homeserverResolver: HomeserverResolver,
private val changeServerPresenter: ChangeServerPresenter,
) : Presenter<SearchAccountProviderState> {
@Composable
override fun present(): SearchAccountProviderState {
var userInput by rememberSaveable {
mutableStateOf("")
}
val changeServerState = changeServerPresenter.present()
val data: MutableState<Async<List<HomeserverData>>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(userInput) {
onUserInput(userInput, data)
}
fun handleEvents(event: SearchAccountProviderEvents) {
when (event) {
is SearchAccountProviderEvents.UserInput -> {
userInput = event.input
}
}
}
return SearchAccountProviderState(
userInput = userInput,
userInputResult = data.value,
changeServerState = changeServerState,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<Async<List<HomeserverData>>>) = launch {
data.value = Async.Uninitialized
// Debounce
delay(300)
data.value = Async.Loading()
homeserverResolver.resolve(userInput).collect {
data.value = Async.Success(it)
}
if (data.value !is Async.Success) {
data.value = Async.Uninitialized
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.searchaccountprovider
import io.element.android.features.login.impl.changeserver.ChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.Async
// Do not use default value, so no member get forgotten in the presenters.
data class SearchAccountProviderState(
val userInput: String,
val userInputResult: Async<List<HomeserverData>>,
val changeServerState: ChangeServerState,
val eventSink: (SearchAccountProviderEvents) -> Unit
)

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.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
override val values: Sequence<SearchAccountProviderState>
get() = sequenceOf(
aSearchAccountProviderState(),
aSearchAccountProviderState(userInputResult = Async.Success(aHomeserverDataList())),
// Add other state here
)
}
fun aSearchAccountProviderState(
userInput: String = "",
userInputResult: Async<List<HomeserverData>> = Async.Uninitialized,
) = SearchAccountProviderState(
userInput = userInput,
userInputResult = userInputResult,
changeServerState = aChangeServerState(),
eventSink = {}
)
fun aHomeserverDataList(): List<HomeserverData> {
return listOf(
aHomeserverData(isWellknownValid = true, supportSlidingSync = true),
aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true, supportSlidingSync = false),
aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false, supportSlidingSync = false),
)
}
fun aHomeserverData(
homeserverUrl: String = "https://matrix.org",
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): HomeserverData {
return HomeserverData(
homeserverUrl = homeserverUrl,
isWellknownValid = isWellknownValid,
supportSlidingSync = supportSlidingSync,
)
}

View file

@ -0,0 +1,230 @@
/*
* 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
*/
@Composable
fun SearchAccountProviderView(
state: SearchAccountProviderState,
onBackPressed: () -> Unit,
onLearnMoreClicked: () -> Unit,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
item {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Search,
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
}
item {
// TextInput
var userInputState by textFieldState(stateValue = state.userInput)
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = userInputState,
// readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer),
onValueChange = {
userInputState = it
eventSink(SearchAccountProviderEvents.UserInput(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) {
{
IconButton(onClick = {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(StringR.string.action_clear)
)
}
}
} else null,
supportingText = {
Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary)
}
)
}
when (state.userInputResult) {
is Async.Failure -> {
// Ignore errors (let the user type more chars)
}
is Async.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
is Async.Success -> {
items(state.userInputResult.state) { homeserverData ->
val item = homeserverData.toAccountProvider()
AccountProviderView(
item = item,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
}
)
}
}
Async.Uninitialized -> Unit
}
item {
Spacer(Modifier.height(32.dp))
}
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClicked = onLearnMoreClicked,
onDone = onDone,
)
}
}
}
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == "https://matrix.org"
return AccountProvider(
title = homeserverUrl.removePrefix("http://").removePrefix("https://"),
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
isPublic = isMatrixOrg, // There is no need to know for other servers right now
isMatrixOrg = isMatrixOrg,
isValid = isWellknownValid,
supportSlidingSync = supportSlidingSync,
)
}
@Preview
@Composable
fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SearchAccountProviderState) {
SearchAccountProviderView(
state = state,
onBackPressed = {},
onLearnMoreClicked = {},
onDone = {},
)
}

View file

@ -16,8 +16,18 @@
package io.element.android.features.login.impl.util package io.element.android.features.login.impl.util
import io.element.android.features.login.impl.accountprovider.AccountProvider
object LoginConstants { object LoginConstants {
const val MATRIX_ORG_URL = "matrix.org"
const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev" const val DEFAULT_HOMESERVER_URL = "matrix.org" // TODO Oidc "synapse-oidc.lab.element.dev"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
} }
val defaultAccountProvider = AccountProvider(
title = LoginConstants.DEFAULT_HOMESERVER_URL,
subtitle = null,
isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
)

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import io.element.android.libraries.core.data.tryOrNull
fun openLearnMorePage(context: Context) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
tryOrNull { context.startActivity(intent) }
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,0L8,0A8,8 0,0 1,16 8L16,8A8,8 0,0 1,8 16L8,16A8,8 0,0 1,0 8L0,8A8,8 0,0 1,8 0z"
android:fillColor="#101317"/>
<path
android:pathData="M5.355,5.141V5.85H5.375C5.564,5.579 5.793,5.37 6.059,5.223C6.324,5.073 6.632,5 6.976,5C7.307,5 7.609,5.065 7.883,5.192C8.157,5.319 8.363,5.548 8.507,5.87C8.662,5.641 8.874,5.438 9.139,5.263C9.405,5.088 9.721,5 10.085,5C10.362,5 10.619,5.034 10.856,5.102C11.094,5.169 11.294,5.277 11.464,5.426C11.633,5.576 11.763,5.768 11.859,6.008C11.952,6.248 12,6.536 12,6.875V10.38H10.563V7.412C10.563,7.236 10.557,7.07 10.543,6.915C10.529,6.759 10.492,6.624 10.433,6.511C10.371,6.395 10.283,6.305 10.165,6.237C10.046,6.169 9.885,6.135 9.684,6.135C9.481,6.135 9.317,6.175 9.193,6.251C9.069,6.33 8.97,6.429 8.899,6.556C8.829,6.68 8.781,6.821 8.758,6.982C8.736,7.14 8.722,7.301 8.722,7.462V10.38H7.284V7.443C7.284,7.287 7.281,7.135 7.273,6.982C7.267,6.83 7.236,6.691 7.185,6.562C7.134,6.435 7.05,6.33 6.931,6.254C6.813,6.178 6.64,6.138 6.409,6.138C6.341,6.138 6.251,6.152 6.14,6.183C6.03,6.214 5.92,6.271 5.816,6.355C5.711,6.44 5.621,6.562 5.547,6.72C5.474,6.878 5.437,7.087 5.437,7.344V10.382H4V5.141H5.355Z"
android:fillColor="#EBEEF2"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M16,8C16,12.418 12.418,16 8,16C3.582,16 0,12.418 0,8C0,3.582 3.582,0 8,0C12.418,0 16,3.582 16,8Z"
android:fillColor="#818A95"/>
<path
android:pathData="M12.473,12.527L13.079,10.656C13.087,10.631 13.091,10.605 13.091,10.579V10.065C13.091,10 13.066,9.938 13.02,9.891L12.483,9.339C12.464,9.319 12.442,9.303 12.418,9.291L11.218,8.674C11.194,8.661 11.172,8.645 11.153,8.625L10.619,8.076C10.572,8.028 10.507,8 10.44,8H8.25C8.112,8 8,7.888 8,7.75V6.941C8,6.803 7.888,6.691 7.75,6.691H6.341C6.203,6.691 6.091,6.579 6.091,6.441V5.689C6.091,5.53 6.236,5.412 6.391,5.444L8.972,5.975C9.128,6.007 9.273,5.888 9.273,5.73V4.829C9.273,4.764 9.298,4.701 9.344,4.655L11.012,2.938C11.107,2.841 11.107,2.687 11.012,2.59L9.948,1.494C9.922,1.468 9.892,1.448 9.857,1.435L9.37,1.249C8.482,0.911 7.507,0.88 6.6,1.161L6.091,1.318C4.776,1.747 3.742,2.774 3.305,4.086L3.081,4.758C3.027,4.919 2.995,5.086 2.986,5.255L2.915,6.582C2.911,6.652 2.937,6.72 2.985,6.77L4.108,7.925C4.155,7.973 4.22,8 4.288,8H5.394C5.434,8 5.473,8.01 5.508,8.028L6.592,8.585C6.675,8.628 6.727,8.714 6.727,8.807V10.561C6.727,10.599 6.736,10.636 6.753,10.67L7.295,11.787C7.337,11.873 7.424,11.927 7.52,11.927H8.531C8.598,11.927 8.663,11.955 8.71,12.003L9.273,12.582L9.881,13.208C9.9,13.227 9.915,13.249 9.927,13.273L10.39,14.225C10.466,14.381 10.673,14.415 10.794,14.29L11.182,13.891L11.818,13.237L12.414,12.624C12.441,12.596 12.461,12.563 12.473,12.527Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -1,5 +1,19 @@
<?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_account_provider_change">"Změna poskytovatele účtu"</string>
<string name="screen_account_provider_continue">"Pokračovat"</string>
<string name="screen_account_provider_form_hint">"Adresa domovského serveru"</string>
<string name="screen_account_provider_form_notice">"Zadejte hledaný výraz nebo adresu domény."</string>
<string name="screen_account_provider_form_subtitle">"Vyhledejte společnost, komunitu nebo soukromý server."</string>
<string name="screen_account_provider_form_title">"Najít poskytovatele účtu"</string>
<string name="screen_account_provider_signin_subtitle">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_account_provider_signin_title">"Chystáte se přihlásit do %s"</string>
<string name="screen_account_provider_signup_subtitle">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_account_provider_signup_title">"Chystáte se vytvořit účet na %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org je otevřená síť pro bezpečnou, decentralizovanou komunikaci."</string>
<string name="screen_change_account_provider_other">"Jiný"</string>
<string name="screen_change_account_provider_subtitle">"Použijte jiného poskytovatele účtu, například vlastní soukromý server nebo pracovní účet."</string>
<string name="screen_change_account_provider_title">"Změnit poskytovatele účtu"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string> <string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Tento server v současné době nepodporuje klouzavou synchronizaci."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Tento server v současné době nepodporuje klouzavou synchronizaci."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string> <string name="screen_change_server_form_header">"Adresa URL domovského serveru"</string>
@ -13,6 +27,12 @@
<string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string> <string name="screen_login_server_header">"Kde budou vaše konverzace probíhat"</string>
<string name="screen_login_title">"Vítejte zpět!"</string> <string name="screen_login_title">"Vítejte zpět!"</string>
<string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string> <string name="screen_login_title_with_homeserver">"Přihlaste se k %1$s"</string>
<string name="screen_server_confirmation_change_server">"Změnit poskytovatele účtu"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Soukromý server pro zaměstnance Elementu."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_server_confirmation_message_register">"Zde budou uloženy vaše konverzace - podobně jako u poskytovatele e-mailových služeb uchováváte své e-maily."</string>
<string name="screen_server_confirmation_title_login">"Chystáte se přihlásit do služby %1$s"</string>
<string name="screen_server_confirmation_title_register">"Chystáte se vytvořit účet na %1$s"</string>
<string name="screen_change_server_submit">"Pokračovat"</string> <string name="screen_change_server_submit">"Pokračovat"</string>
<string name="screen_change_server_title">"Vyberte svůj server"</string> <string name="screen_change_server_title">"Vyberte svůj server"</string>
<string name="screen_login_password_hint">"Heslo"</string> <string name="screen_login_password_hint">"Heslo"</string>

View file

@ -1,5 +1,18 @@
<?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_account_provider_change">"Kontoanbieter wechseln"</string>
<string name="screen_account_provider_continue">"Weiter"</string>
<string name="screen_account_provider_form_hint">"Adresse des Homeservers"</string>
<string name="screen_account_provider_form_notice">"Geben Sie einen Suchbegriff oder eine Domainadresse ein."</string>
<string name="screen_account_provider_form_subtitle">"Suche nach einem Unternehmen, einer Community oder einem privaten Server."</string>
<string name="screen_account_provider_form_title">"Finde einen Accountanbieter"</string>
<string name="screen_account_provider_signin_title">"Du bist dabei dich bei %s anzumelden"</string>
<string name="screen_account_provider_signup_subtitle">"Hier werden deine Konversationen stattfinden — genauso wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
<string name="screen_account_provider_signup_title">"Du bist dabei einen Account auf %s zu erstellen"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org ist ein offenes Netzwerk für sichere, dezentralisierte Kommunikation."</string>
<string name="screen_change_account_provider_other">"Andere"</string>
<string name="screen_change_account_provider_subtitle">"Verwende einen anderen Kontoanbieter, z. B. deinen eigenen privaten Server oder ein Arbeitskonto."</string>
<string name="screen_change_account_provider_title">"Kontoanbieter ändern"</string>
<string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string> <string name="screen_change_server_error_invalid_homeserver">"Wir konnten diesen Homeserver nicht erreichen. Bitte überprüfen Sie, ob Sie die Homeserver-URL korrekt eingegeben haben. Wenn die URL korrekt ist, wenden Sie sich an Ihren Homeserver-Administrator, um weitere Hilfe zu erhalten."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit keine Sliding Sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Dieser Server unterstützt derzeit keine Sliding Sync."</string>
<string name="screen_change_server_form_header">"Homeserver-URL"</string> <string name="screen_change_server_form_header">"Homeserver-URL"</string>
@ -13,6 +26,12 @@
<string name="screen_login_server_header">"Wo deine Gespräche leben"</string> <string name="screen_login_server_header">"Wo deine Gespräche leben"</string>
<string name="screen_login_title">"Willkommen zurück!"</string> <string name="screen_login_title">"Willkommen zurück!"</string>
<string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string> <string name="screen_login_title_with_homeserver">"Bei %1$s anmelden"</string>
<string name="screen_server_confirmation_change_server">"Kontoanbieter wechseln"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Ein privater Server für Element-Mitarbeiter."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix ist ein offenes Netzwerk für sichere, dezentrale Kommunikation"</string>
<string name="screen_server_confirmation_message_register">"Hier werden deine Konversationen stattfinden — genau so wie du einen E-Mail-Anbieter verwenden würdest, um deine E-Mails aufzubewahren."</string>
<string name="screen_server_confirmation_title_login">"Du bist dabei dich bei %1$s anzumelden"</string>
<string name="screen_server_confirmation_title_register">"Du bist dabei einen Account auf %1$s zu erstellen"</string>
<string name="screen_change_server_submit">"Weiter"</string> <string name="screen_change_server_submit">"Weiter"</string>
<string name="screen_change_server_title">"Wählen deinen Server"</string> <string name="screen_change_server_title">"Wählen deinen Server"</string>
<string name="screen_login_password_hint">"Passwort"</string> <string name="screen_login_password_hint">"Passwort"</string>

View file

@ -1,5 +1,18 @@
<?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_account_provider_change">"Schimbați furnizorul contului"</string>
<string name="screen_account_provider_continue">"Continuați"</string>
<string name="screen_account_provider_form_hint">"Adresa Homeserver-ului"</string>
<string name="screen_account_provider_form_notice">"Introduceţi un termen de căutare sau o adresă de domeniu."</string>
<string name="screen_account_provider_form_subtitle">"Căutați o companie, o comunitate sau un server privat."</string>
<string name="screen_account_provider_form_title">"Găsiți un furnizor de cont"</string>
<string name="screen_account_provider_signin_title">"Sunteți pe cale să vă conectați la %s"</string>
<string name="screen_account_provider_signup_subtitle">"Aici vor trăi conversațiile - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_account_provider_signup_title">"Sunteți pe cale să creați un cont pe %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_change_account_provider_other">"Altul"</string>
<string name="screen_change_account_provider_subtitle">"Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu."</string>
<string name="screen_change_account_provider_title">"Schimbați furnizorul contului"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar."</string> <string name="screen_change_server_error_invalid_homeserver">"Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"Momentan acest server nu oferă suport pentru sliding sync."</string>
<string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string> <string name="screen_change_server_form_header">"Adresa URL a homeserver-ului"</string>
@ -12,6 +25,13 @@
<string name="screen_login_form_header">"Introduceți detaliile"</string> <string name="screen_login_form_header">"Introduceți detaliile"</string>
<string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string> <string name="screen_login_server_header">"Locul unde trăiesc conversațiile tale"</string>
<string name="screen_login_title">"Bine ați revenit!"</string> <string name="screen_login_title">"Bine ați revenit!"</string>
<string name="screen_login_title_with_homeserver">"Conectați-vă la %1$s"</string>
<string name="screen_server_confirmation_change_server">"Schimbați furnizorul contului"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"Un server privat pentru angajații Element."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."</string>
<string name="screen_server_confirmation_message_register">"Aici vor trăi conversațiile dvs. - la fel cum ați folosi un furnizor de e-mail pentru a vă păstra e-mailurile."</string>
<string name="screen_server_confirmation_title_login">"Sunteți pe cale să vă conectați la %1$s"</string>
<string name="screen_server_confirmation_title_register">"Sunteți pe cale să creați un cont pe %1$s"</string>
<string name="screen_change_server_submit">"Continuați"</string> <string name="screen_change_server_submit">"Continuați"</string>
<string name="screen_change_server_title">"Selectați serverul"</string> <string name="screen_change_server_title">"Selectați serverul"</string>
<string name="screen_login_password_hint">"Parola"</string> <string name="screen_login_password_hint">"Parola"</string>

View file

@ -1,5 +1,19 @@
<?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_account_provider_change">"Change account provider"</string>
<string name="screen_account_provider_continue">"Continue"</string>
<string name="screen_account_provider_form_hint">"Homeserver address"</string>
<string name="screen_account_provider_form_notice">"Enter a search term or a domain address."</string>
<string name="screen_account_provider_form_subtitle">"Search for a company, community, or private server."</string>
<string name="screen_account_provider_form_title">"Find an account provider"</string>
<string name="screen_account_provider_signin_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signin_title">"Youre about to sign in to %s"</string>
<string name="screen_account_provider_signup_subtitle">"This is where you conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_account_provider_signup_title">"Youre about to create an account on %s"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org is an open network for secure, decentralized communication."</string>
<string name="screen_change_account_provider_other">"Other"</string>
<string name="screen_change_account_provider_subtitle">"Use a different account provider, such as your own private server or a work account."</string>
<string name="screen_change_account_provider_title">"Change account provider"</string>
<string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string> <string name="screen_change_server_error_invalid_homeserver">"We couldn\'t reach this homeserver. Please check that you have entered the homeserver URL correctly. If the URL is correct, contact your homeserver administrator for further help."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"This server currently doesnt support sliding sync."</string> <string name="screen_change_server_error_no_sliding_sync_message">"This server currently doesnt support sliding sync."</string>
<string name="screen_change_server_form_header">"Homeserver URL"</string> <string name="screen_change_server_form_header">"Homeserver URL"</string>
@ -13,6 +27,12 @@
<string name="screen_login_server_header">"Where your conversations live"</string> <string name="screen_login_server_header">"Where your conversations live"</string>
<string name="screen_login_title">"Welcome back!"</string> <string name="screen_login_title">"Welcome back!"</string>
<string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string> <string name="screen_login_title_with_homeserver">"Sign in to %1$s"</string>
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"A private server for Element employees."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
<string name="screen_change_server_submit">"Continue"</string> <string name="screen_change_server_submit">"Continue"</string>
<string name="screen_change_server_title">"Select your server"</string> <string name="screen_change_server_title">"Select your server"</string>
<string name="screen_login_password_hint">"Password"</string> <string name="screen_login_password_hint">"Password"</string>

View file

@ -20,147 +20,72 @@ import app.cash.molecule.RecompositionClock
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.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.util.LoginConstants import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2 import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class ChangeServerPresenterTest { class ChangeServerPresenterTest {
@Test @Test
fun `present - should start with default homeserver`() = runTest { fun `present - initial state`() = runTest {
val presenter = ChangeServerPresenter( val presenter = ChangeServerPresenter(
FakeAuthenticationService(), FakeAuthenticationService(),
AccountProviderDataSource()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL) assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.submitEnabled).isTrue()
} }
} }
@Test @Test
fun `present - authentication service can provide a homeserver`() = runTest { fun `present - change server ok`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService().apply {
givenHomeserver(A_HOMESERVER.copy(url = A_HOMESERVER_URL_2))
},
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL_2)
assertThat(initialState.submitEnabled).isTrue()
}
}
@Test
fun `present - disable if empty or not correct`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.SetServer(""))
val emptyState = awaitItem()
assertThat(emptyState.homeserver).isEqualTo("")
assertThat(emptyState.submitEnabled).isFalse()
}
}
@Test
fun `present - submit`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.Submit)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
}
}
@Test
fun `present - submit parses URL`() = runTest {
val presenter = ChangeServerPresenter(
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val longUrl = "https://matrix.org/.well-known/"
val initialState = awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.SetServer(longUrl))
awaitItem()
initialState.eventSink.invoke(ChangeServerEvents.Submit)
val loadingState = awaitItem()
assertThat(loadingState.submitEnabled).isTrue()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
awaitItem() // Skip changing the url to the parsed domain
val successState = awaitItem()
assertThat(successState.submitEnabled).isFalse()
assertThat(successState.changeServerAction).isInstanceOf(Async.Success::class.java)
assertThat(successState.homeserver).isEqualTo("matrix.org")
}
}
@Test
fun `present - submit fails`() = runTest {
val authServer = FakeAuthenticationService()
val presenter = ChangeServerPresenter(authServer)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
authServer.givenChangeServerError(Throwable())
initialState.eventSink.invoke(ChangeServerEvents.Submit)
skipItems(1) // Loading
val failureState = awaitItem()
assertThat(failureState.submitEnabled).isFalse()
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
}
}
@Test
fun `present - clear error`() = runTest {
val authenticationService = FakeAuthenticationService() val authenticationService = FakeAuthenticationService()
val presenter = ChangeServerPresenter( val presenter = ChangeServerPresenter(
authenticationService, authenticationService,
AccountProviderDataSource()
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
authenticationService.givenHomeserver(A_HOMESERVER)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL)))
val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.changeServerAction).isEqualTo(Async.Success(Unit))
}
}
// Submit will return an error @Test
authenticationService.givenChangeServerError(A_THROWABLE) fun `present - change server error`() = runTest {
initialState.eventSink(ChangeServerEvents.Submit) val authenticationService = FakeAuthenticationService()
val presenter = ChangeServerPresenter(
skipItems(1) // Loading authenticationService,
AccountProviderDataSource()
// Check an error was returned )
val submittedState = awaitItem() moleculeFlow(RecompositionClock.Immediate) {
assertThat(submittedState.changeServerAction).isInstanceOf(Async.Failure::class.java) presenter.present()
}.test {
// Assert the error is then cleared val initialState = awaitItem()
submittedState.eventSink(ChangeServerEvents.ClearError) assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
val clearedState = awaitItem() initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL)))
assertThat(clearedState.changeServerAction).isEqualTo(Async.Uninitialized) val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val failureState = awaitItem()
assertThat(failureState.changeServerAction).isInstanceOf(Async.Failure::class.java)
// Clear error
failureState.eventSink.invoke(ChangeServerEvents.ClearError)
val finalState = awaitItem()
assertThat(finalState.changeServerAction).isEqualTo(Async.Uninitialized)
} }
} }
} }

View file

@ -0,0 +1,28 @@
/*
* 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.login.impl.resolver.network
class FakeWellknownRequest : WellknownRequest {
private var resultMap: Map<String, WellKnown> = emptyMap()
fun givenResultMap(map: Map<String, WellKnown>) {
resultMap = map
}
override suspend fun execute(baseUrl: String): WellKnown {
return resultMap[baseUrl] ?: error("No result provided for $baseUrl")
}
}

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