Merge remote-tracking branch 'origin/develop' into misc/cjs/invite-string-change
This commit is contained in:
4
.github/workflows/nightlyReports.yml
vendored
4
.github/workflows/nightlyReports.yml
vendored
@@ -18,9 +18,7 @@ jobs:
|
||||
if: ${{ github.repository == 'vector-im/element-x-android' }}
|
||||
steps:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
lfs: 'true'
|
||||
uses: nschloe/action-cached-lfs-checkout@v1.2.1
|
||||
|
||||
- name: Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
|
||||
7
.github/workflows/recordScreenshots.yml
vendored
7
.github/workflows/recordScreenshots.yml
vendored
@@ -14,9 +14,9 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: actions/checkout@v3
|
||||
uses: nschloe/action-cached-lfs-checkout@v1.2.1
|
||||
with:
|
||||
lfs: 'true'
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
@@ -30,5 +30,6 @@ jobs:
|
||||
- name: Record screenshots
|
||||
run: "./.github/workflows/scripts/recordScreenshots.sh"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||
|
||||
|
||||
42
.github/workflows/scripts/recordScreenshots.sh
vendored
42
.github/workflows/scripts/recordScreenshots.sh
vendored
@@ -16,13 +16,43 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
if [[ -z ${GITHUB_TOKEN} ]]; then
|
||||
echo "Missing GITHUB_TOKEN variable"
|
||||
TOKEN=$GITHUB_TOKEN
|
||||
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
|
||||
fi
|
||||
|
||||
if [[ -z ${GITHUB_REPOSITORY} ]]; then
|
||||
echo "Missing GITHUB_REPOSITORY variable"
|
||||
if [[ -z ${REPO} ]]; then
|
||||
echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -35,6 +65,8 @@ git config user.email "benoitm+elementbot@element.io"
|
||||
git add -A
|
||||
git commit -m "Update screenshots"
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
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!"
|
||||
|
||||
2
.github/workflows/sync-localazy.yml
vendored
2
.github/workflows/sync-localazy.yml
vendored
@@ -1,5 +1,6 @@
|
||||
name: Sync Localazy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 00:00 on every Monday UTC
|
||||
- cron: '0 0 * * 1'
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
- name: Create Pull Request for Strings
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
commit-message: Sync Strings from Localazy
|
||||
title: Sync Strings
|
||||
body: |
|
||||
|
||||
11
.github/workflows/tests.yml
vendored
11
.github/workflows/tests.yml
vendored
@@ -22,12 +22,11 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: actions/checkout@v3
|
||||
uses: nschloe/action-cached-lfs-checkout@v1.2.1
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
lfs: 'true'
|
||||
- name: ☕️ Use JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
@@ -81,8 +80,12 @@ jobs:
|
||||
**/out/failures/
|
||||
**/build/reports/tests/*UnitTest/
|
||||
|
||||
- name: 🔊 Publish results to Sonar (disabled)
|
||||
run: echo "This is now done only once a day, see nightlyReports.yml"
|
||||
- name: 🔊 Publish results to Sonar
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
|
||||
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
|
||||
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
# https://github.com/codecov/codecov-action
|
||||
- name: ☂️ Upload coverage reports to codecov
|
||||
|
||||
4
.github/workflows/validate-lfs.yml
vendored
4
.github/workflows/validate-lfs.yml
vendored
@@ -7,9 +7,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
lfs: 'true'
|
||||
- uses: nschloe/action-cached-lfs-checkout@v1.2.1
|
||||
|
||||
- run: |
|
||||
./tools/git/validate_lfs.sh
|
||||
|
||||
7
.idea/dictionaries/bmarty.xml
generated
Normal file
7
.idea/dictionaries/bmarty.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="bmarty">
|
||||
<words>
|
||||
<w>homeserver</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
@@ -25,7 +25,7 @@ maestro test \
|
||||
-e APP_ID=io.element.android.x.debug \
|
||||
-e USERNAME=user \
|
||||
-e PASSWORD=123 \
|
||||
-e ROOM_NAME="my room" \
|
||||
-e ROOM_NAME="MyRoom" \
|
||||
.maestro/allTests.yaml
|
||||
```
|
||||
|
||||
|
||||
@@ -3,4 +3,15 @@ appId: ${APP_ID}
|
||||
- tapOn:
|
||||
id: "login-change_server"
|
||||
- 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
|
||||
|
||||
@@ -5,6 +5,8 @@ appId: ${APP_ID}
|
||||
- takeScreenshot: build/maestro/100-SignIn
|
||||
- runFlow: changeServer.yaml
|
||||
- runFlow: ../assertions/assertLoginDisplayed.yaml
|
||||
- tapOn:
|
||||
id: "login-continue"
|
||||
- tapOn:
|
||||
id: "login-email_username"
|
||||
- inputText: ${USERNAME}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: ${APP_ID}
|
||||
---
|
||||
- extendedWaitUntil:
|
||||
visible: "Welcome back!"
|
||||
visible: "Change account provider"
|
||||
timeout: 10_000
|
||||
|
||||
@@ -25,6 +25,6 @@ dependencies {
|
||||
implementation(libs.anvil.compiler.utils)
|
||||
implementation("com.squareup:kotlinpoet:1.14.2")
|
||||
implementation(libs.dagger)
|
||||
compileOnly("com.google.auto.service:auto-service-annotations:1.1.0")
|
||||
kapt("com.google.auto.service:auto-service:1.1.0")
|
||||
compileOnly(libs.google.autoservice.annotations)
|
||||
kapt(libs.google.autoservice)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
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.core.log.logger.LoggerTag
|
||||
import io.element.android.libraries.designsystem.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher
|
||||
import io.element.android.x.di.AppBindings
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -39,17 +42,28 @@ private val loggerTag = LoggerTag("MainActivity")
|
||||
|
||||
class MainActivity : NodeComponentActivity() {
|
||||
|
||||
lateinit var mainNode: MainNode
|
||||
private lateinit var mainNode: MainNode
|
||||
|
||||
private lateinit var appBindings: AppBindings
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Timber.tag(loggerTag.value).w("onCreate, with savedInstanceState: ${savedInstanceState != null}")
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
val appBindings = bindings<AppBindings>()
|
||||
appBindings = bindings<AppBindings>()
|
||||
appBindings.matrixClientsHolder().restore(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
ElementTheme {
|
||||
MainContent(appBindings)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainContent(appBindings: AppBindings) {
|
||||
ElementTheme {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarDispatcher provides appBindings.snackbarDispatcher(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -62,6 +76,7 @@ class MainActivity : NodeComponentActivity() {
|
||||
plugins = listOf(
|
||||
object : NodeReadyObserver<MainNode> {
|
||||
override fun init(node: MainNode) {
|
||||
Timber.tag(loggerTag.value).w("onMainNodeInit")
|
||||
mainNode = node
|
||||
mainNode.handleIntent(intent)
|
||||
}
|
||||
@@ -80,11 +95,17 @@ class MainActivity : NodeComponentActivity() {
|
||||
* - a notification is clicked.
|
||||
* - the app is going to background (<- this is strange)
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Timber.tag(loggerTag.value).w("onNewIntent")
|
||||
intent ?: return
|
||||
mainNode.handleIntent(intent)
|
||||
// If the mainNode is not init yet, keep the intent for later.
|
||||
// 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() {
|
||||
|
||||
@@ -57,24 +57,24 @@ class MainNode(
|
||||
DaggerComponentOwner by mainDaggerComponentOwner {
|
||||
|
||||
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()
|
||||
mainDaggerComponentOwner.addComponent(client.sessionId.value, component)
|
||||
mainDaggerComponentOwner.addComponent(identifier, component)
|
||||
}
|
||||
|
||||
override fun onFlowReleased(client: MatrixClient) {
|
||||
mainDaggerComponentOwner.removeComponent(client.sessionId.value)
|
||||
override fun onFlowReleased(identifier: String, client: MatrixClient) {
|
||||
mainDaggerComponentOwner.removeComponent(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
mainDaggerComponentOwner.addComponent(room.roomId.value, component)
|
||||
mainDaggerComponentOwner.addComponent(identifier, component)
|
||||
}
|
||||
|
||||
override fun onFlowReleased(room: MatrixRoom) {
|
||||
mainDaggerComponentOwner.removeComponent(room.roomId.value)
|
||||
override fun onFlowReleased(identifier: String, room: MatrixRoom) {
|
||||
mainDaggerComponentOwner.removeComponent(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ package io.element.android.x.di
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import io.element.android.appnav.di.MatrixClientsHolder
|
||||
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesTo(AppScope::class)
|
||||
interface AppBindings {
|
||||
fun matrixClientsHolder(): MatrixClientsHolder
|
||||
fun mainDaggerComponentOwner(): MainDaggerComponentsOwner
|
||||
fun snackbarDispatcher(): SnackbarDispatcher
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ class MainDaggerComponentsOwner @Inject constructor(@ApplicationContext context:
|
||||
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
|
||||
get() = daggerComponents.values.reversed()
|
||||
}
|
||||
|
||||
@@ -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.ui.strings.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
class LoggedInEventProcessor @Inject constructor(
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
|
||||
@@ -64,9 +64,9 @@ import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.appnavstate.api.AppNavigationStateService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
@@ -118,9 +118,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -139,7 +139,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
observeAnalyticsState()
|
||||
lifecycle.subscribe(
|
||||
onCreate = {
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowCreated(inputs.matrixClient) }
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().loggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
inputs.matrixClient.startSync()
|
||||
@@ -151,7 +151,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
onDestroy = {
|
||||
val imageLoaderFactory = bindings<MatrixUIBindings>().notLoggedInImageLoaderFactory()
|
||||
Coil.setImageLoader(imageLoaderFactory)
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.matrixClient) }
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.matrixClient) }
|
||||
appNavigationStateService.onLeavingSpace(id)
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
@@ -239,8 +239,13 @@ class LoggedInFlowNode @AssistedInject constructor(
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs) + nodeLifecycleCallbacks)
|
||||
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback) + nodeLifecycleCallbacks)
|
||||
}
|
||||
}
|
||||
NavTarget.Settings -> {
|
||||
|
||||
@@ -63,7 +63,9 @@ class NotLoggedInFlowNode @AssistedInject constructor(
|
||||
object OnBoarding : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object LoginFlow : NavTarget
|
||||
data class LoginFlow(
|
||||
val isAccountCreation: Boolean,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -71,11 +73,11 @@ class NotLoggedInFlowNode @AssistedInject constructor(
|
||||
NavTarget.OnBoarding -> {
|
||||
val callback = object : OnBoardingEntryPoint.Callback {
|
||||
override fun onSignUp() {
|
||||
//NOOP
|
||||
backstack.push(NavTarget.LoginFlow(isAccountCreation = true))
|
||||
}
|
||||
|
||||
override fun onSignIn() {
|
||||
backstack.push(NavTarget.LoginFlow)
|
||||
backstack.push(NavTarget.LoginFlow(isAccountCreation = false))
|
||||
}
|
||||
}
|
||||
onBoardingEntryPoint
|
||||
@@ -83,8 +85,10 @@ class NotLoggedInFlowNode @AssistedInject constructor(
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
NavTarget.LoginFlow -> {
|
||||
loginEntryPoint.createNode(this, buildContext)
|
||||
is NavTarget.LoginFlow -> {
|
||||
loginEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(LoginEntryPoint.Params(isAccountCreation = navTarget.isAccountCreation))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.inputs
|
||||
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.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
@@ -67,9 +68,13 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
plugins = plugins,
|
||||
) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onForwardedToSingleRoom(roomId: RoomId)
|
||||
}
|
||||
|
||||
interface LifecycleCallback : NodeLifecycleCallback {
|
||||
fun onFlowCreated(room: MatrixRoom) = Unit
|
||||
fun onFlowReleased(room: MatrixRoom) = Unit
|
||||
fun onFlowCreated(identifier: String, room: MatrixRoom) = Unit
|
||||
fun onFlowReleased(identifier: String, room: MatrixRoom) = Unit
|
||||
}
|
||||
|
||||
data class Inputs(
|
||||
@@ -78,19 +83,20 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
private val callbacks = plugins.filterIsInstance<Callback>()
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
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)
|
||||
fetchRoomMembers()
|
||||
},
|
||||
onDestroy = {
|
||||
Timber.v("OnDestroy")
|
||||
inputs.room.close()
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowReleased(inputs.room) }
|
||||
plugins<LifecycleCallback>().forEach { it.onFlowReleased(id, inputs.room) }
|
||||
appNavigationStateService.onLeavingRoom(id)
|
||||
}
|
||||
)
|
||||
@@ -124,6 +130,10 @@ class RoomFlowNode @AssistedInject constructor(
|
||||
override fun onUserDataClicked(userId: UserId) {
|
||||
backstack.push(NavTarget.RoomMemberDetails(userId))
|
||||
}
|
||||
|
||||
override fun onForwardedToSingleRoom(roomId: RoomId) {
|
||||
callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
|
||||
}
|
||||
}
|
||||
messagesEntryPoint.createNode(this, buildContext, callback)
|
||||
}
|
||||
|
||||
1
changelog.d/486.feature
Normal file
1
changelog.d/486.feature
Normal file
@@ -0,0 +1 @@
|
||||
Allow forawrding messages from one room to another
|
||||
1
changelog.d/487.feature
Normal file
1
changelog.d/487.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add menu to retry sending failed messages or delete their local echoes.
|
||||
1
changelog.d/489.feature
Normal file
1
changelog.d/489.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add option to report inappropriate content
|
||||
@@ -14,12 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.root
|
||||
package io.element.android.features.analytics.api
|
||||
|
||||
sealed interface LoginRootEvents {
|
||||
object RetryFetchServerInfo : LoginRootEvents
|
||||
data class SetLogin(val login: String) : LoginRootEvents
|
||||
data class SetPassword(val password: String) : LoginRootEvents
|
||||
object Submit : LoginRootEvents
|
||||
object ClearError : LoginRootEvents
|
||||
object Config {
|
||||
const val POLICY_LINK = "https://element.io/cookie-policy"
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ dependencies {
|
||||
api(projects.features.analytics.api)
|
||||
api(projects.services.analytics.api)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.browser)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
|
||||
@@ -16,14 +16,19 @@
|
||||
|
||||
package io.element.android.features.analytics.impl
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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 dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
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
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
@@ -33,12 +38,19 @@ class AnalyticsOptInNode @AssistedInject constructor(
|
||||
private val presenter: AnalyticsOptInPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private fun onClickTerms(activity: Activity, darkTheme: Boolean) {
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, Config.POLICY_LINK)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val activity = LocalContext.current as Activity
|
||||
val isDark = MaterialTheme.colors.isLight.not()
|
||||
val state = presenter.present()
|
||||
AnalyticsOptInView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onClickTerms = { onClickTerms(activity, isDark) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,47 +16,46 @@
|
||||
|
||||
package io.element.android.features.analytics.impl
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.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.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Poll
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.BiasAlignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.element.android.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.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.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@@ -67,157 +66,148 @@ import io.element.android.libraries.ui.strings.R as StringR
|
||||
@Composable
|
||||
fun AnalyticsOptInView(
|
||||
state: AnalyticsOptInState,
|
||||
onClickTerms: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LogCompositions(tag = "Analytics", msg = "Root")
|
||||
val eventSink = state.eventSink
|
||||
Box(
|
||||
HeaderFooterPage(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.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(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Image(
|
||||
painterResource(id = R.drawable.element_logo_stars),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
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))
|
||||
}
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),
|
||||
title = stringResource(id = R.string.screen_analytics_prompt_title, state.applicationName),
|
||||
subTitle = stringResource(id = R.string.screen_analytics_prompt_help_us_improve),
|
||||
iconImageVector = Icons.Filled.Poll
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedStringWithStyledPart(
|
||||
R.string.screen_analytics_prompt_read_terms,
|
||||
R.string.screen_analytics_prompt_read_terms_content_link,
|
||||
color = Color.Unspecified,
|
||||
underline = false,
|
||||
bold = true,
|
||||
),
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
.clickable { onClickTerms() }
|
||||
.padding(8.dp),
|
||||
style = ElementTextStyles.Regular.subheadline,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
append(this@toAnnotatedString)
|
||||
val spannable = SpannableString(this@toAnnotatedString)
|
||||
spannable.getSpans(0, spannable.length, Any::class.java).forEach { span ->
|
||||
val start = spannable.getSpanStart(span)
|
||||
val end = spannable.getSpanEnd(span)
|
||||
when (span) {
|
||||
is StyleSpan -> when (span.style) {
|
||||
Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
|
||||
Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
|
||||
Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
|
||||
}
|
||||
is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
|
||||
is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
|
||||
@Composable
|
||||
private fun AnalyticsOptInContent(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = BiasAlignment(
|
||||
horizontalBias = 0f,
|
||||
verticalBias = -0.4f
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
|
||||
idx = 0
|
||||
)
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
|
||||
idx = 1
|
||||
)
|
||||
AnalyticsOptInContentRow(
|
||||
text = stringResource(id = R.string.screen_analytics_prompt_settings),
|
||||
idx = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun buildAnnotatedStringWithColoredPart(
|
||||
@StringRes fullTextRes: Int,
|
||||
@StringRes coloredTextRes: Int,
|
||||
color: Color = LinkColor,
|
||||
underline: Boolean = true,
|
||||
) = buildAnnotatedString {
|
||||
val coloredPart = stringResource(coloredTextRes)
|
||||
val fullText = stringResource(fullTextRes, coloredPart)
|
||||
val startIndex = fullText.indexOf(coloredPart)
|
||||
append(fullText)
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = if (underline) TextDecoration.Underline else null
|
||||
), start = startIndex, end = startIndex + coloredPart.length
|
||||
)
|
||||
private fun AnalyticsOptInContentRow(
|
||||
text: String,
|
||||
idx: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val radius = 14.dp
|
||||
val bgShape = when (idx) {
|
||||
0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
|
||||
2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = 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
|
||||
@@ -234,5 +224,8 @@ fun AnalyticsOptInViewDarkPreview(@PreviewParameter(AnalyticsOptInStateProvider:
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: AnalyticsOptInState) {
|
||||
AnalyticsOptInView(state = state)
|
||||
AnalyticsOptInView(
|
||||
state = state,
|
||||
onClickTerms = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,9 @@
|
||||
<?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">"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_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_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string>
|
||||
<string name="screen_analytics_prompt_title">"Helfen Sie %1$s zu verbessern"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<?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"><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_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_third_party_sharing"><b>"Nu"</b>" împărtășim informații cu terți"</string>
|
||||
<string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,10 +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">"We "<b>"don\'t"</b>" record or profile any account 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_data_usage">"We won\'t record or profile any personal 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_content_link">"here"</string>
|
||||
<string name="screen_analytics_prompt_settings">"You can turn this off anytime in settings"</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_settings">"You can turn this off anytime"</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>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.net.Uri
|
||||
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.userlist.UserListDataStore
|
||||
import io.element.android.libraries.androidutils.file.safeDelete
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -36,7 +37,7 @@ class CreateRoomDataStore @Inject constructor(
|
||||
private val createRoomConfigFlow: MutableStateFlow<CreateRoomConfig> = MutableStateFlow(CreateRoomConfig())
|
||||
private var cachedAvatarUri: Uri? = null
|
||||
set(value) {
|
||||
field?.path?.let { File(it) }?.delete()
|
||||
field?.path?.let { File(it) }?.safeDelete()
|
||||
field = value
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,6 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
||||
val byteArray = preprocessed.file.readBytes()
|
||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray).getOrThrow()
|
||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
|
||||
<string name="screen_create_room_title">"Crear una sala"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
|
||||
<string name="screen_create_room_title">"Crea una stanza"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_invite_people">"Invitați persoane"</string>
|
||||
<string name="screen_create_room_add_people_title">"Adaugaț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">"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_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>
|
||||
@@ -12,4 +12,4 @@
|
||||
<string name="screen_create_room_topic_label">"Subiect (opțional)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
|
||||
<string name="screen_create_room_title">"Creați o cameră"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
<string name="screen_create_room_topic_label">"Topic (optional)"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
|
||||
<string name="screen_create_room_title">"Create a room"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -21,7 +21,6 @@ import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
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.CreateRoomDataStore
|
||||
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.FakeMatrixClient
|
||||
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.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
@@ -226,6 +226,7 @@ class ConfigureRoomPresenterTests {
|
||||
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config))
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java)
|
||||
val stateAfterCreateRoom = awaitItem()
|
||||
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.Loading::class.java)
|
||||
assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,10 +52,11 @@ class CreateRoomRootPresenterTests {
|
||||
fakeMatrixClient = FakeMatrixClient()
|
||||
userRepository = FakeUserRepository()
|
||||
presenter = CreateRoomRootPresenter(
|
||||
FakeUserListPresenterFactory(fakeUserListPresenter),
|
||||
userRepository,
|
||||
presenterFactory =FakeUserListPresenterFactory(fakeUserListPresenter),
|
||||
userRepository= userRepository,
|
||||
userListDataStore =
|
||||
UserListDataStore(),
|
||||
fakeMatrixClient,
|
||||
matrixClient = fakeMatrixClient,
|
||||
aBuildMeta(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
<string name="screen_invites_decline_direct_chat_title">"Odmítnout chat"</string>
|
||||
<string name="screen_invites_empty_list">"Žádné pozvánky"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) vás pozval(a)"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -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_title">"Chat ablehnen"</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>
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
<string name="screen_invites_decline_direct_chat_title">"Decline chat"</string>
|
||||
<string name="screen_invites_empty_list">"No Invites"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -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_2
|
||||
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_NAME
|
||||
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.FakeMatrixRoom
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@@ -48,7 +47,6 @@ class InviteListPresenterTests {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -73,7 +71,6 @@ class InviteListPresenterTests {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -100,10 +97,8 @@ class InviteListPresenterTests {
|
||||
@Test
|
||||
fun `present - includes sender details for room invites`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -128,10 +123,8 @@ class InviteListPresenterTests {
|
||||
@Test
|
||||
fun `present - shows confirm dialog for declining direct chat invites`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withDirectChatInvitation()
|
||||
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -154,10 +147,8 @@ class InviteListPresenterTests {
|
||||
@Test
|
||||
fun `present - shows confirm dialog for declining room invites`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -180,10 +171,8 @@ class InviteListPresenterTests {
|
||||
@Test
|
||||
fun `present - hides confirm dialog when cancelling`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
FakeSeenInvitesStore(),
|
||||
@@ -207,7 +196,6 @@ class InviteListPresenterTests {
|
||||
fun `present - declines invite after confirming`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -234,7 +222,6 @@ class InviteListPresenterTests {
|
||||
fun `present - declines invite after confirming and sets state on error`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -266,7 +253,6 @@ class InviteListPresenterTests {
|
||||
fun `present - dismisses declining error state`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -299,7 +285,6 @@ class InviteListPresenterTests {
|
||||
fun `present - accepts invites and sets state on success`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -323,7 +308,6 @@ class InviteListPresenterTests {
|
||||
fun `present - accepts invites and sets state on error`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -349,7 +333,6 @@ class InviteListPresenterTests {
|
||||
fun `present - dismisses accepting error state`() = runTest {
|
||||
val invitesDataSource = FakeRoomSummaryDataSource().withRoomInvitation()
|
||||
val client = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
)
|
||||
val room = FakeMatrixRoom()
|
||||
@@ -379,7 +362,6 @@ class InviteListPresenterTests {
|
||||
val store = FakeSeenInvitesStore()
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
store,
|
||||
@@ -416,7 +398,6 @@ class InviteListPresenterTests {
|
||||
store.publishRoomIds(setOf(A_ROOM_ID))
|
||||
val presenter = InviteListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
invitesDataSource = invitesDataSource,
|
||||
),
|
||||
store,
|
||||
|
||||
@@ -213,5 +213,5 @@ private fun TestScope.createPresenter(
|
||||
): LeaveRoomPresenter = LeaveRoomPresenterImpl(
|
||||
client = client,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
dispatchers = testCoroutineDispatchers(testScheduler, false),
|
||||
dispatchers = testCoroutineDispatchers(false),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ plugins {
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.ksp)
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.8.21"
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -39,13 +40,18 @@ dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.features.login.api)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
@@ -55,6 +61,7 @@ dependencies {
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
androidTestImplementation(libs.test.junitext)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.login.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.LoginEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -26,7 +27,19 @@ import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLoginEntryPoint @Inject constructor() : LoginEntryPoint {
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<LoginFlowNode>(buildContext)
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LoginEntryPoint.NodeBuilder {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import android.app.Activity
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import com.bumble.appyx.navmodel.backstack.operation.singleTop
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
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.customtab.CustomTabHandler
|
||||
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.NodeInputs
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
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.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
@@ -51,9 +56,10 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||
private val customTabHandler: CustomTabHandler,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
) : BackstackNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
initialElement = NavTarget.ConfirmAccountProvider,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
@@ -62,12 +68,24 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
private var activity: Activity? = null
|
||||
private var darkTheme: Boolean = false
|
||||
|
||||
data class Inputs(
|
||||
val isAccountCreation: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object Root : NavTarget
|
||||
object ConfirmAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object ChangeServer : NavTarget
|
||||
object ChangeAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object SearchAccountProvider : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object LoginPassword : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class OidcView(val oidcDetails: OidcDetails) : NavTarget
|
||||
@@ -75,12 +93,11 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : LoginRootNode.Callback {
|
||||
override fun onChangeHomeServer() {
|
||||
backstack.push(NavTarget.ChangeServer)
|
||||
}
|
||||
|
||||
NavTarget.ConfirmAccountProvider -> {
|
||||
val inputs = ConfirmAccountProviderNode.Inputs(
|
||||
isAccountCreation = inputs.isAccountCreation
|
||||
)
|
||||
val callback = object : ConfirmAccountProviderNode.Callback {
|
||||
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
if (customTabAvailabilityChecker.supportCustomTab()) {
|
||||
// In this case open a Chrome Custom tab
|
||||
@@ -90,11 +107,44 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
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 -> {
|
||||
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||
@@ -109,6 +159,7 @@ class LoginFlowNode @AssistedInject constructor(
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
activity = null
|
||||
accountProviderDataSource.reset()
|
||||
}
|
||||
}
|
||||
Children(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = { }
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
|
||||
sealed interface ChangeServerEvents {
|
||||
data class SetServer(val server: String) : ChangeServerEvents
|
||||
object Submit : ChangeServerEvents
|
||||
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvents
|
||||
object ClearError : ChangeServerEvents
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ import androidx.compose.runtime.MutableState
|
||||
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.util.LoginConstants
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
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
|
||||
@@ -33,44 +34,43 @@ import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
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
|
||||
override fun present(): ChangeServerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val homeserver = rememberSaveable {
|
||||
mutableStateOf(authenticationService.getHomeserverDetails().value?.url ?: LoginConstants.DEFAULT_HOMESERVER_URL)
|
||||
}
|
||||
val changeServerAction: MutableState<Async<Unit>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: ChangeServerEvents) {
|
||||
when (event) {
|
||||
is ChangeServerEvents.SetServer -> {
|
||||
homeserver.value = event.server
|
||||
handleEvents(ChangeServerEvents.ClearError)
|
||||
}
|
||||
ChangeServerEvents.Submit -> {
|
||||
localCoroutineScope.submit(homeserver, changeServerAction)
|
||||
}
|
||||
is ChangeServerEvents.ChangeServer -> localCoroutineScope.changeServer(event.accountProvider, changeServerAction)
|
||||
ChangeServerEvents.ClearError -> changeServerAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return ChangeServerState(
|
||||
homeserver = homeserver.value,
|
||||
changeServerAction = changeServerAction.value,
|
||||
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 {
|
||||
val domain = tryOrNull { URL(homeserverUrl.value) }?.host ?: homeserverUrl.value
|
||||
authenticationService.setHomeserver(domain).getOrThrow()
|
||||
homeserverUrl.value = domain
|
||||
val domain = tryOrNull { URL(data.title) }?.host ?: data.title
|
||||
authenticationService.setHomeserver(domain).map {
|
||||
authenticationService.getHomeserverDetails().value!!
|
||||
// Valid, remember user choice
|
||||
accountProviderDataSource.userSelection(data)
|
||||
}.getOrThrow()
|
||||
}.execute(changeServerAction, errorMapping = ChangeServerError::from)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ package io.element.android.features.login.impl.changeserver
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
data class ChangeServerState(
|
||||
val homeserver: String,
|
||||
val changeServerAction: Async<Unit>,
|
||||
val eventSink: (ChangeServerEvents) -> Unit,
|
||||
) {
|
||||
val submitEnabled: Boolean get() = homeserver.isNotEmpty() && (changeServerAction is Async.Uninitialized || changeServerAction is Async.Loading)
|
||||
}
|
||||
val eventSink: (ChangeServerEvents) -> Unit
|
||||
)
|
||||
|
||||
@@ -17,26 +17,16 @@
|
||||
package io.element.android.features.login.impl.changeserver
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class ChangeServerStateProvider : PreviewParameterProvider<ChangeServerState> {
|
||||
override val values: Sequence<ChangeServerState>
|
||||
get() = sequenceOf(
|
||||
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(
|
||||
homeserver = "",
|
||||
changeServerAction = Async.Uninitialized,
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -16,276 +16,74 @@
|
||||
|
||||
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.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.login.impl.R
|
||||
import io.element.android.features.login.impl.util.LoginConstants
|
||||
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.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.LinkColor
|
||||
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.components.ProgressDialog
|
||||
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.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
|
||||
fun ChangeServerView(
|
||||
state: ChangeServerState,
|
||||
onLearnMoreClicked: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServerSuccess: () -> Unit = {},
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
val scrollState = rememberScrollState()
|
||||
val isLoading by remember(state.changeServerAction) {
|
||||
derivedStateOf {
|
||||
state.changeServerAction is Async.Loading
|
||||
}
|
||||
}
|
||||
val invalidHomeserverError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.InlineErrorMessage
|
||||
val slidingSyncNotSupportedError = (state.changeServerAction as? Async.Failure)?.error as? ChangeServerError.SlidingSyncAlert
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
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 = "",
|
||||
when (state.changeServerAction) {
|
||||
is Async.Failure -> {
|
||||
when (val error = state.changeServerAction.error) {
|
||||
is ChangeServerError.Error -> {
|
||||
ErrorDialog(
|
||||
modifier = modifier,
|
||||
content = error.message(),
|
||||
onDismiss = {
|
||||
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_change_server_title),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.CenterHorizontally),
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTextStyles.Bold.title2,
|
||||
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)
|
||||
is ChangeServerError.SlidingSyncAlert -> {
|
||||
SlidingSyncNotSupportedDialog(
|
||||
modifier = modifier,
|
||||
onLearnMoreClicked = {
|
||||
onLearnMoreClicked()
|
||||
eventSink.invoke(ChangeServerEvents.ClearError)
|
||||
}, onDismiss = {
|
||||
eventSink.invoke(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
|
||||
@Composable
|
||||
internal fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
fun ChangeServerViewLightPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
fun ChangeServerViewDarkPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: ChangeServerState) {
|
||||
ChangeServerView(state = state, onBackPressed = {}, onLearnMoreClicked = {})
|
||||
ChangeServerView(
|
||||
state = state,
|
||||
onLearnMoreClicked = {},
|
||||
onDone = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* 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.compose.runtime.Composable
|
||||
@@ -23,7 +23,7 @@ import io.element.android.features.login.impl.R
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
|
||||
sealed class ChangeServerError : Throwable() {
|
||||
data class InlineErrorMessage(@StringRes val messageId: Int) : ChangeServerError() {
|
||||
data class Error(@StringRes val messageId: Int) : ChangeServerError() {
|
||||
@Composable
|
||||
fun message(): String = stringResource(messageId)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ sealed class ChangeServerError : Throwable() {
|
||||
companion object {
|
||||
fun from(error: Throwable): ChangeServerError = when (error) {
|
||||
is AuthenticationException.SlidingSyncNotAvailable -> SlidingSyncAlert
|
||||
else -> InlineErrorMessage(R.string.screen_change_server_error_invalid_homeserver)
|
||||
else -> Error(R.string.screen_change_server_error_invalid_homeserver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,21 @@
|
||||
|
||||
package io.element.android.features.login.impl.error
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.AuthenticationException
|
||||
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(
|
||||
throwable: Throwable
|
||||
): Int {
|
||||
val authException = throwable as? AuthenticationException ?: return StringR.error_unknown
|
||||
val authException = throwable as? AuthenticationException ?: return StringR.string.error_unknown
|
||||
return when (authException.errorCode) {
|
||||
AuthErrorCode.FORBIDDEN -> R.string.screen_login_error_invalid_credentials
|
||||
AuthErrorCode.USER_DEACTIVATED -> R.string.screen_login_error_deactivated_account
|
||||
AuthErrorCode.UNKNOWN -> StringR.error_unknown
|
||||
AuthErrorCode.UNKNOWN -> StringR.string.error_unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
import androidx.browser.customtabs.CustomTabsServiceConnection
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.di.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -14,49 +14,52 @@
|
||||
* 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.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.LoginConstants
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.features.login.impl.util.openLearnMorePage
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class ChangeServerNode @AssistedInject constructor(
|
||||
class ChangeAccountProviderNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: ChangeServerPresenter,
|
||||
private val presenter: ChangeAccountProviderPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
private fun onSuccess() {
|
||||
navigateUp()
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
fun onOtherClicked()
|
||||
}
|
||||
|
||||
private fun openLearnMorePage(context: Context) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
|
||||
tryOrNull { context.startActivity(intent) }
|
||||
private fun onDone() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
private fun onOtherClicked() {
|
||||
plugins<Callback>().forEach { it.onOtherClicked() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val context = LocalContext.current
|
||||
ChangeServerView(
|
||||
ChangeAccountProviderView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onChangeServerSuccess = this::onSuccess,
|
||||
onBackPressed = { navigateUp() },
|
||||
onBackPressed = ::navigateUp,
|
||||
onLearnMoreClicked = { openLearnMorePage(context) },
|
||||
onDone = ::onDone,
|
||||
onOtherProviderClicked = ::onOtherClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -14,36 +14,23 @@
|
||||
* 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 io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
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 kotlinx.parcelize.Parcelize
|
||||
|
||||
data class LoginRootState(
|
||||
val homeserverUrl: String,
|
||||
val homeserverDetails: Async<MatrixHomeServerDetails>,
|
||||
val loggedInState: LoggedInState,
|
||||
data class LoginPasswordState(
|
||||
val accountProvider: AccountProvider,
|
||||
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
|
||||
get() = loggedInState !is LoggedInState.ErrorLoggingIn &&
|
||||
((formState.login.isNotEmpty() && formState.password.isNotEmpty()) || supportOidcLogin)
|
||||
}
|
||||
|
||||
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
|
||||
get() = loginAction !is Async.Failure &&
|
||||
((formState.login.isNotEmpty() && formState.password.isNotEmpty()))
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@@ -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 = {}
|
||||
)
|
||||
@@ -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");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,15 +14,11 @@
|
||||
* 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.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
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.imePadding
|
||||
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.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.ChevronRight
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
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.PasswordVisualTransformation
|
||||
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.PreviewParameter
|
||||
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.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||
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.button.ButtonWithProgress
|
||||
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.autofill
|
||||
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.testTag
|
||||
import io.element.android.libraries.ui.strings.R as StringR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LoginRootView(
|
||||
state: LoginRootState,
|
||||
fun LoginPasswordView(
|
||||
state: LoginPasswordState,
|
||||
modifier: Modifier = Modifier,
|
||||
onChangeServer: () -> Unit = {},
|
||||
onOidcDetails: (OidcDetails) -> Unit = {},
|
||||
onBackPressed: () -> Unit,
|
||||
) {
|
||||
val isLoading by remember(state.loggedInState) {
|
||||
val isLoading by remember(state.loginAction) {
|
||||
derivedStateOf {
|
||||
state.loggedInState == LoggedInState.LoggingIn
|
||||
state.loginAction is Async.Loading
|
||||
}
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -111,10 +96,11 @@ fun LoginRootView(
|
||||
// Clear focus to prevent keyboard issues with textfields
|
||||
focusManager.clearFocus(force = true)
|
||||
|
||||
state.eventSink(LoginRootEvents.Submit)
|
||||
state.eventSink(LoginPasswordEvents.Submit)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
@@ -123,7 +109,7 @@ fun LoginRootView(
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.padding(padding)
|
||||
@@ -136,142 +122,48 @@ fun LoginRootView(
|
||||
.verticalScroll(state = scrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Title
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_login_title),
|
||||
IconTitleSubtitleMolecule(
|
||||
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
|
||||
.fillMaxWidth(),
|
||||
style = ElementTextStyles.Bold.title1,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
.fillMaxWidth()
|
||||
.testTag(TestTags.loginContinue)
|
||||
)
|
||||
Spacer(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)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
when (val loggedInState = state.loggedInState) {
|
||||
is LoggedInState.OidcStarted -> onOidcDetails(loggedInState.oidcDetail)
|
||||
else -> Unit
|
||||
|
||||
if (state.loginAction is Async.Failure) {
|
||||
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)
|
||||
@Composable
|
||||
internal fun LoginForm(
|
||||
state: LoginRootState,
|
||||
state: LoginPasswordState,
|
||||
isLoading: Boolean,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -299,14 +191,14 @@ internal fun LoginForm(
|
||||
.testTag(TestTags.loginEmailUsername)
|
||||
.autofill(autofillTypes = listOf(AutofillType.Username), onFill = {
|
||||
loginFieldState = it
|
||||
eventSink(LoginRootEvents.SetLogin(it))
|
||||
eventSink(LoginPasswordEvents.SetLogin(it))
|
||||
}),
|
||||
label = {
|
||||
Text(text = stringResource(R.string.screen_login_username_hint))
|
||||
},
|
||||
onValueChange = {
|
||||
loginFieldState = it
|
||||
eventSink(LoginRootEvents.SetLogin(it))
|
||||
eventSink(LoginPasswordEvents.SetLogin(it))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
@@ -316,7 +208,6 @@ internal fun LoginForm(
|
||||
focusManager.moveFocus(FocusDirection.Down)
|
||||
}),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
trailingIcon = if (loginFieldState.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = {
|
||||
@@ -329,7 +220,7 @@ internal fun LoginForm(
|
||||
)
|
||||
|
||||
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
|
||||
passwordVisible = false
|
||||
}
|
||||
@@ -343,11 +234,11 @@ internal fun LoginForm(
|
||||
.testTag(TestTags.loginPassword)
|
||||
.autofill(autofillTypes = listOf(AutofillType.Password), onFill = {
|
||||
passwordFieldState = it
|
||||
eventSink(LoginRootEvents.SetPassword(it))
|
||||
eventSink(LoginPasswordEvents.SetPassword(it))
|
||||
}),
|
||||
onValueChange = {
|
||||
passwordFieldState = it
|
||||
eventSink(LoginRootEvents.SetPassword(it))
|
||||
eventSink(LoginPasswordEvents.SetPassword(it))
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(R.string.screen_login_password_hint))
|
||||
@@ -371,7 +262,6 @@ internal fun LoginForm(
|
||||
onDone = { onSubmit() }
|
||||
),
|
||||
singleLine = true,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -386,17 +276,17 @@ internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun LoginRootScreenLightPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
|
||||
internal fun LoginPasswordViewLightPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
|
||||
ElementPreviewLight { ContentToPreview(state) }
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun LoginRootScreenDarkPreview(@PreviewParameter(LoginRootStateProvider::class) state: LoginRootState) =
|
||||
internal fun LoginPasswordViewDarkPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) =
|
||||
ElementPreviewDark { ContentToPreview(state) }
|
||||
|
||||
@Composable
|
||||
private fun ContentToPreview(state: LoginRootState) {
|
||||
LoginRootView(
|
||||
private fun ContentToPreview(state: LoginPasswordState) {
|
||||
LoginPasswordView(
|
||||
state = state,
|
||||
onBackPressed = {}
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -14,10 +14,11 @@
|
||||
* 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.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
|
||||
@@ -25,38 +26,34 @@ 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.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
class LoginRootNode @AssistedInject constructor(
|
||||
class SearchAccountProviderNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LoginRootPresenter,
|
||||
private val presenter: SearchAccountProviderPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeHomeServer()
|
||||
fun onOidcDetails(oidcDetails: OidcDetails)
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
private fun onChangeHomeServer() {
|
||||
plugins<Callback>().forEach { it.onChangeHomeServer() }
|
||||
}
|
||||
|
||||
private fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
plugins<Callback>().forEach { it.onOidcDetails(oidcDetails) }
|
||||
private fun onDone() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
LoginRootView(
|
||||
val context = LocalContext.current
|
||||
SearchAccountProviderView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onChangeServer = ::onChangeHomeServer,
|
||||
onOidcDetails = ::onOidcDetails,
|
||||
onBackPressed = ::navigateUp
|
||||
onBackPressed = ::navigateUp,
|
||||
onLearnMoreClicked = { openLearnMorePage(context) },
|
||||
onDone = ::onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,18 @@
|
||||
|
||||
package io.element.android.features.login.impl.util
|
||||
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProvider
|
||||
|
||||
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 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,
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
12
features/login/impl/src/main/res/drawable/ic_matrix.xml
Normal file
12
features/login/impl/src/main/res/drawable/ic_matrix.xml
Normal 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>
|
||||
12
features/login/impl/src/main/res/drawable/ic_public.xml
Normal file
12
features/login/impl/src/main/res/drawable/ic_public.xml
Normal 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>
|
||||
@@ -1,5 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_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>
|
||||
@@ -13,9 +27,15 @@
|
||||
<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_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_title">"Vyberte svůj server"</string>
|
||||
<string name="screen_login_password_hint">"Heslo"</string>
|
||||
<string name="screen_login_submit">"Pokračovat"</string>
|
||||
<string name="screen_login_username_hint">"Uživatelské jméno"</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user