Rewrite login to M3 and with better UI states (bitfireAT/davx5#567)

* [WIP] Rewrite login to M3 and better UI states

* Use AccountRepository to create account

* LoginModel is SSOT for page navigation

* Support forced group method

* Show progress bar when account is being created

* Make account name suggestions work again

* Use M3 text field supportText for errors

* Refactor: login by URL, login by email, advanced login

* Refactor Nextcloud login, move login flow logic to separate class

* Refactor Google login, move OAuth logic to separate class

* Fix errors when navigating back after successful resource detection

* Make PasswordTextField M3

* ManagedLogin: M3, UiState

* Updated theme; managed login functionality

* Improve back navigation
This commit is contained in:
Ricki Hirner 2024-04-21 16:20:08 +02:00
parent 34b88c3ad8
commit 019dde6ef9
35 changed files with 2373 additions and 1778 deletions

View file

@ -0,0 +1,88 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.network
import android.net.Uri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import java.net.URI
class GoogleLogin(
val authService: AuthorizationService
) {
companion object {
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
private val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun googleBaseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token")
)
}
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
GoogleLogin.serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
suspend fun authenticate(authResponse: AuthorizationResponse): Credentials {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
}
}
}
return credentials.await()
}
}

View file

@ -0,0 +1,139 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.network
import android.content.Context
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.apache.commons.lang3.StringUtils
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
/**
* Implements Nextcloud Login Flow v2.
*
* See https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class NextcloudLoginFlow(
context: Context
): AutoCloseable {
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"
/** Path to DAV endpoint (e.g. `remote.php/dav`). Will be appended to the server URL returned by Login Flow. */
const val DAV_PATH = "remote.php/dav"
}
val httpClient = HttpClient.Builder(context)
.setForeground(true)
.build()
override fun close() {
httpClient.close()
}
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = StringUtils.appendIfMissing(json.getString("server"), "/")
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword")
)
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.okHttpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body?.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}

View file

@ -2,19 +2,13 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Application
import android.content.ContentResolver
import android.provider.CalendarContract
import androidx.core.os.CancellationSignal
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
@ -27,116 +21,46 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import java.util.logging.Level
import javax.inject.Inject
@HiltViewModel
class LoginModel @Inject constructor(
/**
* Repository for managing CalDAV/CardDAV accounts.
*
* *Note:* This class is not related to address book accounts, which are managed by
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
val context: Application,
val db: AppDatabase,
val settingsManager: SettingsManager
): ViewModel() {
) {
val forcedGroupMethod = settingsManager.getStringFlow(AccountSettings.KEY_CONTACT_GROUP_METHOD).map { methodName ->
methodName?.let {
try {
GroupMethod.valueOf(it)
} catch (e: IllegalArgumentException) {
null
}
}
}
val accountType = context.getString(R.string.account_type)
val foundConfig = MutableLiveData<DavResourceFinder.Configuration>()
fun detectResources(loginInfo: LoginInfo, cancellationSignal: CancellationSignal) {
foundConfig.value = null
val job = viewModelScope.launch(Dispatchers.IO) {
try {
val configuration = runInterruptible {
DavResourceFinder(context, loginInfo.baseUri!!, loginInfo.credentials).use { finder ->
finder.findInitialConfiguration()
}
}
foundConfig.postValue(configuration)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Exception during service detection", e)
}
}
cancellationSignal.setOnCancelListener {
job.cancel()
}
}
fun accountExists(accountName: String): LiveData<Boolean> = liveData {
val accountType = context.getString(R.string.account_type)
val exists =
if (accountName.isEmpty())
false
else
AccountManager.get(context)
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
emit(exists)
}
interface CreateAccountResult {
class Success(val account: Account): CreateAccountResult
class Error(val exception: Exception?): CreateAccountResult
}
val createAccountResult = MutableLiveData<CreateAccountResult>()
fun createAccount(
credentials: Credentials?,
foundConfig: DavResourceFinder.Configuration,
name: String,
groupMethod: GroupMethod
) {
viewModelScope.launch(Dispatchers.IO) {
try {
if (createAccount(name, credentials, foundConfig, groupMethod))
createAccountResult.postValue(CreateAccountResult.Success(Account(name, context.getString(R.string.account_type))))
else
createAccountResult.postValue(CreateAccountResult.Error(null))
} catch (e: Exception) {
createAccountResult.postValue(CreateAccountResult.Error(e))
}
}
}
/**
* Creates a new main account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param name Name of the account
* @param credentials Server credentials
* @param config Discovered server capabilities for syncable authorities
* @param groupMethod Whether CardDAV contact groups are separate VCards or as contact categories
* @return *true* if account creation was succesful; *false* otherwise (for instance because an account with this name already exists)
* @param accountName name of the account
* @param credentials server credentials
* @param config discovered server capabilities for syncable authorities
* @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Boolean {
val account = Account(name, context.getString(R.string.account_type))
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = Account(accountName, context.getString(R.string.account_type))
// create Android account
val userData = AccountSettings.initialUserData(credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
return false
return null
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
@ -148,7 +72,7 @@ class LoginModel @Inject constructor(
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(name, Service.TYPE_CARDDAV, config.cardDAV)
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
@ -165,7 +89,7 @@ class LoginModel @Inject constructor(
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(name, Service.TYPE_CALDAV, config.calDAV)
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
@ -188,9 +112,9 @@ class LoginModel @Inject constructor(
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
return false
return null
}
return true
return account
}
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
@ -213,4 +137,13 @@ class LoginModel @Inject constructor(
return serviceId
}
fun exists(accountName: String): Boolean =
if (accountName.isEmpty())
false
else
AccountManager.get(context)
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
}

View file

@ -21,7 +21,7 @@ import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.customtabs.CustomTabsClient
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
@ -155,7 +155,7 @@ object UiUtils {
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.secondary
color = MaterialTheme.colorScheme.primary
),
start = start, end = end
)

View file

@ -8,16 +8,20 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun Assistant(
@ -27,26 +31,31 @@ fun Assistant(
content: @Composable () -> Unit
) {
Column(Modifier.fillMaxSize()) {
Column(Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.weight(1f)) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.weight(1f)) {
content()
}
Surface(Modifier
.fillMaxWidth()
.imePadding()) {
if (nextLabel != null)
TextButton(
enabled = nextEnabled,
onClick = onNext,
modifier = Modifier
.wrapContentSize(Alignment.CenterEnd)
) {
Text(nextLabel.uppercase())
}
}
BottomAppBar(
modifier = Modifier
.fillMaxWidth()
.imePadding(),
actions = {
if (nextLabel != null)
Button(
enabled = nextEnabled,
onClick = onNext,
modifier = Modifier
.padding(horizontal = 8.dp)
.wrapContentSize(Alignment.CenterEnd)
) {
Text(nextLabel)
}
}
)
}
}

View file

@ -7,10 +7,10 @@ package at.bitfire.davdroid.ui.composable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@ -44,7 +44,7 @@ fun PasswordTextField(
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { if (labelText != null) Text(labelText) },
label = labelText?.let { { Text(it) } },
leadingIcon = leadingIcon,
isError = isError,
singleLine = true,
@ -54,7 +54,10 @@ fun PasswordTextField(
keyboardActions = keyboardActions,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
IconButton(
enabled = enabled,
onClick = { passwordVisible = !passwordVisible }
) {
if (passwordVisible)
Icon(Icons.Default.VisibilityOff, stringResource(R.string.login_password_hide))
else

View file

@ -0,0 +1,97 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.composable
import android.app.Activity
import android.os.Build
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
import kotlinx.coroutines.launch
@Composable
fun SelectClientCertificateCard(
snackbarHostState: SnackbarHostState,
suggestedAlias: String?,
chosenAlias: String?,
onAliasChosen: (String) -> Unit = {}
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
if (!chosenAlias.isNullOrEmpty())
stringResource(R.string.login_client_certificate_selected, chosenAlias)
else
stringResource(R.string.login_no_client_certificate_optional),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(8.dp)
)
val activity = LocalContext.current as? Activity
val scope = rememberCoroutineScope()
OutlinedButton(
onClick = {
if (activity != null)
KeyChain.choosePrivateKeyAlias(activity, { alias ->
if (alias != null)
onAliasChosen(alias)
else {
// Show a Snackbar to add a certificate if no certificate was found
// API Versions < 29 does that itself
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
scope.launch {
if (snackbarHostState.showSnackbar(
message = activity.getString(R.string.login_no_certificate_found),
actionLabel = activity.getString(R.string.login_install_certificate),
duration = SnackbarDuration.Long
) == SnackbarResult.ActionPerformed)
activity.startActivity(KeyChain.createInstallIntent())
}
}
}, null, null, null, -1, suggestedAlias)
}
) {
Text(stringResource(R.string.login_select_certificate))
}
}
}
}
@Composable
@Preview
fun SelectClientCertificateCard_Preview_CertSelected() {
SelectClientCertificateCard(
snackbarHostState = SnackbarHostState(),
suggestedAlias = "Test",
chosenAlias = "Test"
)
}
@Composable
@Preview
fun SelectClientCertificateCard_Preview_NothingSelected() {
SelectClientCertificateCard(
snackbarHostState = SnackbarHostState(),
suggestedAlias = null,
chosenAlias = null
)
}

View file

@ -12,25 +12,24 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ExposedDropdownMenuBox
import androidx.compose.material.ExposedDropdownMenuDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.RadioButton
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -40,117 +39,81 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.launch
@Composable
fun AccountDetailsPage(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
foundConfig: DavResourceFinder.Configuration,
onBack: () -> Unit,
onAccountCreated: (Account) -> Unit,
model: LoginModel = viewModel()
model: LoginScreenModel = viewModel()
) {
BackHandler(onBack = onBack)
val uiState = model.accountDetailsUiState
if (uiState.createdAccount != null)
onAccountCreated(uiState.createdAccount)
val context = LocalContext.current
val scope = rememberCoroutineScope()
val resultOrNull by model.createAccountResult.observeAsState()
var showExceptionInfo by remember { mutableStateOf(false) }
LaunchedEffect(resultOrNull) {
showExceptionInfo = resultOrNull != null
LaunchedEffect(uiState.couldNotCreateAccount) {
if (uiState.couldNotCreateAccount)
snackbarHostState.showSnackbar(context.getString(R.string.login_account_not_created))
}
if (showExceptionInfo)
resultOrNull?.let { result ->
when (result) {
is LoginModel.CreateAccountResult.Success -> {
onAccountCreated(result.account)
}
is LoginModel.CreateAccountResult.Error -> {
if (result.exception != null)
ExceptionInfoDialog(
result.exception,
onDismiss = {
model.createAccountResult.value = null
}
)
else
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.login_account_not_created))
}
}
else -> {}
}
// reset result
model.createAccountResult.value = null
}
val suggestedAccountNames = foundConfig.calDAV?.emails ?: emptyList()
var accountName by remember { mutableStateOf(suggestedAccountNames.firstOrNull() ?: "") }
var groupMethod by remember { mutableStateOf(loginInfo.suggestedGroupMethod) }
val forcedGroupMethod by model.forcedGroupMethod.collectAsStateWithLifecycle(null)
forcedGroupMethod?.let { groupMethod = it }
AccountDetailsPage_Content(
suggestedAccountNames = suggestedAccountNames,
accountName = accountName,
accountNameAlreadyExists = model.accountExists(accountName).observeAsState(false).value,
onUpdateAccountName = { accountName = it },
onCreateAccount = {
model.createAccount(
credentials = loginInfo.credentials,
foundConfig = foundConfig,
name = accountName,
groupMethod = groupMethod
)
},
groupMethod = groupMethod,
groupMethodReadOnly = forcedGroupMethod != null,
onUpdateGroupMethod = { groupMethod = it }
AccountDetailsPageContent(
accountName = uiState.accountName,
suggestedAccountNames = uiState.suggestedAccountNames,
accountNameAlreadyExists = uiState.accountNameExists,
onUpdateAccountName = { model.updateAccountName(it) },
showApostropheWarning = uiState.showApostropheWarning,
groupMethod = uiState.groupMethod,
groupMethodReadOnly = uiState.groupMethodReadOnly,
onUpdateGroupMethod = { model.updateGroupMethod(it) },
onCreateAccount = { model.createAccount() },
creatingAccount = uiState.creatingAccount
)
}
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountDetailsPage_Content(
suggestedAccountNames: List<String>,
fun AccountDetailsPageContent(
suggestedAccountNames: Set<String>,
accountName: String,
accountNameAlreadyExists: Boolean,
onUpdateAccountName: (String) -> Unit = {},
showApostropheWarning: Boolean,
groupMethod: GroupMethod,
groupMethodReadOnly: Boolean,
onUpdateGroupMethod: (GroupMethod) -> Unit = {},
onCreateAccount: () -> Unit = {}
onCreateAccount: () -> Unit = {},
creatingAccount: Boolean
) {
Assistant(
nextLabel = stringResource(R.string.login_create_account),
onNext = onCreateAccount,
nextEnabled = accountName.isNotBlank() && !accountNameAlreadyExists
nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists
) {
Column(Modifier.padding(8.dp)) {
if (creatingAccount)
LinearProgressIndicator(Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
val accountNameLabel = if (accountNameAlreadyExists)
stringResource(R.string.login_account_name_already_taken)
else
stringResource(R.string.login_account_name)
OutlinedTextField(
value = accountName,
onValueChange = onUpdateAccountName,
label = { Text(accountNameLabel) },
label = { Text(stringResource(R.string.login_account_name)) },
isError = accountNameAlreadyExists,
supportingText =
if (accountNameAlreadyExists) {
{ Text(stringResource(R.string.login_account_name_already_taken)) }
} else
null,
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email
@ -161,7 +124,9 @@ fun AccountDetailsPage_Content(
expanded = expanded
)
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
if (suggestedAccountNames.isNotEmpty())
@ -184,7 +149,7 @@ fun AccountDetailsPage_Content(
}
// apostrophe warning
if (accountName.contains('\'') || accountName.contains('"'))
if (showApostropheWarning)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp)
@ -196,7 +161,7 @@ fun AccountDetailsPage_Content(
)
Text(
stringResource(R.string.login_account_avoid_apostrophe),
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
}
@ -212,14 +177,14 @@ fun AccountDetailsPage_Content(
)
Text(
stringResource(R.string.login_account_name_info),
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
}
// group type selector
Text(
stringResource(R.string.login_account_contact_group_method),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries)
@ -237,7 +202,7 @@ fun AccountDetailsPage_Content(
modifier = modifier.clickable(onClick = { onUpdateGroupMethod(method) })
Text(
name,
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = modifier
)
}
@ -249,23 +214,27 @@ fun AccountDetailsPage_Content(
@Composable
@Preview
fun AccountDetailsPage_Content_Preview() {
AccountDetailsPage_Content(
suggestedAccountNames = listOf("name1", "name2@example.com"),
AccountDetailsPageContent(
suggestedAccountNames = setOf("name1", "name2@example.com"),
accountName = "account@example.com",
accountNameAlreadyExists = false,
showApostropheWarning = false,
groupMethod = GroupMethod.GROUP_VCARDS,
groupMethodReadOnly = false
groupMethodReadOnly = false,
creatingAccount = true
)
}
@Composable
@Preview
fun AccountDetailsPage_Content_Preview_With_Apostrophe() {
AccountDetailsPage_Content(
suggestedAccountNames = listOf("name1", "name2@example.com"),
AccountDetailsPageContent(
suggestedAccountNames = setOf("name1", "name2@example.com"),
accountName = "account'example.com",
accountNameAlreadyExists = true,
showApostropheWarning = true,
groupMethod = GroupMethod.CATEGORIES,
groupMethodReadOnly = true
groupMethodReadOnly = true,
creatingAccount = false
)
}

View file

@ -0,0 +1,219 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Password
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
object AdvancedLogin : LoginType {
override val title: Int
get() = R.string.login_type_advanced
override val helpUrl: Uri?
get() = null
@Composable
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
val model = viewModel<AdvancedLoginModel>()
LaunchedEffect(Unit) {
model.initialize(initialLoginInfo)
}
val uiState = model.uiState
AdvancedLoginScreen(
snackbarHostState = snackbarHostState,
url = uiState.url,
onSetUrl = model::setUrl,
username = uiState.username,
onSetUsername = model::setUsername,
password = uiState.password,
onSetPassword = model::setPassword,
certAlias = uiState.certAlias,
onSetCertAlias = model::setCertAlias,
canContinue = uiState.canContinue,
onLogin = {
onLogin(uiState.asLoginInfo())
}
)
}
}
@Composable
fun AdvancedLoginScreen(
snackbarHostState: SnackbarHostState,
url: String,
onSetUrl: (String) -> Unit = {},
username: String,
onSetUsername: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
certAlias: String,
onSetCertAlias: (String) -> Unit = {},
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
val focusRequester = remember { FocusRequester() }
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = canContinue,
onNext = onLogin
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_advanced),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
OutlinedTextField(
value = url,
onValueChange = onSetUrl,
label = { Text(stringResource(R.string.login_base_url)) },
placeholder = { Text("dav.example.com/path") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Folder, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
val manualUrl = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_ACCOUNTS_COLLECTIONS)
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
OutlinedTextField(
value = username,
onValueChange = onSetUsername,
label = { Text(stringResource(R.string.login_user_name_optional)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.AccountCircle, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
PasswordTextField(
password = password,
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password_optional),
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
SelectClientCertificateCard(
snackbarHostState = snackbarHostState,
suggestedAlias = null,
chosenAlias = certAlias,
onAliasChosen = onSetCertAlias
)
Text(
stringResource(R.string.optional_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
@Preview
fun AdvancedLoginScreen_Preview_Empty() {
AdvancedLoginScreen(
snackbarHostState = SnackbarHostState(),
url = "",
username = "",
password = "",
certAlias = "",
canContinue = false
)
}
@Composable
@Preview
fun AdvancedLoginScreen_Preview_AllFilled() {
AdvancedLoginScreen(
snackbarHostState = SnackbarHostState(),
url = "dav.example.com",
username = "someuser",
password = "password",
certAlias = "someCert",
canContinue = true
)
}

View file

@ -0,0 +1,74 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.util.DavUtils.toURIorNull
import org.apache.commons.lang3.StringUtils
import java.net.URI
import java.net.URISyntaxException
class AdvancedLoginModel: ViewModel() {
data class UiState(
val url: String = "",
val username: String = "",
val password: String = "",
val certAlias: String = ""
) {
val urlWithPrefix =
if (url.startsWith("http://") || url.startsWith("https://"))
url
else
"https://$url"
val uri = urlWithPrefix.toURIorNull()
val canContinue = uri != null
fun asLoginInfo() = LoginInfo(
baseUri = uri,
credentials = Credentials(
username = StringUtils.trimToNull(username),
password = StringUtils.trimToNull(password),
certificateAlias = StringUtils.trimToNull(certAlias)
)
)
}
var uiState by mutableStateOf(UiState())
private set
fun initialize(loginInfo: LoginInfo) {
uiState = uiState.copy(
url = loginInfo.baseUri?.toString()?.removePrefix("https://") ?: "",
username = loginInfo.credentials?.username ?: "",
password = loginInfo.credentials?.password ?: "",
certAlias = loginInfo.credentials?.certificateAlias ?: ""
)
}
fun setUrl(url: String) {
uiState = uiState.copy(url = url)
}
fun setUsername(username: String) {
uiState = uiState.copy(username = username)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
fun setCertAlias(certAlias: String) {
uiState = uiState.copy(certAlias = certAlias)
}
}

View file

@ -4,89 +4,77 @@
package at.bitfire.davdroid.ui.setup
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.CancellationSignal
import androidx.core.text.HtmlCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
@Composable
fun DetectResourcesPage(
loginInfo: LoginInfo,
onSuccess: (DavResourceFinder.Configuration) -> Unit,
model: LoginModel = viewModel()
model: LoginScreenModel = viewModel()
) {
val cancellationSignal = remember { CancellationSignal() }
DisposableEffect(Unit) {
onDispose {
cancellationSignal.cancel()
}
}
LaunchedEffect(loginInfo) {
model.detectResources(loginInfo, cancellationSignal)
}
val result by model.foundConfig.observeAsState()
val foundSomething = result?.calDAV != null || result?.cardDAV != null
val foundNothing = result?.calDAV == null && result?.cardDAV == null
LaunchedEffect(result) {
if (foundSomething)
result?.let { onSuccess(it) }
}
val uiState = model.detectResourcesUiState
DetectResourcesPageContent(
loading = uiState.loading,
foundNothing = uiState.foundNothing,
encountered401 = uiState.encountered401,
logs = uiState.logs
)
}
@Composable
fun DetectResourcesPageContent(
loading: Boolean,
foundNothing: Boolean,
encountered401: Boolean,
logs: String?
) {
Column(Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
if (result == null)
DetectResourcesPage_InProgress()
if (loading)
DetectResourcesPageContent_InProgress()
else if (foundNothing)
DetectResourcesPage_NothingFound(
encountered401 = result?.encountered401 ?: false,
logs = result?.logs ?: ""
DetectResourcesPageContent_NothingFound(
encountered401 = encountered401,
logs = logs
)
}
}
@Composable
@Preview
fun DetectResourcesPage_InProgress() {
fun DetectResourcesPageContent_InProgress() {
Column(Modifier.fillMaxWidth()) {
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
//color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
@ -94,26 +82,26 @@ fun DetectResourcesPage_InProgress() {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_configuration_detection),
style = MaterialTheme.typography.h5,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
stringResource(R.string.login_querying_server),
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
fun DetectResourcesPage_NothingFound(
fun DetectResourcesPageContent_NothingFound(
encountered401: Boolean,
logs: String
logs: String?
) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_configuration_detection),
style = MaterialTheme.typography.h5,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
@ -123,14 +111,14 @@ fun DetectResourcesPage_NothingFound(
Icon(Icons.Default.CloudOff, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(
stringResource(R.string.login_no_service),
style = MaterialTheme.typography.h6,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
}
Text(
stringResource(R.string.login_no_service_info),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
@ -140,7 +128,7 @@ fun DetectResourcesPage_NothingFound(
.build()
ClickableTextWithLink(
HtmlCompat.fromHtml(stringResource(R.string.login_see_tested_services, urlServices), HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString(),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
@ -148,18 +136,18 @@ fun DetectResourcesPage_NothingFound(
Text(
stringResource(R.string.login_check_credentials),
modifier = Modifier.padding(vertical = 8.dp),
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
if (logs.isNotBlank()) {
if (logs != null && logs.isNotEmpty()) {
Text(
stringResource(R.string.login_logs_available),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 8.dp)
)
val context = LocalContext.current
TextButton(
Button(
onClick = {
val intent = DebugInfoActivity.IntentBuilder(context)
.withLogs(logs)
@ -167,7 +155,7 @@ fun DetectResourcesPage_NothingFound(
context.startActivity(intent)
}
) {
Text(stringResource(R.string.login_view_logs).uppercase())
Text(stringResource(R.string.login_view_logs))
}
}
}
@ -177,8 +165,8 @@ fun DetectResourcesPage_NothingFound(
@Composable
@Preview
fun DetectResourcesPage_NothingFound() {
DetectResourcesPage_NothingFound(
fun DetectResourcesPageContent_NothingFound() {
DetectResourcesPageContent_NothingFound(
encountered401 = false,
logs = "SOME LOGS"
)
@ -187,7 +175,7 @@ fun DetectResourcesPage_NothingFound() {
@Composable
@Preview
fun DetectResourcesPage_NothingFound_401() {
DetectResourcesPage_NothingFound(
DetectResourcesPageContent_NothingFound(
encountered401 = true,
logs = ""
)

View file

@ -10,20 +10,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Password
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -32,18 +29,16 @@ 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.unit.dp
import androidx.core.net.MailTo
import androidx.core.text.HtmlCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import java.net.URI
object LoginTypeEmail : LoginType {
object EmailLogin : LoginType {
override val title: Int
get() = R.string.login_type_email
@ -53,17 +48,24 @@ object LoginTypeEmail : LoginType {
@Composable
override fun Content(
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
LoginTypeEmail_Content(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
val model = viewModel<EmailLoginModel>()
LaunchedEffect(initialLoginInfo) {
model.initialize(initialLoginInfo)
}
val uiState = model.uiState
EmailLoginScreen(
email = uiState.email,
onSetEmail = model::setEmail,
password = uiState.password,
onSetPassword = model::setPassword,
canContinue = uiState.canContinue,
onLogin = { onLogin(uiState.asLoginInfo()) }
)
}
@ -71,32 +73,23 @@ object LoginTypeEmail : LoginType {
@Composable
fun LoginTypeEmail_Content(
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit = {},
fun EmailLoginScreen(
email: String,
onSetEmail: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
var email by remember { mutableStateOf(loginInfo.credentials?.username ?: "") }
var password by remember { mutableStateOf(loginInfo.credentials?.password ?: "") }
onUpdateLoginInfo(LoginInfo(
baseUri = URI(MailTo.MAILTO_SCHEME, email, null),
credentials = Credentials(username = email, password = password)
))
val ok = email.contains('@') && password.isNotEmpty()
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = ok,
onNext = {
if (ok)
onLogin()
}
nextEnabled = canContinue,
onNext = onLogin
) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_email),
style = MaterialTheme.typography.h5,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
@ -105,7 +98,7 @@ fun LoginTypeEmail_Content(
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = email,
onValueChange = { email = it },
onValueChange = onSetEmail,
label = { Text(stringResource(R.string.login_email_address)) },
singleLine = true,
leadingIcon = {
@ -130,7 +123,7 @@ fun LoginTypeEmail_Content(
val emailInfo = HtmlCompat.fromHtml(stringResource(R.string.login_email_address_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
emailInfo.toAnnotatedString(),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
@ -138,7 +131,7 @@ fun LoginTypeEmail_Content(
PasswordTextField(
password = password,
onPasswordChange = { password = it },
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password),
leadingIcon = {
Icon(Icons.Default.Password, null)
@ -147,10 +140,9 @@ fun LoginTypeEmail_Content(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
if (ok)
onLogin()
}),
keyboardActions = KeyboardActions(
onDone = { onLogin() }
),
modifier = Modifier.fillMaxWidth()
)
}
@ -160,6 +152,10 @@ fun LoginTypeEmail_Content(
@Composable
@Preview
fun LoginTypeEmail_Content_Preview() {
LoginTypeEmail_Content(LoginInfo())
fun EmailLoginScreen_Preview() {
EmailLoginScreen(
email = "test@example.com",
password = "",
canContinue = false
)
}

View file

@ -0,0 +1,53 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.util.DavUtils.toURIorNull
class EmailLoginModel: ViewModel() {
data class UiState(
val email: String = "",
val password: String = ""
) {
val uri = "mailto:$email".toURIorNull()
val canContinue = uri != null && password.isNotEmpty()
fun asLoginInfo(): LoginInfo {
return LoginInfo(
baseUri = uri,
credentials = Credentials(
username = email,
password = password
)
)
}
}
var uiState by mutableStateOf(UiState())
private set
fun initialize(initialLoginInfo: LoginInfo) {
uiState = uiState.copy(
email = initialLoginInfo.credentials?.username ?: "",
password = initialLoginInfo.credentials?.password ?: ""
)
}
fun setEmail(email: String) {
uiState = uiState.copy(email = email)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
}

View file

@ -0,0 +1,293 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.ActivityNotFoundException
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
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.Email
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
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.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.setup.GoogleLogin.GOOGLE_POLICY_URL
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import java.util.logging.Level
object GoogleLogin : LoginType {
override val title: Int
get() = R.string.login_type_google
override val helpUrl: Uri
get() = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("google")
.withStatParams("LoginTypeGoogle")
.build()
// Google API Services User Data Policy
const val GOOGLE_POLICY_URL =
"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes"
// Support site
val URI_TESTED_WITH_GOOGLE: Uri =
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("google")
.build()
@Composable
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
val model = viewModel<GoogleLoginModel>()
LaunchedEffect(initialLoginInfo) {
model.initialize(initialLoginInfo)
}
val uiState = model.uiState
LaunchedEffect(uiState.result) {
if (uiState.result != null) {
onLogin(uiState.result)
model.resetResult()
}
}
LaunchedEffect(uiState.error) {
if (uiState.error != null)
snackbarHostState.showSnackbar(uiState.error)
}
// contract to open the browser for authentication
val authRequestContract = rememberLauncherForActivityResult(contract = model.AuthorizationContract()) { authResponse ->
if (authResponse != null)
model.authenticate(authResponse)
else
model.authCodeFailed()
}
GoogleLoginScreen(
email = uiState.email,
onSetEmail = model::setEmail,
customClientId = uiState.customClientId,
onSetCustomClientId = model::setCustomClientId,
canContinue = uiState.canContinue,
onLogin = {
if (uiState.canContinue) {
val authRequest = model.signIn()
try {
authRequestContract.launch(authRequest)
} catch (e: ActivityNotFoundException) {
Logger.log.log(Level.WARNING, "Couldn't start OAuth intent", e)
model.signInFailed()
}
}
}
)
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun GoogleLoginScreen(
email: String,
onSetEmail: (String) -> Unit = {},
customClientId: String,
onSetCustomClientId: (String) -> Unit = {},
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Row {
Text(
stringResource(R.string.login_google_see_tested_with),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
stringResource(R.string.login_google_unexpected_warnings),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = {
uriHandler.openUri(GoogleLogin.URI_TESTED_WITH_GOOGLE.toString())
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
email,
singleLine = true,
onValueChange = onSetEmail,
leadingIcon = {
Icon(Icons.Default.Email, null)
},
label = { Text(stringResource(R.string.login_google_account)) },
placeholder = { Text("example@gmail.com") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
if (email.isEmpty())
focusRequester.requestFocus()
}
OutlinedTextField(
customClientId,
singleLine = true,
onValueChange = onSetCustomClientId,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onLogin() }
),
label = { Text(stringResource(R.string.login_google_client_id)) },
placeholder = { Text("[...].apps.googleusercontent.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
Button(
enabled = canContinue,
onClick = { onLogin() },
modifier = Modifier
.padding(top = 8.dp)
.wrapContentSize()
) {
Image(
painter = painterResource(R.drawable.google_g_logo),
contentDescription = stringResource(R.string.login_google),
modifier = Modifier.size(18.dp)
)
Text(
text = stringResource(R.string.login_google),
modifier = Modifier.padding(start = 12.dp)
)
}
Spacer(Modifier.padding(8.dp))
val privacyPolicyUrl = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_PRIVACY)
.withStatParams("GoogleLoginFragment")
.build()
val privacyPolicyNote = HtmlCompat.fromHtml(
stringResource(
R.string.login_google_client_privacy_policy,
context.getString(R.string.app_name),
privacyPolicyUrl.toString()
), 0
).toAnnotatedString()
ClickableTextWithLink(
privacyPolicyNote,
style = MaterialTheme.typography.bodyMedium
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
).toAnnotatedString()
ClickableTextWithLink(
limitedUseNote,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
@Preview(showBackground = true)
fun GoogleLoginScreen_Preview_Empty() {
GoogleLoginScreen(
email = "",
customClientId = "",
canContinue = false
)
}
@Composable
@Preview(showBackground = true)
fun GoogleLoginScreen_Preview_WithDefaultEmail() {
GoogleLoginScreen(
email = "example@gmail.com",
customClientId = "some-client-id",
canContinue = true
)
}

View file

@ -0,0 +1,130 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.accounts.AccountManager
import android.app.Application
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.GoogleLogin
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import org.apache.commons.lang3.StringUtils
import java.util.Locale
import java.util.logging.Level
import javax.inject.Inject
@HiltViewModel
class GoogleLoginModel @Inject constructor(
val context: Application,
val authService: AuthorizationService
): ViewModel() {
val googleLogin = GoogleLogin(authService)
override fun onCleared() {
authService.dispose()
}
data class UiState(
val email: String = "",
val customClientId: String = "",
val error: String? = null,
/** login info (set after successful login) */
val result: LoginInfo? = null
) {
val canContinue = email.isNotEmpty()
val emailWithDomain = StringUtils.appendIfMissing(email, "@gmail.com")
}
var uiState by mutableStateOf(UiState())
private set
fun initialize(loginInfo: LoginInfo) {
uiState = uiState.copy(
email = loginInfo.credentials?.username ?: findGoogleAccount() ?: "",
error = null,
result = null
)
}
fun setEmail(email: String) {
uiState = uiState.copy(email = email)
}
fun setCustomClientId(clientId: String) {
uiState = uiState.copy(customClientId = clientId)
}
fun signIn() =
googleLogin.signIn(
email = uiState.emailWithDomain,
customClientId = StringUtils.trimToNull(uiState.customClientId),
locale = Locale.getDefault().toLanguageTag()
)
fun signInFailed() {
uiState = uiState.copy(error = context.getString(R.string.install_browser))
}
fun authenticate(authResponse: AuthorizationResponse) {
viewModelScope.launch {
try {
val credentials = googleLogin.authenticate(authResponse)
// success, provide login info to continue
uiState = uiState.copy(
result = LoginInfo(
baseUri = GoogleLogin.googleBaseUri(uiState.emailWithDomain),
credentials = credentials,
suggestedAccountName = uiState.emailWithDomain
)
)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Google authentication failed", e)
uiState = uiState.copy(error = e.message)
}
}
}
fun authCodeFailed() {
uiState = uiState.copy(error = context.getString(R.string.login_oauth_couldnt_obtain_auth_code))
}
fun resetResult() {
uiState = uiState.copy(result = null)
}
private fun findGoogleAccount(): String? {
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType("com.google")
.map { it.name }
.firstOrNull()
}
inner class AuthorizationContract() : ActivityResultContract<AuthorizationRequest, AuthorizationResponse?>() {
override fun createIntent(context: Context, input: AuthorizationRequest) =
authService.getAuthorizationRequestIntent(input)
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? =
intent?.let { AuthorizationResponse.fromIntent(it) }
}
}

View file

@ -8,9 +8,10 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.account.AccountActivity
import dagger.hilt.android.AndroidEntryPoint
import java.net.URI
import javax.inject.Inject
@ -102,30 +103,28 @@ class LoginActivity @Inject constructor(): AppCompatActivity() {
}
enum class Phase {
LOGIN_TYPE,
LOGIN_DETAILS,
DETECT_RESOURCES,
ACCOUNT_DETAILS
}
@Inject
lateinit var loginTypesProvider: LoginTypesProvider
val model: LoginScreenModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// start with login info from Intent
model.updateLoginInfo(loginInfoFromIntent(intent))
setContent {
M2Theme {
LoginScreen(
loginTypesProvider = loginTypesProvider,
initialLoginInfo = loginInfoFromIntent(intent),
initialLoginType = loginTypesProvider.intentToInitialLoginType(intent),
onFinish = { finish() }
)
}
LoginScreen(
onNavUp = { onNavigateUp() },
onFinish = { newAccount ->
finish()
if (newAccount != null) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, newAccount)
startActivity(intent)
}
},
model = model
)
}
}

View file

@ -0,0 +1,26 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun LoginDetailsPage(
snackbarHostState: SnackbarHostState,
model: LoginScreenModel = viewModel()
) {
val uiState = model.loginDetailsUiState
uiState.loginType.LoginScreen(
snackbarHostState = snackbarHostState,
initialLoginInfo = uiState.loginInfo,
onLogin = { loginInfo ->
model.updateLoginInfo(loginInfo)
model.navToNextPage()
}
)
}

View file

@ -1,163 +1,122 @@
/*
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import android.net.Uri
import android.accounts.Account
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.AppTheme
@Composable
fun LoginScreen(
loginTypesProvider: LoginTypesProvider,
initialLoginInfo: LoginInfo = LoginInfo(),
initialLoginType: LoginType,
onFinish: () -> Unit = {}
onNavUp: () -> Unit,
onFinish: (Account?) -> Unit,
model: LoginScreenModel = viewModel()
) {
val uriHandler = LocalUriHandler.current
// handle back/up navigation
BackHandler {
model.navBack()
}
if (model.finish) {
onFinish(null)
return
}
val initialPhase =
if (initialLoginInfo.baseUri != null)
LoginActivity.Phase.LOGIN_DETAILS
else
LoginActivity.Phase.LOGIN_TYPE
var phase: LoginActivity.Phase by remember { mutableStateOf(initialPhase) }
var selectedLoginType: LoginType by remember { mutableStateOf(initialLoginType) }
LoginScreenContent(
page = model.page,
onNavUp = onNavUp,
onFinish = onFinish
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun LoginScreenContent(
page: LoginScreenModel.Page,
onNavUp: () -> Unit = {},
onFinish: (newAccount: Account?) -> Unit = {}
) {
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onFinish) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
stringResource(R.string.navigate_up)
)
}
},
title = {
Text(stringResource(R.string.login_title))
},
actions = {
val testedWithUrl = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.withStatParams("LoginActivity")
.build()
val helpUri: Uri? =
when (phase) {
LoginActivity.Phase.LOGIN_TYPE -> testedWithUrl
LoginActivity.Phase.LOGIN_DETAILS -> selectedLoginType.helpUrl ?: testedWithUrl
else -> null
AppTheme {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
stringResource(R.string.navigate_up)
)
}
if (helpUri != null)
},
title = {
Text(stringResource(R.string.login_title))
},
actions = {
val uriHandler = LocalUriHandler.current
val testedWithUri = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.withStatParams("LoginActivity")
.build()
IconButton(onClick = {
// show tested-with page
uriHandler.openUri(helpUri.toString())
uriHandler.openUri(testedWithUri.toString())
}) {
Icon(Icons.AutoMirrored.Default.Help, stringResource(R.string.help))
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding)
) {
var loginInfo by remember { mutableStateOf(initialLoginInfo) }
var foundConfig by remember { mutableStateOf<DavResourceFinder.Configuration?>(null) }
when (phase) {
LoginActivity.Phase.LOGIN_TYPE ->
loginTypesProvider.LoginTypePage(
selectedLoginType = selectedLoginType,
onSelectLoginType = { selectedLoginType = it },
loginInfo = loginInfo,
onUpdateLoginInfo = { loginInfo = it },
onContinue = {
phase = LoginActivity.Phase.LOGIN_DETAILS
},
onFinish = onFinish
)
LoginActivity.Phase.LOGIN_DETAILS -> {
BackHandler {
phase = LoginActivity.Phase.LOGIN_TYPE
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding)
) {
selectedLoginType.Content(
snackbarHostState = snackbarHostState,
loginInfo = loginInfo,
onUpdateLoginInfo = { loginInfo = it },
onDetectResources = {
phase = LoginActivity.Phase.DETECT_RESOURCES
},
onFinish = onFinish
)
}
when (page) {
LoginScreenModel.Page.LoginType ->
LoginTypePage()
LoginActivity.Phase.DETECT_RESOURCES -> {
BackHandler(
onBack = { phase = LoginActivity.Phase.LOGIN_TYPE }
)
LoginScreenModel.Page.LoginDetails ->
LoginDetailsPage(snackbarHostState = snackbarHostState)
DetectResourcesPage(
loginInfo = loginInfo,
onSuccess = {
foundConfig = it
phase = LoginActivity.Phase.ACCOUNT_DETAILS
}
)
}
LoginScreenModel.Page.DetectResources ->
DetectResourcesPage()
LoginActivity.Phase.ACCOUNT_DETAILS ->
foundConfig?.let {
val context = LocalContext.current
LoginScreenModel.Page.AccountDetails ->
AccountDetailsPage(
snackbarHostState = snackbarHostState,
loginInfo = loginInfo,
foundConfig = it,
onBack = { phase = LoginActivity.Phase.LOGIN_TYPE },
onAccountCreated = { account ->
onFinish()
val intent = Intent(context, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
context.startActivity(intent)
onFinish(account)
}
)
}
}
}
}

View file

@ -0,0 +1,279 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.accounts.Account
import android.app.Application
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.AccountRepository
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class LoginScreenModel @Inject constructor(
val context: Application,
val loginTypesProvider: LoginTypesProvider,
private val accountRepository: AccountRepository,
private val settingsManager: SettingsManager
): ViewModel() {
enum class Page {
LoginType,
LoginDetails,
DetectResources,
AccountDetails
}
var page by mutableStateOf(Page.LoginType)
private set
var finish by mutableStateOf(false)
private set
// navigation events
fun navToNextPage() {
when (page) {
Page.LoginType -> {
// continue to login details
loginDetailsUiState = loginDetailsUiState.copy(
loginType = loginTypeUiState.loginType
)
page = Page.LoginDetails
}
Page.LoginDetails -> {
// continue to resource detection
loginInfo = loginDetailsUiState.loginInfo
page = Page.DetectResources
detectResources()
}
Page.DetectResources -> {
// continue to account details
val emails = foundConfig?.calDAV?.emails.orEmpty().toSet()
val initialAccountName = emails.firstOrNull()
?: loginInfo.suggestedAccountName
?: loginInfo.credentials?.username
?: loginInfo.baseUri?.host
?: ""
_accountDetailsUiState = _accountDetailsUiState.copy(
accountName = initialAccountName,
suggestedAccountNames = emails,
)
page = Page.AccountDetails
}
Page.AccountDetails -> {
// last page
}
}
}
fun navBack() {
when (page) {
Page.LoginType ->
finish = true
Page.LoginDetails ->
if (loginTypesProvider.maybeNonInteractive)
finish = true
else
page = Page.LoginType
Page.DetectResources -> {
cancelResourceDetection()
page = Page.LoginDetails
}
Page.AccountDetails ->
page = Page.LoginDetails
}
}
// UI element state first page: login type
data class LoginTypeUiState(
val loginType: LoginType
)
var loginTypeUiState by mutableStateOf(LoginTypeUiState(loginType = loginTypesProvider.defaultLoginType))
private set
fun selectLoginType(loginType: LoginType) {
loginTypeUiState = loginTypeUiState.copy(loginType = loginType)
}
// UI element state second page: login details
// base URI and credentials
private var loginInfo: LoginInfo = LoginInfo()
data class LoginDetailsUiState(
val loginType: LoginType,
val loginInfo: LoginInfo
)
var loginDetailsUiState by mutableStateOf(LoginDetailsUiState(
loginType = loginTypesProvider.defaultLoginType,
loginInfo = loginInfo
))
private set
fun updateLoginInfo(loginInfo: LoginInfo) {
loginDetailsUiState = loginDetailsUiState.copy(loginInfo = loginInfo)
}
// UI element state third page: detect resources
data class DetectResourcesUiState(
val loading: Boolean = false,
val foundNothing: Boolean = false,
val encountered401: Boolean = false,
val logs: String? = null
)
var detectResourcesUiState by mutableStateOf(DetectResourcesUiState())
private set
private var foundConfig: DavResourceFinder.Configuration? = null
private var detectResourcesJob: Job? = null
private fun detectResources() {
detectResourcesUiState = detectResourcesUiState.copy(loading = true)
detectResourcesJob = viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
runInterruptible {
DavResourceFinder(context, loginInfo.baseUri!!, loginInfo.credentials).use { finder ->
finder.findInitialConfiguration()
}
}
}
if (result.calDAV != null || result.cardDAV != null) {
foundConfig = result
navToNextPage()
} else {
foundConfig = null
detectResourcesUiState = detectResourcesUiState.copy(
loading = false,
foundNothing = true,
encountered401 = result.encountered401,
logs = result.logs
)
}
}
}
private fun cancelResourceDetection() {
detectResourcesJob?.cancel()
}
// UI element state last page: account details
data class AccountDetailsUiState(
val accountName: String = "",
val suggestedAccountNames: Set<String> = emptySet(),
val accountNameExists: Boolean = false,
val groupMethod: GroupMethod = GroupMethod.GROUP_VCARDS,
val groupMethodReadOnly: Boolean = false,
val creatingAccount: Boolean = false,
val createdAccount: Account? = null,
val couldNotCreateAccount: Boolean = false
) {
val showApostropheWarning = accountName.contains('\'') || accountName.contains('"')
}
private val forcedGroupMethod = settingsManager
.getStringFlow(AccountSettings.KEY_CONTACT_GROUP_METHOD)
.map { groupMethodName ->
// map group method name to GroupMethod
if (groupMethodName != null)
try {
GroupMethod.valueOf(groupMethodName)
} catch (e: IllegalArgumentException) {
Logger.log.warning("Invalid forced group method: $groupMethodName")
null
}
else
null
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
// backing field that is combined with dynamic content for the resulting UI State
private var _accountDetailsUiState by mutableStateOf(AccountDetailsUiState())
val accountDetailsUiState by derivedStateOf {
val method = forcedGroupMethod.value
// set group type to read-only if group method is forced
var combinedState = _accountDetailsUiState.copy(groupMethodReadOnly = method != null)
// apply forced group method, if applicable
if (method != null)
combinedState = combinedState.copy(groupMethod = method)
combinedState
}
fun updateAccountName(accountName: String) {
_accountDetailsUiState = _accountDetailsUiState.copy(
accountName = accountName,
accountNameExists = accountRepository.exists(accountName)
)
}
fun updateGroupMethod(groupMethod: GroupMethod) {
_accountDetailsUiState = _accountDetailsUiState.copy(groupMethod = groupMethod)
}
fun createAccount() {
_accountDetailsUiState = _accountDetailsUiState.copy(creatingAccount = true)
viewModelScope.launch {
val account = withContext(Dispatchers.Default) {
accountRepository.create(
accountDetailsUiState.accountName,
loginInfo.credentials,
foundConfig!!,
accountDetailsUiState.groupMethod
)
}
_accountDetailsUiState =
if (account != null)
accountDetailsUiState.copy(createdAccount = account)
else
accountDetailsUiState.copy(
creatingAccount = false,
couldNotCreateAccount = true
)
}
}
}

View file

@ -5,7 +5,7 @@
package at.bitfire.davdroid.ui.setup
import android.net.Uri
import androidx.compose.material.SnackbarHostState
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
interface LoginType {
@ -16,12 +16,10 @@ interface LoginType {
val helpUrl: Uri?
@Composable
fun Content(
fun LoginScreen(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
)
}

View file

@ -1,273 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.app.Activity
import android.net.Uri
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Password
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.core.text.HtmlCompat
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import kotlinx.coroutines.launch
import org.apache.commons.lang3.StringUtils
import java.net.URI
object LoginTypeAdvanced : LoginType {
override val title: Int
get() = R.string.login_type_advanced
override val helpUrl: Uri?
get() = null
@Composable
override fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
) {
LoginTypeAdvanced_Content(
snackbarHostState = snackbarHostState,
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
)
}
}
@Composable
fun LoginTypeAdvanced_Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit = {},
onLogin: () -> Unit = {}
) {
var baseUrl by remember { mutableStateOf(
loginInfo.baseUri?.takeIf {
it.scheme.equals("http", ignoreCase = true) ||
it.scheme.equals("https", ignoreCase = true)
}?.toString() ?: ""
) }
var username by remember { mutableStateOf(loginInfo.credentials?.username ?: "") }
var password by remember { mutableStateOf(loginInfo.credentials?.password ?: "") }
var certificateAlias by remember { mutableStateOf(loginInfo.credentials?.certificateAlias) }
val newLoginInfo = LoginInfo(
baseUri = try {
URI(
if (baseUrl.startsWith("http://", ignoreCase = true) || baseUrl.startsWith("https://", ignoreCase = true))
baseUrl
else
"https://$baseUrl"
)
} catch (_: Exception) {
null
},
credentials = Credentials(
username = StringUtils.trimToNull(username),
password = StringUtils.trimToNull(password)
)
)
onUpdateLoginInfo(newLoginInfo)
val ok =
newLoginInfo.baseUri != null && (
newLoginInfo.baseUri.scheme.equals("http", ignoreCase = true) ||
newLoginInfo.baseUri.scheme.equals("https",ignoreCase = true)
)
val focusRequester = remember { FocusRequester() }
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = ok,
onNext = onLogin
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_advanced),
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text(stringResource(R.string.login_base_url)) },
placeholder = { Text("dav.example.com/path") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Folder, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
val manualUrl = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_ACCOUNTS_COLLECTIONS)
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.login_user_name_optional)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.AccountCircle, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
PasswordTextField(
password = password,
onPasswordChange = { password = it },
labelText = stringResource(R.string.login_password_optional),
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
certificateAlias?.let { alias ->
stringResource(R.string.login_client_certificate_selected, alias)
} ?: stringResource(R.string.login_no_client_certificate_optional),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(8.dp)
)
val activity = LocalContext.current as Activity
val scope = rememberCoroutineScope()
TextButton(
onClick = {
KeyChain.choosePrivateKeyAlias(activity, { alias ->
if (alias != null)
certificateAlias = alias
else {
// Show a Snackbar to add a certificate if no certificate was found
// API Versions < 29 does that itself
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q)
scope.launch {
if (snackbarHostState.showSnackbar(
message = activity.getString(R.string.login_no_certificate_found),
actionLabel = activity.getString(R.string.login_install_certificate).uppercase()
) == SnackbarResult.ActionPerformed)
activity.startActivity(KeyChain.createInstallIntent())
}
}
}, null, null, null, -1, certificateAlias)
}
) {
Text(stringResource(R.string.login_select_certificate).uppercase())
}
}
}
Text(
stringResource(R.string.optional_label),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 16.dp)
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
@Preview
fun LoginTypeAdvancedPreview_Empty() {
LoginTypeAdvanced_Content(
snackbarHostState = SnackbarHostState(),
loginInfo = LoginInfo()
)
}
@Composable
@Preview
fun LoginTypeAdvancedPreview_AllFilled() {
LoginTypeAdvanced_Content(
snackbarHostState = SnackbarHostState(),
loginInfo = LoginInfo(
baseUri = URI("https://some-dav.example.com"),
credentials = Credentials(
username = "some-user",
password = "password",
certificateAlias = "some-alias"
)
)
)
}

View file

@ -1,418 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.accounts.AccountManager
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.setup.LoginTypeGoogle.GOOGLE_POLICY_URL
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import org.apache.commons.lang3.StringUtils
import java.net.URI
import java.util.logging.Level
import javax.inject.Inject
object LoginTypeGoogle : LoginType {
override val title: Int
get() = R.string.login_type_google
override val helpUrl: Uri
get() = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("google")
.withStatParams("LoginTypeGoogle")
.build()
// Google API Services User Data Policy
const val GOOGLE_POLICY_URL =
"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes"
// Support site
val URI_TESTED_WITH_GOOGLE: Uri =
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("google")
.build()
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token")
)
fun authRequestBuilder(clientId: String?) =
AuthorizationRequest.Builder(
serviceConfig,
clientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth2/redirect")
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun googleBaseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
@Composable
override fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val model: Model = viewModel()
val authRequestContract = rememberLauncherForActivityResult(contract = AuthorizationContract(model)) { authResponse ->
if (authResponse != null)
model.authenticate(authResponse)
else
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.login_oauth_couldnt_obtain_auth_code))
}
}
model.credentials.observeAsState().value?.let { credentials ->
onUpdateLoginInfo(loginInfo.copy(credentials = credentials))
onDetectResources()
}
GoogleLoginScreen(
defaultEmail = loginInfo.credentials?.username ?: model.findGoogleAccount(),
onLogin = { accountEmail, clientId ->
onUpdateLoginInfo(
LoginInfo(
baseUri = googleBaseUri(accountEmail),
suggestedAccountName = accountEmail
)
)
val authRequest = authRequestBuilder(clientId)
.setScopes(*SCOPES)
.setLoginHint(accountEmail)
.setUiLocales(Locale.current.toLanguageTag())
.build()
try {
authRequestContract.launch(authRequest)
} catch (e: ActivityNotFoundException) {
Logger.log.log(Level.WARNING, "Couldn't start OAuth intent", e)
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
}
}
}
)
}
class AuthorizationContract(val model: Model) : ActivityResultContract<AuthorizationRequest, AuthorizationResponse?>() {
override fun createIntent(context: Context, input: AuthorizationRequest) =
model.authService.getAuthorizationRequestIntent(input)
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? =
intent?.let { AuthorizationResponse.fromIntent(it) }
}
@HiltViewModel
class Model @Inject constructor(
val context: Application,
val authService: AuthorizationService
) : ViewModel() {
val credentials = MutableLiveData<Credentials>()
fun authenticate(resp: AuthorizationResponse) = viewModelScope.launch(Dispatchers.IO) {
val authState = AuthState(resp, null) // authorization code must not be stored; exchange it to refresh token
authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success
authState.update(tokenResponse, refreshTokenException)
// save authState (= refresh token)
credentials.postValue(Credentials(authState = authState))
}
}
}
fun findGoogleAccount(): String? {
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType("com.google")
.map { it.name }
.firstOrNull()
}
override fun onCleared() {
authService.dispose()
}
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun GoogleLoginScreen(
defaultEmail: String?,
onLogin: (accountEmail: String, clientId: String?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 8.dp)
)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Row {
Text(
stringResource(R.string.login_google_see_tested_with),
style = MaterialTheme.typography.body2,
)
}
Text(
stringResource(R.string.login_google_unexpected_warnings),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = {
uriHandler.openUri(LoginTypeGoogle.URI_TESTED_WITH_GOOGLE.toString())
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
var email by rememberSaveable { mutableStateOf(defaultEmail ?: "") }
var userClientId by rememberSaveable { mutableStateOf("") }
var emailError: String? by rememberSaveable { mutableStateOf(null) }
fun login() {
val userEmail: String? = StringUtils.trimToNull(email.trim())
val clientId: String? = StringUtils.trimToNull(userClientId.trim())
if (userEmail.isNullOrBlank()) {
emailError = context.getString(R.string.login_email_address_error)
return
}
// append @gmail.com, if necessary
val loginEmail =
if (userEmail.contains('@'))
userEmail
else
"$userEmail@gmail.com"
onLogin(loginEmail, clientId)
}
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
email,
singleLine = true,
onValueChange = { emailError = null; email = it },
leadingIcon = {
Icon(Icons.Default.Email, null)
},
isError = emailError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
label = { Text(emailError ?: stringResource(R.string.login_google_account)) },
placeholder = { Text("example@gmail.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
if (email.isEmpty())
focusRequester.requestFocus()
}
OutlinedTextField(
userClientId,
singleLine = true,
onValueChange = { clientId ->
userClientId = clientId
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { login() }
),
label = { Text(stringResource(R.string.login_google_client_id)) },
placeholder = { Text("[...].apps.googleusercontent.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
Button(
onClick = { login() },
modifier = Modifier
.padding(top = 8.dp)
.wrapContentSize(),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.surface
)
) {
Image(
painter = painterResource(R.drawable.google_g_logo),
contentDescription = stringResource(R.string.login_google),
modifier = Modifier.size(18.dp)
)
Text(
text = stringResource(R.string.login_google),
modifier = Modifier.padding(start = 12.dp)
)
}
Spacer(Modifier.padding(8.dp))
val privacyPolicyUrl = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_PRIVACY)
.withStatParams("GoogleLoginFragment")
.build()
val privacyPolicyNote = HtmlCompat.fromHtml(
stringResource(
R.string.login_google_client_privacy_policy,
context.getString(R.string.app_name),
privacyPolicyUrl.toString()
), 0
).toAnnotatedString()
ClickableTextWithLink(
privacyPolicyNote,
style = MaterialTheme.typography.body2
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
).toAnnotatedString()
ClickableTextWithLink(
limitedUseNote,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_withDefaultEmail() {
GoogleLoginScreen("example@example.example") { _, _ -> }
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_empty() {
GoogleLoginScreen("") { _, _ -> }
}

View file

@ -1,473 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.provider.Browser
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
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.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.setup.LoginTypeNextcloud.DAV_PATH
import at.bitfire.davdroid.ui.setup.LoginTypeNextcloud.LOGIN_FLOW_V1_PATH
import at.bitfire.davdroid.ui.setup.LoginTypeNextcloud.LOGIN_FLOW_V2_PATH
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.util.logging.Level
import javax.inject.Inject
object LoginTypeNextcloud : LoginType {
override val title: Int
get() = R.string.login_type_nextcloud
override val helpUrl: Uri
get() = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("nextcloud")
.withStatParams("LoginTypeNextcloud")
.build()
const val LOGIN_FLOW_V1_PATH = "index.php/login/flow"
const val LOGIN_FLOW_V2_PATH = "index.php/login/v2"
/** Path to DAV endpoint (e.g. `/remote.php/dav`). Will be appended to the
* server URL returned by Login Flow without further processing. */
const val DAV_PATH = "/remote.php/dav"
@Composable
override fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo, // initial login info, may contain entry URL
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
) {
val context = LocalContext.current
val locale = Locale.current
val scope = rememberCoroutineScope()
val model = viewModel<Model>()
val onLaunchLoginFlow: (HttpUrl) -> Unit = { entryUrl ->
model.startLoginFlow(entryUrl)
}
val checkResultCallback = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
model.onReturnFromBrowser()
}
LaunchedEffect(model.loginUrl) {
model.loginUrl?.toUri()?.let { loginUri ->
if (haveCustomTabs(context)) {
// Custom Tabs are available
@Suppress("DEPRECATION")
val browser = CustomTabsIntent.Builder()
.setToolbarColor(context.resources.getColor(R.color.primaryColor))
.build()
browser.intent.data = loginUri
browser.intent.putExtra(
Browser.EXTRA_HEADERS,
bundleOf("Accept-Language" to locale.toLanguageTag())
)
checkResultCallback.launch(browser.intent)
} else {
// fallback: launch normal browser
val browser = Intent(Intent.ACTION_VIEW, loginUri)
browser.addCategory(Intent.CATEGORY_BROWSABLE)
if (browser.resolveActivity(context.packageManager) != null) {
checkResultCallback.launch(browser)
} else
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
}
}
}
}
// continue to detecting resources when resultLoginInfo is set in model
val resultLoginInfo = model.loginInfo
LaunchedEffect(resultLoginInfo) {
if (resultLoginInfo != null) {
onUpdateLoginInfo(resultLoginInfo)
onDetectResources()
model.resourceDetectionStarted()
}
}
NextcloudLoginScreen(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
inProgress = model.inProgress,
error = model.error,
onLaunchLoginFlow = onLaunchLoginFlow
)
}
/**
* Implements Login Flow v2.
*
* @see https://docs.nextcloud.com/server/20/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
@HiltViewModel
class Model @Inject constructor(
val context: Application,
val state: SavedStateHandle
): ViewModel() {
companion object {
const val STATE_POLL_URL = "poll_url"
const val STATE_TOKEN = "token"
}
private val httpClient = HttpClient.Builder(context)
.setForeground(true)
.build()
/** When set, UI will start Login Flow will be started with this URI. */
var loginUrl by mutableStateOf<String?>(null)
// model state
var inProgress by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
// Login flow state
private var pollUrl: HttpUrl?
get() = state.get<String>(STATE_POLL_URL)?.toHttpUrlOrNull()
set(value) {
state[STATE_POLL_URL] = value.toString()
}
private var token: String?
get() = state.get<String>(STATE_TOKEN)
set(value) {
state[STATE_TOKEN] = value
}
/** When set, UI will continue to detecting resources with the given login info */
var loginInfo by mutableStateOf<LoginInfo?>(null)
override fun onCleared() {
httpClient.close()
}
/**
* Starts the Login Flow.
*
* @param entryUrl entryURL: either a Login Flow path (ending with [LOGIN_FLOW_V1_PATH] or [LOGIN_FLOW_V2_PATH]),
* or another URL which is treated as Nextcloud root URL. In this case, [LOGIN_FLOW_V2_PATH] is appended.
*/
@UiThread
fun startLoginFlow(entryUrl: HttpUrl) = viewModelScope.launch {
inProgress = true
error = null
// reset login state
loginUrl = null
pollUrl = null
token = null
var entryUrlStr = entryUrl.toString()
if (entryUrlStr.endsWith(LOGIN_FLOW_V1_PATH))
// got Login Flow v1 URL, rewrite to v2
entryUrlStr = entryUrlStr.removeSuffix(LOGIN_FLOW_V1_PATH)
val v2Url = entryUrlStr.toHttpUrl().newBuilder()
.addPathSegments(LOGIN_FLOW_V2_PATH)
.build()
// send POST request and process JSON reply
try {
val json = postForJson(v2Url, "".toRequestBody())
// login URL
loginUrl = json.getString("login")
// poll URL and token
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't obtain login URL", e)
error = context.getString(R.string.login_nextcloud_login_flow_no_login_url)
} finally {
inProgress = false
}
}
/**
* Called when the custom tab / browser activity is finished. If memory is low, our
* [LoginTypeNextcloud] and its model have been cleared in the meanwhile. So if
* we need certain data from the model, we have to make sure that these data are retained when the
* model is cleared (saved state).
*/
@UiThread
fun onReturnFromBrowser() = viewModelScope.launch {
// Login Flow has been started in browser by UI, should not be started again
loginUrl = null
val pollUrl = pollUrl ?: return@launch
val token = token ?: return@launch
try {
inProgress = true
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
val serverUrl = json.getString("server")
val loginName = json.getString("loginName")
val appPassword = json.getString("appPassword")
val baseUri = URI.create(serverUrl + DAV_PATH)
loginInfo = LoginInfo(
baseUri = baseUri,
credentials = Credentials(loginName, appPassword),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Polling login URL failed", e)
error = context.getString(R.string.login_nextcloud_login_flow_no_login_data)
} finally {
inProgress = false
}
}
fun resourceDetectionStarted() {
// resource detection has been started, should not be started again
loginInfo = null
}
@WorkerThread
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.okHttpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body?.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}
}
@Composable
fun NextcloudLoginScreen(
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
inProgress: Boolean,
error: String? = null,
onLaunchLoginFlow: (entryUrl: HttpUrl) -> Unit
) {
val initialEntryUrl = loginInfo.baseUri?.toString()?.removeSuffix(DAV_PATH)
var entryUrl by remember { mutableStateOf(initialEntryUrl ?: "") }
val newLoginInfo = LoginInfo(
baseUri = try {
URI(
if (entryUrl.startsWith("http://", ignoreCase = true) ||
entryUrl.startsWith("https://", ignoreCase = true))
entryUrl
else
"https://$entryUrl"
)
} catch (_: Exception) {
null
}
)
onUpdateLoginInfo(newLoginInfo)
val onLogin = {
if (newLoginInfo.baseUri != null && !inProgress)
onLaunchLoginFlow(newLoginInfo.baseUri.toHttpUrlOrNull()!!)
}
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = newLoginInfo.baseUri != null,
onNext = onLogin
) {
if (inProgress)
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_nextcloud_login_with_nextcloud),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 8.dp)
)
Column {
Text(
stringResource(R.string.login_nextcloud_login_flow_text),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 8.dp)
)
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = entryUrl,
onValueChange = { entryUrl = it },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.focusRequester(focusRequester),
enabled = !inProgress,
leadingIcon = {
Icon(Icons.Default.Cloud, null)
},
label = {
Text(stringResource(R.string.login_nextcloud_login_flow_server_address))
},
placeholder = { Text("cloud.example.com") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onLogin() }
),
singleLine = true
)
LaunchedEffect(Unit) {
if (loginInfo.baseUri == null)
focusRequester.requestFocus()
}
if (error != null)
Card(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp)
)
Text(
error,
style = MaterialTheme.typography.body1
)
}
}
}
}
}
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview() {
NextcloudLoginScreen(
loginInfo = LoginInfo(),
onUpdateLoginInfo = {},
inProgress = false,
onLaunchLoginFlow = {}
)
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview_InProgressError() {
NextcloudLoginScreen(
loginInfo = LoginInfo(),
onUpdateLoginInfo = {},
inProgress = true,
error = "Some Error",
onLaunchLoginFlow = {}
)
}

View file

@ -0,0 +1,29 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun LoginTypePage(
model: LoginScreenModel = viewModel()
) {
val uiState = model.loginTypeUiState
// show login type selection page
model.loginTypesProvider.LoginTypePage(
selectedLoginType = uiState.loginType,
onSelectLoginType = { loginType ->
model.selectLoginType(loginType)
},
setInitialLoginInfo = { loginInfo ->
model.updateLoginInfo(loginInfo)
},
onContinue = {
model.navToNextPage()
}
)
}

View file

@ -13,14 +13,16 @@ interface LoginTypesProvider {
fun intentToInitialLoginType(intent: Intent): LoginType
/** Whether the [LoginTypePage] may be non-interactive. This causes it to be skipped in back navigation. */
val maybeNonInteractive: Boolean
get() = false
@Composable
fun LoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onContinue: () -> Unit,
onFinish: () -> Unit
setInitialLoginInfo: (LoginInfo) -> Unit,
onContinue: () -> Unit
)
}

