mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-10-07 03:42:59 +00:00
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:
parent
34b88c3ad8
commit
019dde6ef9
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)")
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = ""
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
293
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLogin.kt
Normal file
293
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/GoogleLogin.kt
Normal 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
|
||||
)
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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("") { _, _ -> }
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)*/
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue