Loading .gitlab-ci.yml +4 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,10 @@ stages: - build before_script: - echo MURENA_CLIENT_ID=$MURENA_CLIENT_ID >> local.properties - echo MURENA_REDIRECT_URI=$MURENA_REDIRECT_URI >> local.properties - echo MURENA_BASE_URL=$MURENA_BASE_URL_STAGING >> local.properties - echo MURENA_DISCOVERY_END_POINT=$MURENA_DISCOVERY_END_POINT_STAGING >> local.properties - export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 - export GRADLE_USER_HOME=$(pwd)/.gradle - chmod +x ./gradlew Loading app/.gitignore +1 −0 Original line number Diff line number Diff line build target ose app/build.gradle.kts +26 −0 Original line number Diff line number Diff line import java.util.Properties /* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ Loading Loading @@ -58,6 +60,18 @@ android { create("ose") { dimension = "distribution" versionNameSuffix = "-ose" buildConfigField("String", "MURENA_CLIENT_ID", "\"${retrieveKey("MURENA_CLIENT_ID")}\"") buildConfigField("String", "MURENA_REDIRECT_URI", "\"${retrieveKey("MURENA_REDIRECT_URI")}\"") buildConfigField("String", "MURENA_BASE_URL", "\"${retrieveKey("MURENA_BASE_URL")}\"") buildConfigField("String", "MURENA_DISCOVERY_END_POINT", "\"${retrieveKey("MURENA_DISCOVERY_END_POINT")}\"") manifestPlaceholders.putAll( mapOf<String, Any>( "appAuthRedirectScheme" to "$applicationId", "murenaAuthRedirectScheme" to retrieveKey("MURENA_REDIRECT_URI"), ) ) } } Loading Loading @@ -127,6 +141,15 @@ android { } } fun retrieveKey(keyName: String): String { val properties = Properties().apply { load(rootProject.file("local.properties").inputStream()) } return properties.getProperty(keyName) ?: throw GradleException("$keyName property not found in local.properties file") } ksp { arg("room.schemaLocation", "$projectDir/schemas") } Loading Loading @@ -213,6 +236,9 @@ dependencies { implementation(libs.commons.codec) implementation(libs.commons.lang) // e-Specific dependencies - (Avoid moving into toml) implementation("androidx.compose.runtime:runtime-livedata:1.8.3") // for tests androidTestImplementation(libs.androidx.arch.core.testing) androidTestImplementation(libs.androidx.test.core) Loading app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt 0 → 100644 +51 −0 Original line number Diff line number Diff line /* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ package foundation.e.accountmanager.network import android.net.Uri import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.ResponseTypeValues import java.net.URI import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine object OAuthMurena { private val SCOPES = arrayOf("openid", "profile", "email", "offline_access") const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID private val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host } val baseUri = "https://$DOMAIN" val redirectURI = "${BuildConfig.MURENA_REDIRECT_URI}:/redirect".toUri() val discoveryUrl = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri() suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration = suspendCoroutine { cont -> AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex -> if (config != null) cont.resume(config) else cont.resumeWithException(ex ?: Exception("Unknown error")) } } fun signIn(email: String?, locale: String?, config: AuthorizationServiceConfiguration): AuthorizationRequest { val builder = AuthorizationRequest.Builder( config, CLIENT_ID, ResponseTypeValues.CODE, redirectURI ) return builder .setScopes(*SCOPES) .setLoginHint(email) .setUiLocales(locale) .build() } } app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt 0 → 100644 +331 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 eFoundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.accountmanager.ui.setup import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.ui.setup.LoginType import foundation.e.accountmanager.network.OAuthMurena import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.logging.Level import java.util.logging.Logger object MurenaLogin : LoginType { override val title: Int get() = R.string.eelo_account_name override val helpUrl: Uri get() = "https://doc.e.foundation/support-topics".toUri() @Composable override fun LoginScreen( snackbarHostState: SnackbarHostState, initialLoginInfo: LoginInfo, onLogin: (LoginInfo) -> Unit ) { val model: MurenaLoginModel = hiltViewModel( creationCallback = { factory: MurenaLoginModel.Factory -> factory.create(loginInfo = initialLoginInfo) } ) // continue to resource detection when result is set in model val uiState = model.uiState LaunchedEffect(uiState.result) { if (uiState.result != null) { onLogin(uiState.result) model.resetResult() } } // contract to open the browser for authentication val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> if (authResponse != null) model.authenticate(authResponse) else model.authCodeFailed() } LaunchedEffect(uiState.error) { if (uiState.error != null) { snackbarHostState.showSnackbar(uiState.error) } } val isLoading by model.isLoading.observeAsState(false) if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } return } MurenaLoginScreen( email = uiState.email, serverUri = uiState.baseUri, discoveryEndPoint = uiState.discoveryEndPoint, onSetEmail = model::setEmail, onSetServerUrl = model::setCustomBareUri, onSetDiscoveryEndPoint = model::setDiscoveryEndPoint, canContinue = uiState.canContinue, onLogin = { if (uiState.canContinue) { CoroutineScope(Dispatchers.Main).launch { try { val authRequest = model.signIn() authRequestContract.launch(authRequest) } catch (e: Exception) { Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e) model.signInFailed() } } } } ) } } @Composable fun MurenaLoginScreen( email: String, serverUri: String, discoveryEndPoint: String, onSetEmail: (String) -> Unit = {}, onSetServerUrl: (String) -> Unit = {}, onSetDiscoveryEndPoint: (String) -> Unit = {}, canContinue: Boolean, onLogin: () -> Unit = {}, ) { var showServerField by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(40.dp)) Text( text = stringResource(R.string.login_eelo_title), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.height(24.dp)) val focusRequester = remember { FocusRequester() } OutlinedTextField( value = email, onValueChange = onSetEmail, singleLine = true, label = { Text(stringResource(R.string.login_user_id)) }, placeholder = { Text("example@murena.io") }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) // Suggestion buttons to add domain suffixes if (!email.contains("@")) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) ) { OutlinedButton( onClick = { onSetEmail("$email@murena.io") }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), modifier = Modifier .defaultMinSize(minHeight = 32.dp) ) { Text( text = "@murena.io", style = MaterialTheme.typography.bodySmall ) } OutlinedButton( onClick = { onSetEmail("$email@e.email") }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), modifier = Modifier .defaultMinSize(minHeight = 32.dp) ) { Text( text = "@e.email", style = MaterialTheme.typography.bodySmall ) } } } if (showServerField) { OutlinedTextField( value = serverUri, onValueChange = onSetServerUrl, singleLine = true, label = { Text(stringResource(R.string.login_server_url)) }, placeholder = { Text(OAuthMurena.baseUri) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) OutlinedTextField( value = discoveryEndPoint, onValueChange = onSetDiscoveryEndPoint, label = { Text(stringResource(R.string.login_discovery_endpoint)) }, placeholder = { Text(OAuthMurena.discoveryUrl.toString()) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) } else { onSetServerUrl("") onSetDiscoveryEndPoint("") } LaunchedEffect(Unit) { if (email.isEmpty()) focusRequester.requestFocus() } Spacer(modifier = Modifier.height(14.dp)) Button( onClick = onLogin, enabled = canContinue, modifier = Modifier .fillMaxWidth() .height(48.dp) ) { Text(stringResource(R.string.login_nextcloud_login_flow_sign_in)) } Spacer(modifier = Modifier.height(16.dp)) Column ( modifier = Modifier .clickable { showServerField = !showServerField } .padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.login_eelo_server_uri_title), color = Color.Gray, style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = if (showServerField) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = if (showServerField) stringResource(R.string.collapse) else stringResource(R.string.expand), tint = Color.Gray ) } } } @Composable @Preview(showBackground = true) fun MurenaLoginScreen_Preview_Empty() { MurenaLoginScreen( email = "", serverUri = "", discoveryEndPoint = "", canContinue = false, ) } @Composable @Preview(showBackground = true) fun MurenaLoginScreen_Preview_WithDefaultEmail() { MurenaLoginScreen( email = "example@murena.io", serverUri = OAuthMurena.baseUri, discoveryEndPoint = OAuthMurena.discoveryUrl.toString(), canContinue = true, ) } Loading
.gitlab-ci.yml +4 −0 Original line number Diff line number Diff line Loading @@ -5,6 +5,10 @@ stages: - build before_script: - echo MURENA_CLIENT_ID=$MURENA_CLIENT_ID >> local.properties - echo MURENA_REDIRECT_URI=$MURENA_REDIRECT_URI >> local.properties - echo MURENA_BASE_URL=$MURENA_BASE_URL_STAGING >> local.properties - echo MURENA_DISCOVERY_END_POINT=$MURENA_DISCOVERY_END_POINT_STAGING >> local.properties - export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 - export GRADLE_USER_HOME=$(pwd)/.gradle - chmod +x ./gradlew Loading
app/build.gradle.kts +26 −0 Original line number Diff line number Diff line import java.util.Properties /* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ Loading Loading @@ -58,6 +60,18 @@ android { create("ose") { dimension = "distribution" versionNameSuffix = "-ose" buildConfigField("String", "MURENA_CLIENT_ID", "\"${retrieveKey("MURENA_CLIENT_ID")}\"") buildConfigField("String", "MURENA_REDIRECT_URI", "\"${retrieveKey("MURENA_REDIRECT_URI")}\"") buildConfigField("String", "MURENA_BASE_URL", "\"${retrieveKey("MURENA_BASE_URL")}\"") buildConfigField("String", "MURENA_DISCOVERY_END_POINT", "\"${retrieveKey("MURENA_DISCOVERY_END_POINT")}\"") manifestPlaceholders.putAll( mapOf<String, Any>( "appAuthRedirectScheme" to "$applicationId", "murenaAuthRedirectScheme" to retrieveKey("MURENA_REDIRECT_URI"), ) ) } } Loading Loading @@ -127,6 +141,15 @@ android { } } fun retrieveKey(keyName: String): String { val properties = Properties().apply { load(rootProject.file("local.properties").inputStream()) } return properties.getProperty(keyName) ?: throw GradleException("$keyName property not found in local.properties file") } ksp { arg("room.schemaLocation", "$projectDir/schemas") } Loading Loading @@ -213,6 +236,9 @@ dependencies { implementation(libs.commons.codec) implementation(libs.commons.lang) // e-Specific dependencies - (Avoid moving into toml) implementation("androidx.compose.runtime:runtime-livedata:1.8.3") // for tests androidTestImplementation(libs.androidx.arch.core.testing) androidTestImplementation(libs.androidx.test.core) Loading
app/src/main/kotlin/foundation/e/accountmanager/network/OAuthMurena.kt 0 → 100644 +51 −0 Original line number Diff line number Diff line /* * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ package foundation.e.accountmanager.network import android.net.Uri import androidx.core.net.toUri import at.bitfire.davdroid.BuildConfig import net.openid.appauth.AuthorizationRequest import net.openid.appauth.AuthorizationServiceConfiguration import net.openid.appauth.ResponseTypeValues import java.net.URI import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine object OAuthMurena { private val SCOPES = arrayOf("openid", "profile", "email", "offline_access") const val CLIENT_ID = BuildConfig.MURENA_CLIENT_ID private val DOMAIN: String by lazy { URI(BuildConfig.MURENA_BASE_URL).host } val baseUri = "https://$DOMAIN" val redirectURI = "${BuildConfig.MURENA_REDIRECT_URI}:/redirect".toUri() val discoveryUrl = BuildConfig.MURENA_DISCOVERY_END_POINT.toUri() suspend fun fetchOAuthConfigSuspend(discoveryUrl: Uri): AuthorizationServiceConfiguration = suspendCoroutine { cont -> AuthorizationServiceConfiguration.fetchFromUrl(discoveryUrl) { config, ex -> if (config != null) cont.resume(config) else cont.resumeWithException(ex ?: Exception("Unknown error")) } } fun signIn(email: String?, locale: String?, config: AuthorizationServiceConfiguration): AuthorizationRequest { val builder = AuthorizationRequest.Builder( config, CLIENT_ID, ResponseTypeValues.CODE, redirectURI ) return builder .setScopes(*SCOPES) .setLoginHint(email) .setUiLocales(locale) .build() } }
app/src/main/kotlin/foundation/e/accountmanager/ui/setup/MurenaLogin.kt 0 → 100644 +331 −0 Original line number Diff line number Diff line /* * Copyright (C) 2025 eFoundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.accountmanager.ui.setup import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import at.bitfire.davdroid.R import at.bitfire.davdroid.ui.setup.LoginInfo import at.bitfire.davdroid.ui.setup.LoginType import foundation.e.accountmanager.network.OAuthMurena import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.logging.Level import java.util.logging.Logger object MurenaLogin : LoginType { override val title: Int get() = R.string.eelo_account_name override val helpUrl: Uri get() = "https://doc.e.foundation/support-topics".toUri() @Composable override fun LoginScreen( snackbarHostState: SnackbarHostState, initialLoginInfo: LoginInfo, onLogin: (LoginInfo) -> Unit ) { val model: MurenaLoginModel = hiltViewModel( creationCallback = { factory: MurenaLoginModel.Factory -> factory.create(loginInfo = initialLoginInfo) } ) // continue to resource detection when result is set in model val uiState = model.uiState LaunchedEffect(uiState.result) { if (uiState.result != null) { onLogin(uiState.result) model.resetResult() } } // contract to open the browser for authentication val authRequestContract = rememberLauncherForActivityResult(model.authorizationContract()) { authResponse -> if (authResponse != null) model.authenticate(authResponse) else model.authCodeFailed() } LaunchedEffect(uiState.error) { if (uiState.error != null) { snackbarHostState.showSnackbar(uiState.error) } } val isLoading by model.isLoading.observeAsState(false) if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } return } MurenaLoginScreen( email = uiState.email, serverUri = uiState.baseUri, discoveryEndPoint = uiState.discoveryEndPoint, onSetEmail = model::setEmail, onSetServerUrl = model::setCustomBareUri, onSetDiscoveryEndPoint = model::setDiscoveryEndPoint, canContinue = uiState.canContinue, onLogin = { if (uiState.canContinue) { CoroutineScope(Dispatchers.Main).launch { try { val authRequest = model.signIn() authRequestContract.launch(authRequest) } catch (e: Exception) { Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e) model.signInFailed() } } } } ) } } @Composable fun MurenaLoginScreen( email: String, serverUri: String, discoveryEndPoint: String, onSetEmail: (String) -> Unit = {}, onSetServerUrl: (String) -> Unit = {}, onSetDiscoveryEndPoint: (String) -> Unit = {}, canContinue: Boolean, onLogin: () -> Unit = {}, ) { var showServerField by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(40.dp)) Text( text = stringResource(R.string.login_eelo_title), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.height(24.dp)) val focusRequester = remember { FocusRequester() } OutlinedTextField( value = email, onValueChange = onSetEmail, singleLine = true, label = { Text(stringResource(R.string.login_user_id)) }, placeholder = { Text("example@murena.io") }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) // Suggestion buttons to add domain suffixes if (!email.contains("@")) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp) ) { OutlinedButton( onClick = { onSetEmail("$email@murena.io") }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), modifier = Modifier .defaultMinSize(minHeight = 32.dp) ) { Text( text = "@murena.io", style = MaterialTheme.typography.bodySmall ) } OutlinedButton( onClick = { onSetEmail("$email@e.email") }, contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), shape = RoundedCornerShape(12.dp), modifier = Modifier .defaultMinSize(minHeight = 32.dp) ) { Text( text = "@e.email", style = MaterialTheme.typography.bodySmall ) } } } if (showServerField) { OutlinedTextField( value = serverUri, onValueChange = onSetServerUrl, singleLine = true, label = { Text(stringResource(R.string.login_server_url)) }, placeholder = { Text(OAuthMurena.baseUri) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) OutlinedTextField( value = discoveryEndPoint, onValueChange = onSetDiscoveryEndPoint, label = { Text(stringResource(R.string.login_discovery_endpoint)) }, placeholder = { Text(OAuthMurena.discoveryUrl.toString()) }, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done ), modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester) ) } else { onSetServerUrl("") onSetDiscoveryEndPoint("") } LaunchedEffect(Unit) { if (email.isEmpty()) focusRequester.requestFocus() } Spacer(modifier = Modifier.height(14.dp)) Button( onClick = onLogin, enabled = canContinue, modifier = Modifier .fillMaxWidth() .height(48.dp) ) { Text(stringResource(R.string.login_nextcloud_login_flow_sign_in)) } Spacer(modifier = Modifier.height(16.dp)) Column ( modifier = Modifier .clickable { showServerField = !showServerField } .padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = stringResource(R.string.login_eelo_server_uri_title), color = Color.Gray, style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = if (showServerField) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = if (showServerField) stringResource(R.string.collapse) else stringResource(R.string.expand), tint = Color.Gray ) } } } @Composable @Preview(showBackground = true) fun MurenaLoginScreen_Preview_Empty() { MurenaLoginScreen( email = "", serverUri = "", discoveryEndPoint = "", canContinue = false, ) } @Composable @Preview(showBackground = true) fun MurenaLoginScreen_Preview_WithDefaultEmail() { MurenaLoginScreen( email = "example@murena.io", serverUri = OAuthMurena.baseUri, discoveryEndPoint = OAuthMurena.discoveryUrl.toString(), canContinue = true, ) }