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

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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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!"

View File

@@ -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: |

View File

@@ -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

View File

@@ -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
View File

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

View File

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

View File

@@ -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

View File

@@ -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}

View File

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

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -23,7 +23,6 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationS
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.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,

View File

@@ -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 -> {

View File

@@ -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()
}
}
}

View File

@@ -39,6 +39,7 @@ import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.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
View File

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

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

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

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

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

View File

@@ -14,12 +14,9 @@
* limitations under the License.
*/
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"
}

View File

@@ -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)

View File

@@ -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) },
)
}
}

View File

@@ -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 = {},
)
}

View File

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

View File

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

View File

@@ -1,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -20,6 +20,7 @@ import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.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
}

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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(),
)
}

View File

@@ -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>

View File

@@ -5,4 +5,5 @@
<string name="screen_invites_decline_direct_chat_message">"Möchten Sie den Chat mit %1$s wirklich ablehnen?"</string>
<string name="screen_invites_decline_direct_chat_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>

View File

@@ -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>

View File

@@ -32,12 +32,11 @@ import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_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,

View File

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

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -18,6 +18,7 @@ package io.element.android.features.login.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.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)
}
}
}
}

View File

@@ -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(

View File

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

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.accountprovider
import io.element.android.features.login.impl.util.defaultAccountProvider
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
@SingleIn(AppScope::class)
class AccountProviderDataSource @Inject constructor(
) {
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(
defaultAccountProvider
)
fun flow(): StateFlow<AccountProvider> {
return accountProvider.asStateFlow()
}
fun reset() {
accountProvider.tryEmit(defaultAccountProvider)
}
fun userSelection(data: AccountProvider) {
accountProvider.tryEmit(data)
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider>
get() = sequenceOf(
anAccountProvider(),
anAccountProvider().copy(subtitle = null),
anAccountProvider().copy(subtitle = null, title = "no.sliding.sync", supportSlidingSync = false),
anAccountProvider().copy(subtitle = null, title = "invalid", isValid = false, supportSlidingSync = false),
anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
// Add other state here
)
}
fun anAccountProvider() = AccountProvider(
title = "matrix.org",
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,
isValid = true,
supportSlidingSync = true,
)

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.accountprovider
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Divider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun AccountProviderView(
item: AccountProvider,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Column(modifier = modifier
.fillMaxWidth()
.clickable { onClick() }) {
Divider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.isMatrixOrg) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
resourceId = R.drawable.ic_matrix,
tint = Color.Unspecified,
)
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = Icons.Filled.Search,
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = item.title,
style = ElementTextStyles.Regular.headline.copy(textAlign = TextAlign.Start),
color = MaterialTheme.colorScheme.primary,
)
if (item.isPublic) {
Icon(
modifier = Modifier
.padding(start = 10.dp)
.size(16.dp),
resourceId = R.drawable.ic_public,
contentDescription = null,
tint = Color.Unspecified,
)
}
}
if (item.subtitle != null) {
Text(
modifier = Modifier
.padding(start = 46.dp, bottom = 12.dp, end = 26.dp),
text = item.subtitle,
style = ElementTextStyles.Regular.subheadline.copy(textAlign = TextAlign.Start),
color = MaterialTheme.colorScheme.secondary,
)
}
}
}
}
@Preview
@Composable
fun AccountProviderViewLightPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
ElementPreviewLight { ContentToPreview(item) }
@Preview
@Composable
fun AccountProviderViewDarkPreview(@PreviewParameter(AccountProviderProvider::class) item: AccountProvider) =
ElementPreviewDark { ContentToPreview(item) }
@Composable
private fun ContentToPreview(item: AccountProvider) {
AccountProviderView(
item = item,
onClick = { }
)
}

View File

@@ -16,8 +16,9 @@
package io.element.android.features.login.impl.changeserver
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
}

View File

@@ -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)
}
}

View File

@@ -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
)

View File