View file

@ -0,0 +1,240 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import android.net.Uri
import android.provider.Browser
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
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.remember
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.platform.LocalContext
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.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs
import at.bitfire.davdroid.ui.composable.Assistant
import kotlinx.coroutines.launch
object NextcloudLogin : LoginType {
override val title: Int
get() = R.string.login_type_nextcloud
override val helpUrl: Uri
get() = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("nextcloud")
.withStatParams("LoginTypeNextcloud")
.build()
@Composable
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
val model = viewModel<NextcloudLoginModel>()
LaunchedEffect(initialLoginInfo) {
model.initialize(initialLoginInfo)
}
val context = LocalContext.current
val checkResultCallback = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
model.onReturnFromBrowser()
}
val uiState = model.uiState
LaunchedEffect(uiState.loginUrl) {
if (uiState.loginUrl != null) {
val loginUri = uiState.loginUrl.toString().toUri()
if (haveCustomTabs(context)) {
// Custom Tabs are available
@Suppress("DEPRECATION")
val browser = CustomTabsIntent.Builder()
.setToolbarColor(context.resources.getColor(R.color.primaryColor))
.build()
browser.intent.data = loginUri
browser.intent.putExtra(
Browser.EXTRA_HEADERS,
bundleOf("Accept-Language" to Locale.current.toLanguageTag())
)
checkResultCallback.launch(browser.intent)
} else {
// fallback: launch normal browser
val browser = Intent(Intent.ACTION_VIEW, loginUri)
browser.addCategory(Intent.CATEGORY_BROWSABLE)
if (browser.resolveActivity(context.packageManager) != null) {
checkResultCallback.launch(browser)
} else
this@LaunchedEffect.launch {
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
}
}
}
}
// continue to resource detection when result is set in model
LaunchedEffect(uiState.result) {
if (uiState.result != null) {
onLogin(uiState.result)
model.resetResult()
}
}
NextcloudLoginScreen(
baseUrl = uiState.baseUrl,
onUpdateBaseUrl = { model.updateBaseUrl(it) },
canContinue = uiState.canContinue,
inProgress = uiState.inProgress,
error = uiState.error,
onLogin = { model.startLoginFlow() }
)
}
}
@Composable
fun NextcloudLoginScreen(
baseUrl: String,
onUpdateBaseUrl: (String) -> Unit = {},
canContinue: Boolean,
inProgress: Boolean,
error: String? = null,
onLogin: () -> Unit = {}
) {
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = canContinue,
onNext = onLogin
) {
if (inProgress)
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_nextcloud_login_with_nextcloud),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
Column {
Text(
stringResource(R.string.login_nextcloud_login_flow_text),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 8.dp)
)
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = baseUrl,
onValueChange = onUpdateBaseUrl,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.focusRequester(focusRequester),
enabled = !inProgress,
leadingIcon = {
Icon(Icons.Default.Cloud, null)
},
label = {
Text(stringResource(R.string.login_nextcloud_login_flow_server_address))
},
placeholder = { Text("cloud.example.com") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onLogin() }
),
singleLine = true
)
LaunchedEffect(Unit) {
if (baseUrl.isEmpty())
focusRequester.requestFocus()
}
if (error != null)
Card(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp)
)
Text(
error,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview() {
NextcloudLoginScreen(
baseUrl = "cloud.example.com",
canContinue = true,
inProgress = false,
error = null
)
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview_InProgressError() {
NextcloudLoginScreen(
baseUrl = "cloud.example.com",
canContinue = true,
inProgress = true,
error = "Some Error"
)
}

View file

@ -0,0 +1,172 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.NextcloudLoginFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.util.logging.Level
import javax.inject.Inject
@HiltViewModel
class NextcloudLoginModel @Inject constructor(
val context: Application
//val state: SavedStateHandle
): ViewModel() {
/*companion object {
const val STATE_POLL_URL = "poll_url"
const val STATE_TOKEN = "token"
}*/
data class UiState(
val baseUrl: String = "",
val inProgress: Boolean = false,
val error: String? = null,
/** URL to open in the browser (set during Login Flow) */
val loginUrl: HttpUrl? = null,
/** login info (set after successful login) */
val result: LoginInfo? = null
) {
val baseHttpUrl: HttpUrl? = run {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
baseUrlWithPrefix.toHttpUrlOrNull()
}
val canContinue = !inProgress && baseHttpUrl != null
}
var uiState by mutableStateOf(UiState())
private set
fun initialize(loginInfo: LoginInfo) {
val baseUri = loginInfo.baseUri
if (baseUri != null)
uiState = uiState.copy(
baseUrl = baseUri.toString()
.removePrefix("https://")
.removeSuffix(NextcloudLoginFlow.FLOW_V1_PATH)
.removeSuffix(NextcloudLoginFlow.FLOW_V2_PATH)
.removeSuffix(NextcloudLoginFlow.DAV_PATH)
)
uiState = uiState.copy(
error = null,
result = null
)
}
fun updateBaseUrl(baseUrl: String) {
uiState = uiState.copy(baseUrl = baseUrl)
}
val loginFlow = NextcloudLoginFlow(context)
// Login flow state
/*private var pollUrl: HttpUrl?
get() = state.get<String>(STATE_POLL_URL)?.toHttpUrlOrNull()
set(value) {
state[STATE_POLL_URL] = value.toString()
}
private var token: String?
get() = state.get<String>(STATE_TOKEN)
set(value) {
state[STATE_TOKEN] = value
}*/
override fun onCleared() {
loginFlow.close()
}
/**
* Starts the Login Flow.
*/
fun startLoginFlow() {
val baseUrl = uiState.baseHttpUrl
if (uiState.inProgress || baseUrl == null)
return
uiState = uiState.copy(
inProgress = true,
error = null
)
viewModelScope.launch {
try {
val loginUrl = loginFlow.initiate(baseUrl)
uiState = uiState.copy(
loginUrl = loginUrl,
inProgress = false
)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Initiating Login Flow failed", e)
uiState = uiState.copy(
inProgress = false,
error = e.toString()
)
}
}
}
/**
* Called when the custom tab / browser activity is finished. If memory is low, our
* [NextcloudLogin] and its model have been cleared in the meanwhile. So if
* we need certain data from the model, we have to make sure that these data are retained when the
* model is cleared (saved state).
*/
fun onReturnFromBrowser() = viewModelScope.launch {
// Login Flow has been started in browser by UI, should not be started again
uiState = uiState.copy(
loginUrl = null,
inProgress = true
)
val loginInfo = try {
loginFlow.fetchLoginInfo()
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Fetching login info failed", e)
uiState = uiState.copy(
inProgress = false,
error = e.toString()
)
return@launch
}
uiState = uiState.copy(
inProgress = false,
result = loginInfo
)
}
fun resetResult() {
uiState = uiState.copy(
loginUrl = null,
result = null
)
}
}

View file

@ -10,21 +10,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Password
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@ -34,16 +31,15 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.text.HtmlCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.Assistant
import at.bitfire.davdroid.ui.composable.PasswordTextField
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import java.net.URI
object LoginTypeUrl : LoginType {
object UrlLogin : LoginType {
override val title
get() = R.string.login_type_url
@ -52,81 +48,63 @@ object LoginTypeUrl : LoginType {
get() = null
@Composable
override fun Content(
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
onFinish: () -> Unit
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
LoginTypeUrl_Content(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
val model = viewModel<UrlLoginModel>()
LaunchedEffect(initialLoginInfo) {
model.initialize(initialLoginInfo)
}
val uiState = model.uiState
UrlLoginScreen(
url = uiState.url,
onSetUrl = model::setUrl,
username = uiState.username,
onSetUsername = model::setUsername,
password = uiState.password,
onSetPassword = model::setPassword,
canContinue = uiState.canContinue,
onLogin = {
if (uiState.canContinue)
onLogin(uiState.asLoginInfo())
}
)
}
}
@Composable
fun LoginTypeUrl_Content(
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit = {},
fun UrlLoginScreen(
url: String,
onSetUrl: (String) -> Unit = {},
username: String,
onSetUsername: (String) -> Unit = {},
password: String,
onSetPassword: (String) -> Unit = {},
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
var baseUrl by remember { mutableStateOf(
loginInfo.baseUri?.takeIf {
it.scheme.equals("http", ignoreCase = true) ||
it.scheme.equals("https", ignoreCase = true)
}?.toString() ?: ""
) }
var username by remember { mutableStateOf(loginInfo.credentials?.username ?: "") }
var password by remember { mutableStateOf(loginInfo.credentials?.password ?: "") }
val newLoginInfo = LoginInfo(
baseUri = try {
URI(
if (baseUrl.startsWith("http://", ignoreCase = true) || baseUrl.startsWith("https://", ignoreCase = true))
baseUrl
else
"https://$baseUrl"
)
} catch (_: Exception) {
null
},
credentials = Credentials(
username = username,
password = password
)
)
onUpdateLoginInfo(newLoginInfo)
val ok =
newLoginInfo.baseUri != null && (
newLoginInfo.baseUri.scheme.equals("http", ignoreCase = true) ||
newLoginInfo.baseUri.scheme.equals("https",ignoreCase = true)
) && newLoginInfo.credentials != null &&
newLoginInfo.credentials.username?.isNotEmpty() == true &&
newLoginInfo.credentials.password?.isNotEmpty() == true
val focusRequester = remember { FocusRequester() }
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = ok,
nextEnabled = canContinue,
onNext = onLogin
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_url),
style = MaterialTheme.typography.h5,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
value = url,
onValueChange = onSetUrl,
label = { Text(stringResource(R.string.login_base_url)) },
placeholder = { Text("dav.example.com/path") },
singleLine = true,
@ -149,7 +127,7 @@ fun LoginTypeUrl_Content(
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
@ -157,7 +135,7 @@ fun LoginTypeUrl_Content(
OutlinedTextField(
value = username,
onValueChange = { username = it },
onValueChange = onSetUsername,
label = { Text(stringResource(R.string.login_user_name)) },
singleLine = true,
leadingIcon = {
@ -172,7 +150,7 @@ fun LoginTypeUrl_Content(
PasswordTextField(
password = password,
onPasswordChange = { password = it },
onPasswordChange = onSetPassword,
labelText = stringResource(R.string.login_password),
leadingIcon = {
Icon(Icons.Default.Password, null)
@ -181,10 +159,9 @@ fun LoginTypeUrl_Content(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
if (ok)
onLogin()
}),
keyboardActions = KeyboardActions(
onDone = { onLogin() }
),
modifier = Modifier.fillMaxWidth()
)
}
@ -197,6 +174,11 @@ fun LoginTypeUrl_Content(
@Composable
@Preview
fun LoginTypeUrl_Content_Preview() {
LoginTypeUrl_Content(LoginInfo())
fun UrlLoginScreen_Preview() {
UrlLoginScreen(
url = "https://example.com",
username = "user",
password = "",
canContinue = false
)
}

View file

@ -0,0 +1,71 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.util.DavUtils.toURIorNull
import dagger.hilt.android.lifecycle.HiltViewModel
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
import java.net.URI
import java.net.URISyntaxException
import javax.inject.Inject
class UrlLoginModel: ViewModel() {
data class UiState(
val url: String = "",
val username: String = "",
val password: String = ""
) {
val urlWithPrefix =
if (url.startsWith("http://") || url.startsWith("https://"))
url
else
"https://$url"
val uri = urlWithPrefix.toURIorNull()
val canContinue = uri != null && username.isNotEmpty() && password.isNotEmpty()
fun asLoginInfo(): LoginInfo =
LoginInfo(
baseUri = uri,
credentials = Credentials(
username = StringUtils.trimToNull(username),
password = StringUtils.trimToNull(password)
)
)
}
var uiState by mutableStateOf(UiState())
private set
fun initialize(loginInfo: LoginInfo) {
uiState = UiState(
url = loginInfo.baseUri?.toString()?.removePrefix("https://") ?: "",
username = loginInfo.credentials?.username ?: "",
password = loginInfo.credentials?.password ?: ""
)
}
fun setUrl(url: String) {
uiState = uiState.copy(url = url)
}
fun setUsername(username: String) {
uiState = uiState.copy(username = username)
}
fun setPassword(password: String) {
uiState = uiState.copy(password = password)
}
}

View file

@ -5,7 +5,7 @@
package at.bitfire.davdroid.ui.widget
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalContentColor
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler

View file

@ -20,6 +20,8 @@ import org.xbill.DNS.SRVRecord
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TXTRecord
import java.net.InetAddress
import java.net.URI
import java.net.URISyntaxException
import java.util.LinkedList
import java.util.Locale
import java.util.TreeMap
@ -206,4 +208,10 @@ object DavUtils {
fun MediaType.sameTypeAs(other: MediaType) =
type == other.type && subtype == other.subtype
fun String.toURIorNull(): URI? = try {
URI(this)
} catch (_: URISyntaxException) {
null
}
}

View file

@ -18,6 +18,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
@ -28,7 +29,11 @@ interface OseFlavorModules {
interface ForActivities {
@Binds
fun accountsDrawerHandler(impl: OseAccountsDrawerHandler): AccountsDrawerHandler
}
@Module
@InstallIn(ViewModelComponent::class)
interface ForViewModels {
@Binds
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider

View file

@ -60,70 +60,68 @@ object M2Colors {
object M3ColorScheme {
val md_theme_light_primary = Color(0xFF3C6A00)
val md_theme_light_primary = Color(0xFF47680F)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFB8F47A)
val md_theme_light_onPrimaryContainer = Color(0xFF0E2000)
val md_theme_light_secondary = Color(0xFF3C6A00)
val md_theme_light_primaryContainer = Color(0xFFC7F089)
val md_theme_light_onPrimaryContainer = Color(0xFF121F00)
val md_theme_light_secondary = Color(0xFF8B5000)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFB8F47A)
val md_theme_light_onSecondaryContainer = Color(0xFF0E2000)
val md_theme_light_tertiary = Color(0xFF964900)
val md_theme_light_secondaryContainer = Color(0xFFFFDCBF)
val md_theme_light_onSecondaryContainer = Color(0xFF2D1600)
val md_theme_light_tertiary = Color(0xFF4C6700)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFFFDCC7)
val md_theme_light_onTertiaryContainer = Color(0xFF311300)
val md_theme_light_tertiaryContainer = Color(0xFFC4F25A)
val md_theme_light_onTertiaryContainer = Color(0xFF151F00)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFF8FDFF)
val md_theme_light_onBackground = Color(0xFF001F25)
val md_theme_light_surface = Color(0xFFF8FDFF)
val md_theme_light_onSurface = Color(0xFF001F25)
val md_theme_light_background = Color(0xFFFEFCF5)
val md_theme_light_onBackground = Color(0xFF1B1C18)
val md_theme_light_surface = Color(0xFFFEFCF5)
val md_theme_light_onSurface = Color(0xFF1B1C18)
val md_theme_light_surfaceVariant = Color(0xFFE1E4D5)
val md_theme_light_onSurfaceVariant = Color(0xFF44483D)
val md_theme_light_outline = Color(0xFF75796C)
val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF)
val md_theme_light_inverseSurface = Color(0xFF00363F)
val md_theme_light_inversePrimary = Color(0xFF9DD761)
val md_theme_light_onSurfaceVariant = Color(0xFF45483D)
val md_theme_light_outline = Color(0xFF75786C)
val md_theme_light_inverseOnSurface = Color(0xFFF2F1E9)
val md_theme_light_inverseSurface = Color(0xFF30312C)
val md_theme_light_inversePrimary = Color(0xFFACD370)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF3C6A00)
val md_theme_light_outlineVariant = Color(0xFFC4C8BA)
val md_theme_light_surfaceTint = Color(0xFF47680F)
val md_theme_light_outlineVariant = Color(0xFFC5C8B9)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF9DD761)
val md_theme_dark_onPrimary = Color(0xFF1D3700)
val md_theme_dark_primaryContainer = Color(0xFF2C5000)
val md_theme_dark_onPrimaryContainer = Color(0xFFB8F47A)
val md_theme_dark_secondary = Color(0xFF9DD761)
val md_theme_dark_onSecondary = Color(0xFF1D3700)
val md_theme_dark_secondaryContainer = Color(0xFF2C5000)
val md_theme_dark_onSecondaryContainer = Color(0xFFB8F47A)
val md_theme_dark_tertiary = Color(0xFFFFB787)
val md_theme_dark_onTertiary = Color(0xFF502400)
val md_theme_dark_tertiaryContainer = Color(0xFF723600)
val md_theme_dark_onTertiaryContainer = Color(0xFFFFDCC7)
val md_theme_dark_primary = Color(0xFFACD370)
val md_theme_dark_onPrimary = Color(0xFF223600)
val md_theme_dark_primaryContainer = Color(0xFF334F00)
val md_theme_dark_onPrimaryContainer = Color(0xFFC7F089)
val md_theme_dark_secondary = Color(0xFFFFB872)
val md_theme_dark_onSecondary = Color(0xFF4A2800)
val md_theme_dark_secondaryContainer = Color(0xFF6A3B00)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFDCBF)
val md_theme_dark_tertiary = Color(0xFFA9D540)
val md_theme_dark_onTertiary = Color(0xFF263500)
val md_theme_dark_tertiaryContainer = Color(0xFF394E00)
val md_theme_dark_onTertiaryContainer = Color(0xFFC4F25A)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF001F25)
val md_theme_dark_onBackground = Color(0xFFA6EEFF)
val md_theme_dark_surface = Color(0xFF001F25)
val md_theme_dark_onSurface = Color(0xFFA6EEFF)
val md_theme_dark_surfaceVariant = Color(0xFF44483D)
val md_theme_dark_onSurfaceVariant = Color(0xFFC4C8BA)
val md_theme_dark_outline = Color(0xFF8E9285)
val md_theme_dark_inverseOnSurface = Color(0xFF001F25)
val md_theme_dark_inverseSurface = Color(0xFFA6EEFF)
val md_theme_dark_inversePrimary = Color(0xFF3C6A00)
val md_theme_dark_background = Color(0xFF1B1C18)
val md_theme_dark_onBackground = Color(0xFFE4E3DB)
val md_theme_dark_surface = Color(0xFF1B1C18)
val md_theme_dark_onSurface = Color(0xFFE4E3DB)
val md_theme_dark_surfaceVariant = Color(0xFF45483D)
val md_theme_dark_onSurfaceVariant = Color(0xFFC5C8B9)
val md_theme_dark_outline = Color(0xFF8F9285)
val md_theme_dark_inverseOnSurface = Color(0xFF1B1C18)
val md_theme_dark_inverseSurface = Color(0xFFE4E3DB)
val md_theme_dark_inversePrimary = Color(0xFF47680F)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF9DD761)
val md_theme_dark_outlineVariant = Color(0xFF44483D)
val md_theme_dark_surfaceTint = Color(0xFFACD370)
val md_theme_dark_outlineVariant = Color(0xFF45483D)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF7CB342)
val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,

View file

@ -8,9 +8,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -24,6 +24,7 @@ import at.bitfire.davdroid.ui.composable.Assistant
fun StandardLoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
setInitialLoginInfo: (LoginInfo) -> Unit,
onContinue: () -> Unit = {}
) {
Assistant(
@ -34,7 +35,7 @@ fun StandardLoginTypePage(
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_generic_login),
style = MaterialTheme.typography.h6,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
for (type in StandardLoginTypesProvider.genericLoginTypes)
@ -46,7 +47,7 @@ fun StandardLoginTypePage(
Text(
stringResource(R.string.login_provider_login),
style = MaterialTheme.typography.h6,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
for (type in StandardLoginTypesProvider.specificLoginTypes)
@ -78,7 +79,7 @@ fun LoginTypeSelector(
)
Text(
title,
style = MaterialTheme.typography.body1,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
@ -89,8 +90,8 @@ fun LoginTypeSelector(
@Composable
@Preview
fun LoginScreen_Preview() {
LoginScreen(
/*LoginScreen(
loginTypesProvider = StandardLoginTypesProvider(),
initialLoginType = LoginTypeUrl
)
)*/
}

View file

@ -12,37 +12,36 @@ class StandardLoginTypesProvider @Inject constructor() : LoginTypesProvider {
companion object {
val genericLoginTypes = listOf(
LoginTypeUrl,
LoginTypeEmail,
LoginTypeAdvanced
UrlLogin,
EmailLogin,
AdvancedLogin
)
val specificLoginTypes = listOf(
LoginTypeGoogle,
LoginTypeNextcloud
GoogleLogin,
NextcloudLogin
)
}
override val defaultLoginType = LoginTypeUrl
override val defaultLoginType = UrlLogin
override fun intentToInitialLoginType(intent: Intent) =
if (intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW))
LoginTypeNextcloud
NextcloudLogin
else
LoginTypeUrl
UrlLogin
@Composable
override fun LoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onContinue: () -> Unit,
onFinish: () -> Unit
setInitialLoginInfo: (LoginInfo) -> Unit,
onContinue: () -> Unit
) {
StandardLoginTypePage(
selectedLoginType = selectedLoginType,
onSelectLoginType = onSelectLoginType,
setInitialLoginInfo = setInitialLoginInfo,
onContinue = onContinue
)
}