@@ -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 = {}
)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* 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 = {},
)
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.dialogs
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.R as StringR
@Composable
internal fun SlidingSyncNotSupportedDialog(
onLearnMoreClicked: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ConfirmationDialog(
modifier = modifier,
onDismiss = onDismiss,
submitText = stringResource(StringR.string.action_learn_more),
onSubmitClicked = onLearnMoreClicked,
onCancelClicked = onDismiss,
emphasizeSubmitButton = true,
title = stringResource(StringR.string.dialog_title_error),
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
)
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver
data class HomeserverData constructor(
// The computed homeserver url, for which a wellknown file has been retrieved, or just a valid Url
val homeserverUrl: String,
// True if a wellknown file has been found and is valid. If false, it means that the [homeserverUrl] is valid
val isWellknownValid: Boolean,
// True if a wellknown file has been found and is valid and is claiming a sliding sync Url
val supportSlidingSync: Boolean,
)

View File

@@ -0,0 +1,104 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver
import io.element.android.features.login.impl.resolver.network.WellknownRequest
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.core.uri.isValidUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.Collections
import javax.inject.Inject
/**
* Resolve homeserver base on search terms.
*/
class HomeserverResolver @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val wellknownRequest: WellknownRequest,
) {
suspend fun resolve(userInput: String): Flow<List<HomeserverData>> = flow {
val flowContext = currentCoroutineContext()
val trimmedUserInput = userInput.trim()
if (trimmedUserInput.length < 4) return@flow
val candidateBase = trimmedUserInput.ensureProtocol().removeSuffix("/")
val list = getUrlCandidates(candidateBase)
val currentList = Collections.synchronizedList(mutableListOf<HomeserverData>())
// Run all the requests in parallel
withContext(dispatchers.io) {
list.map { url ->
async {
val wellKnown = tryOrNull {
withTimeout(5000) {
wellknownRequest.execute(url)
}
}
val isValid = wellKnown?.isValid().orFalse()
if (isValid) {
val supportSlidingSync = wellKnown?.supportSlidingSync().orFalse()
// Emit the list as soon as possible
currentList.add(
HomeserverData(
homeserverUrl = url,
isWellknownValid = true,
supportSlidingSync = supportSlidingSync
)
)
withContext(flowContext) {
emit(currentList.toList())
}
}
}
}.awaitAll()
}
// If list is empty, and the user has entered an URL, do not block the user.
if (currentList.isEmpty() && trimmedUserInput.isValidUrl()) {
emit(
listOf(
HomeserverData(
homeserverUrl = trimmedUserInput,
isWellknownValid = false,
supportSlidingSync = false,
)
)
)
}
}
private fun getUrlCandidates(data: String): List<String> {
return buildList {
if (data.contains(".")) {
// TLD detected?
} else {
add("${data}.org")
add("${data}.com")
add("${data}.io")
}
// Always try what the user has entered
add(data)
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver.network
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.network.RetrofitFactory
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultWellknownRequest @Inject constructor(
private val retrofitFactory: RetrofitFactory,
) : WellknownRequest {
/**
* Return the WellKnown data, if found.
* @param baseUrl for instance https://matrix.org
*/
override suspend fun execute(baseUrl: String): WellKnown {
val wellknownApi = retrofitFactory.create(baseUrl)
.create(WellknownAPI::class.java)
return wellknownApi.getWellKnown()
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver.network
import io.element.android.libraries.core.bool.orFalse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "m.homeserver": {
* "base_url": "https://matrix.org"
* },
* "m.identity_server": {
* "base_url": "https://vector.im"
* },
* "org.matrix.msc3575.proxy": {
* "url": "https://slidingsync.lab.matrix.org"
* }
* }
* </pre>
* .
*/
@Serializable
data class WellKnown(
@SerialName("m.homeserver")
val homeServer: WellKnownBaseConfig? = null,
@SerialName("m.identity_server")
val identityServer: WellKnownBaseConfig? = null,
@SerialName("org.matrix.msc3575.proxy")
val slidingSyncProxy: WellKnownSlidingSyncConfig? = null,
) {
fun isValid(): Boolean {
return homeServer?.baseURL?.isNotBlank().orFalse()
}
fun supportSlidingSync(): Boolean {
return slidingSyncProxy?.url?.isNotBlank().orFalse()
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.resolver.network
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "base_url": "https://element.io"
* }
* </pre>
* .
*/
@Serializable
data class WellKnownBaseConfig(
@SerialName("base_url")
val baseURL: String? = null
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,49 +14,52 @@
* limitations under the License.
*/
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,
)
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
class ChangeAccountProviderPresenter @Inject constructor(
private val changeServerPresenter: ChangeServerPresenter,
) : Presenter<ChangeAccountProviderState> {
@Composable
override fun present(): ChangeAccountProviderState {
val changeServerState = changeServerPresenter.present()
return ChangeAccountProviderState(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
title = "matrix.org",
subtitle = null,
isPublic = true,
isMatrixOrg = true,
isValid = true,
supportSlidingSync = true,
)
),
changeServerState = changeServerState,
)
}
}

View File

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

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
open class ChangeAccountProviderStateProvider : PreviewParameterProvider<ChangeAccountProviderState> {
override val values: Sequence<ChangeAccountProviderState>
get() = sequenceOf(
aChangeAccountProviderState(),
// Add other state here
)
}
fun aChangeAccountProviderState() = ChangeAccountProviderState(
accountProviders = listOf(
anAccountProvider()
),
changeServerState = aChangeServerState(),
)

View File

@@ -0,0 +1,147 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
*/
@Composable
fun ChangeAccountProviderView(
state: ChangeAccountProviderState,
onBackPressed: () -> Unit,
onLearnMoreClicked: () -> Unit,
onDone: () -> Unit,
onOtherProviderClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier
.verticalScroll(state = rememberScrollState())
) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 32.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Home,
iconTint = MaterialTheme.colorScheme.primary,
title = stringResource(id = R.string.screen_change_account_provider_title),
subTitle = stringResource(id = R.string.screen_change_account_provider_subtitle),
)
state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView(
item = alteredItem,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(alteredItem))
}
)
}
// Other
AccountProviderView(
item = AccountProvider(
title = stringResource(id = R.string.screen_change_account_provider_other),
),
onClick = onOtherProviderClicked
)
Spacer(Modifier.height(32.dp))
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClicked = onLearnMoreClicked,
onDone = onDone,
)
}
}
}
@Preview
@Composable
fun ChangeAccountProviderViewLightPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ChangeAccountProviderViewDarkPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ChangeAccountProviderState) {
ChangeAccountProviderView(
state = state,
onBackPressed = { },
onLearnMoreClicked = { },
onDone = { },
onOtherProviderClicked = { },
)
}

View File

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

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.login.impl.util.openLearnMorePage
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ContributesNode(AppScope::class)
class ConfirmAccountProviderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ConfirmAccountProviderPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val isAccountCreation: Boolean,
) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(
ConfirmAccountProviderPresenter.Params(
isAccountCreation = inputs.isAccountCreation,
)
)
interface Callback : Plugin {
fun onLoginPasswordNeeded()
fun onOidcDetails(oidcDetails: OidcDetails)
fun onChangeAccountProvider()
}
private fun onOidcDetails(data: OidcDetails) {
plugins<Callback>().forEach { it.onOidcDetails(data) }
}
private fun onLoginPasswordNeeded() {
plugins<Callback>().forEach { it.onLoginPasswordNeeded() }
}
private fun onChangeAccountProvider() {
plugins<Callback>().forEach { it.onChangeAccountProvider() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
ConfirmAccountProviderView(
state = state,
modifier = modifier,
onOidcDetails = ::onOidcDetails,
onLoginPasswordNeeded = ::onLoginPasswordNeeded,
onChange = ::onChangeAccountProvider,
onLearnMoreClicked = { openLearnMorePage(context) },
)
}
}

View File

@@ -0,0 +1,100 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.net.URL
class ConfirmAccountProviderPresenter @AssistedInject constructor(
@Assisted private val params: Params,
private val accountProviderDataSource: AccountProviderDataSource,
private val authenticationService: MatrixAuthenticationService
) : Presenter<ConfirmAccountProviderState> {
data class Params(
val isAccountCreation: Boolean,
)
@AssistedFactory
interface Factory {
fun create(params: Params): ConfirmAccountProviderPresenter
}
@Composable
override fun present(): ConfirmAccountProviderState {
val accountProvider by accountProviderDataSource.flow().collectAsState()
val localCoroutineScope = rememberCoroutineScope()
val loginFlowAction: MutableState<Async<LoginFlow>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) {
ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.title, loginFlowAction)
}
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
}
}
return ConfirmAccountProviderState(
accountProvider = accountProvider,
isAccountCreation = params.isAccountCreation,
loginFlow = loginFlowAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(
homeserverUrl: String,
loginFlowAction: MutableState<Async<LoginFlow>>,
) = launch {
suspend {
val domain = tryOrNull { URL(homeserverUrl) }?.host ?: homeserverUrl
authenticationService.setHomeserver(domain).map {
val matrixHomeServerDetails = authenticationService.getHomeserverDetails().value!!
if (matrixHomeServerDetails.supportsOidcLogin) {
// Retrieve the details right now
LoginFlow.OidcFlow(authenticationService.getOidcUrl().getOrThrow())
} else if (matrixHomeServerDetails.supportsPasswordLogin) {
LoginFlow.PasswordLogin
} else {
throw IllegalStateException("Unsupported login flow")
}
}.getOrThrow()
}.execute(loginFlowAction, errorMapping = ChangeServerError::from)
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.confirmaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.OidcDetails
// Do not use default value, so no member get forgotten in the presenters.
data class ConfirmAccountProviderState(
val accountProvider: AccountProvider,
val isAccountCreation: Boolean,
val loginFlow: Async<LoginFlow>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit
) {
val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
}
sealed interface LoginFlow {
object PasswordLogin : LoginFlow
data class OidcFlow(val oidcDetails: OidcDetails) : LoginFlow
}

View File

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

View File

@@ -0,0 +1,163 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.dialogs.SlidingSyncNotSupportedDialog
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.button.ButtonWithProgress
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onOidcDetails: (OidcDetails) -> Unit,
onLoginPasswordNeeded: () -> Unit,
onLearnMoreClicked: () -> Unit,
onChange: () -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginFlow) {
derivedStateOf {
state.loginFlow is Async.Loading
}
}
val eventSink = state.eventSink
HeaderFooterPage(
modifier = modifier,
header = {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp),
iconImageVector = Icons.Filled.AccountCircle,
title = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_title
} else {
R.string.screen_account_provider_signin_title
},
state.accountProvider.title
),
subTitle = stringResource(
id = if (state.isAccountCreation) {
R.string.screen_account_provider_signup_subtitle
} else {
R.string.screen_account_provider_signin_subtitle
},
)
)
},
footer = {
ButtonColumnMolecule {
ButtonWithProgress(
text = stringResource(id = R.string.screen_account_provider_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginContinue)
)
TextButton(
onClick = onChange,
enabled = true,
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.loginChangeServer)
) {
Text(text = stringResource(id = R.string.screen_account_provider_change))
}
}
}
) {
when (state.loginFlow) {
is Async.Failure -> {
when (val error = state.loginFlow.error) {
is ChangeServerError.Error -> {
ErrorDialog(
content = error.message(),
onDismiss = {
eventSink.invoke(ConfirmAccountProviderEvents.ClearError)
}
)
}
is ChangeServerError.SlidingSyncAlert -> {
SlidingSyncNotSupportedDialog(onLearnMoreClicked = {
onLearnMoreClicked()
eventSink(ConfirmAccountProviderEvents.ClearError)
}, onDismiss = {
eventSink(ConfirmAccountProviderEvents.ClearError)
})
}
}
}
is Async.Loading -> Unit // The Continue button shows the loading state
is Async.Success -> {
when (val loginFlowState = state.loginFlow.state) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
}
}
Async.Uninitialized -> Unit
}
}
}
@Preview
@Composable
fun ConfirmAccountProviderViewLightPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun ConfirmAccountProviderViewDarkPreview(@PreviewParameter(ConfirmAccountProviderStateProvider::class) state: ConfirmAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: ConfirmAccountProviderState) {
ConfirmAccountProviderView(
state = state,
onOidcDetails = {},
onLoginPasswordNeeded = {},
onLearnMoreClicked = {},
onChange = {},
)
}

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginPasswordPresenter @Inject constructor(
private val authenticationService: MatrixAuthenticationService,
private val accountProviderDataSource: AccountProviderDataSource,
) : Presenter<LoginPasswordState> {
@Composable
override fun present(): LoginPasswordState {
val localCoroutineScope = rememberCoroutineScope()
val loginAction: MutableState<Async<SessionId>> = remember {
mutableStateOf(Async.Uninitialized)
}
val formState = rememberSaveable {
mutableStateOf(LoginFormState.Default)
}
val accountProvider by accountProviderDataSource.flow().collectAsState()
fun handleEvents(event: LoginPasswordEvents) {
when (event) {
is LoginPasswordEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginPasswordEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginPasswordEvents.Submit -> {
localCoroutineScope.submit(formState.value, loginAction)
}
LoginPasswordEvents.ClearError -> loginAction.value = Async.Uninitialized
}
}
return LoginPasswordState(
accountProvider = accountProvider,
formState = formState.value,
loginAction = loginAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submit(formState: LoginFormState, loggedInState: MutableState<Async<SessionId>>) = launch {
loggedInState.value = Async.Loading()
authenticationService.login(formState.login.trim(), formState.password)
.onSuccess { sessionId ->
loggedInState.value = Async.Success(sessionId)
}
.onFailure { failure ->
loggedInState.value = Async.Failure(failure)
}
}
private fun updateFormState(formState: MutableState<LoginFormState>, updateLambda: LoginFormState.() -> LoginFormState) {
formState.value = updateLambda(formState.value)
}
}

View File

@@ -14,36 +14,23 @@
* limitations under the License.
*/
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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* 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 = {}
)

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.searchaccountprovider
sealed interface SearchAccountProviderEvents {
/**
* The user has typed something, expect to get a list of matching account provider results
* in the state.
*/
data class UserInput(val input: String) : SearchAccountProviderEvents
}

View File

@@ -14,10 +14,11 @@
* limitations under the License.
*/
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,
)
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.resolver.HomeserverResolver
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
class SearchAccountProviderPresenter @Inject constructor(
private val homeserverResolver: HomeserverResolver,
private val changeServerPresenter: ChangeServerPresenter,
) : Presenter<SearchAccountProviderState> {
@Composable
override fun present(): SearchAccountProviderState {
var userInput by rememberSaveable {
mutableStateOf("")
}
val changeServerState = changeServerPresenter.present()
val data: MutableState<Async<List<HomeserverData>>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(userInput) {
onUserInput(userInput, data)
}
fun handleEvents(event: SearchAccountProviderEvents) {
when (event) {
is SearchAccountProviderEvents.UserInput -> {
userInput = event.input
}
}
}
return SearchAccountProviderState(
userInput = userInput,
userInputResult = data.value,
changeServerState = changeServerState,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.onUserInput(userInput: String, data: MutableState<Async<List<HomeserverData>>>) = launch {
data.value = Async.Uninitialized
// Debounce
delay(300)
data.value = Async.Loading()
homeserverResolver.resolve(userInput).collect {
data.value = Async.Success(it)
}
if (data.value !is Async.Success) {
data.value = Async.Uninitialized
}
}
}

View File

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

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
override val values: Sequence<SearchAccountProviderState>
get() = sequenceOf(
aSearchAccountProviderState(),
aSearchAccountProviderState(userInputResult = Async.Success(aHomeserverDataList())),
// Add other state here
)
}
fun aSearchAccountProviderState(
userInput: String = "",
userInputResult: Async<List<HomeserverData>> = Async.Uninitialized,
) = SearchAccountProviderState(
userInput = userInput,
userInputResult = userInputResult,
changeServerState = aChangeServerState(),
eventSink = {}
)
fun aHomeserverDataList(): List<HomeserverData> {
return listOf(
aHomeserverData(isWellknownValid = true, supportSlidingSync = true),
aHomeserverData(homeserverUrl = "https://no.sliding.sync", isWellknownValid = true, supportSlidingSync = false),
aHomeserverData(homeserverUrl = "https://invalid", isWellknownValid = false, supportSlidingSync = false),
)
}
fun aHomeserverData(
homeserverUrl: String = "https://matrix.org",
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): HomeserverData {
return HomeserverData(
homeserverUrl = homeserverUrl,
isWellknownValid = isWellknownValid,
supportSlidingSync = supportSlidingSync,
)
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=611-61435
*/
@Composable
fun SearchAccountProviderView(
state: SearchAccountProviderState,
onBackPressed: () -> Unit,
onLearnMoreClicked: () -> Unit,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) }
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
item {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 16.dp, bottom = 40.dp, start = 16.dp, end = 16.dp),
iconImageVector = Icons.Filled.Search,
title = stringResource(id = R.string.screen_account_provider_form_title),
subTitle = stringResource(id = R.string.screen_account_provider_form_subtitle),
)
}
item {
// TextInput
var userInputState by textFieldState(stateValue = state.userInput)
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = userInputState,
// readOnly = isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
.onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.changeServerServer),
onValueChange = {
userInputState = it
eventSink(SearchAccountProviderEvents.UserInput(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = {
focusManager.moveFocus(FocusDirection.Down)
}),
singleLine = true,
trailingIcon = if (userInputState.isNotEmpty()) {
{
IconButton(onClick = {
userInputState = ""
eventSink(SearchAccountProviderEvents.UserInput(""))
}) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(StringR.string.action_clear)
)
}
}
} else null,
supportingText = {
Text(text = stringResource(id = R.string.screen_account_provider_form_notice), color = MaterialTheme.colorScheme.secondary)
}
)
}
when (state.userInputResult) {
is Async.Failure -> {
// Ignore errors (let the user type more chars)
}
is Async.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxSize()
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
is Async.Success -> {
items(state.userInputResult.state) { homeserverData ->
val item = homeserverData.toAccountProvider()
AccountProviderView(
item = item,
onClick = {
state.changeServerState.eventSink.invoke(ChangeServerEvents.ChangeServer(item))
}
)
}
}
Async.Uninitialized -> Unit
}
item {
Spacer(Modifier.height(32.dp))
}
}
ChangeServerView(
state = state.changeServerState,
onLearnMoreClicked = onLearnMoreClicked,
onDone = onDone,
)
}
}
}
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == "https://matrix.org"
return AccountProvider(
title = homeserverUrl.removePrefix("http://").removePrefix("https://"),
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
isPublic = isMatrixOrg, // There is no need to know for other servers right now
isMatrixOrg = isMatrixOrg,
isValid = isWellknownValid,
supportSlidingSync = supportSlidingSync,
)
}
@Preview
@Composable
fun SearchAccountProviderViewLightPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun SearchAccountProviderViewDarkPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: SearchAccountProviderState) {
SearchAccountProviderView(
state = state,
onBackPressed = {},
onLearnMoreClicked = {},
onDone = {},
)
}

View File

@@ -16,8 +16,18 @@
package io.element.android.features.login.impl.util
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,
)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<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