Rewrite login activity to Compose (#672)

* Remove unnecessary layout files

* [WIP] Rewrite LoginActivity to Compose

* [WIP] Login type

* [WIP] Login by URL, Google, Nextcloud

* Remove unnecessary files and kapt

* More renaming and removing of unnecessary files

* Login with email, URL

* Login type: Advanced

* Drop "known base URLs"

* "Detect resources" and "Create account" page

* Introduce LoginTypesProvider interface
This commit is contained in:
Ricki Hirner 2024-03-24 18:25:30 +01:00 committed by GitHub
parent 014c94a031
commit 079c3efdfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 2756 additions and 2831 deletions

View file

@ -7,7 +7,6 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.ksp)
}
@ -44,8 +43,6 @@ android {
buildFeatures {
buildConfig = true
compose = true
viewBinding = true
dataBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()

View file

@ -13,7 +13,6 @@ import androidx.work.Configuration
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
@ -22,7 +21,6 @@ import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.setup.LoginModel
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
@ -41,7 +39,6 @@ import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import javax.inject.Inject
@HiltAndroidTest
@ -98,7 +95,6 @@ class RefreshCollectionsWorkerTest {
var mockServer = MockWebServer()
lateinit var client: HttpClient
lateinit var loginModel: LoginModel
@Before
fun mockServerSetup() {
@ -106,13 +102,7 @@ class RefreshCollectionsWorkerTest {
mockServer.dispatcher = TestDispatcher()
mockServer.start()
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, loginModel.credentials!!)
.build()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}

View file

@ -16,8 +16,8 @@ class CredentialsStoreTest {
@Test
fun testSetGetDelete() {
store.setCredentials(0, Credentials(userName = "myname", password = "12345"))
assertEquals(Credentials(userName = "myname", password = "12345"), store.getCredentials(0))
store.setCredentials(0, Credentials(username = "myname", password = "12345"))
assertEquals(Credentials(username = "myname", password = "12345"), store.getCredentials(0))
store.setCredentials(0, null)
assertNull(store.getCredentials(0))

View file

@ -7,14 +7,12 @@ package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.WebDavDocument
import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.setup.LoginModel
import at.bitfire.davdroid.network.HttpClient
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.CookieJar
@ -28,7 +26,6 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI
import javax.inject.Inject
@HiltAndroidTest
@ -49,7 +46,6 @@ class DavDocumentsProviderTest {
private var mockServer = MockWebServer()
private lateinit var client: HttpClient
private lateinit var loginModel: LoginModel
companion object {
private const val PATH_WEBDAV_ROOT = "/webdav"
@ -61,13 +57,7 @@ class DavDocumentsProviderTest {
mockServer.dispatcher = TestDispatcher()
mockServer.start()
loginModel = LoginModel()
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, loginModel.credentials!!)
.build()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
// mock server delivers HTTP without encryption
assertTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)

View file

@ -114,8 +114,9 @@
<activity
android:name=".ui.setup.LoginActivity"
android:label="@string/login_title"
android:parentActivityName=".ui.AccountsActivity"
android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>

View file

@ -1,39 +0,0 @@
carddav.a1.net
aol.com
calendar.dingtalk.com/dav/
cloud.disroot.org
dav.edis.at
dav.fruux.com
caldav.gmx.com
caldav.gmx.co.uk
caldav.gmx.de
caldav.gmx.es
caldav.gmx.fr
caldav.gmx.net
carddav.gmx.com
carddav.gmx.co.uk
carddav.gmx.de
carddav.gmx.es
carddav.gmx.fr
carddav.gmx.net
framagenda.org/remote.php/dav/
icloud.com
cloud.liberta.vip
office.luckycloud.de
calendar.mail.ru
dav.mailbox.org
mailfence.com
caldav.mailo.com
carddav.mailo.com
posteo.de:8443
purelymail.com
dav.runbox.com
live.teambox.eu
spica.t-online.de
caldav.calendar.yahoo.com
yandex.ru
webmail.your-server.de/rpc.php/
calendar.zoho.com
calendar.zoho.eu
contacts.zoho.com
contacts.zoho.eu

View file

@ -22,6 +22,8 @@ object Constants {
const val HOMEPAGE_PATH_TESTED_SERVICES = "tested-with"
val MANUAL_URL = "https://manual.davx5.com".toUri()
const val MANUAL_PATH_ACCOUNTS_COLLECTIONS = "accounts_collections.html"
const val MANUAL_FRAGMENT_SERVICE_DISCOVERY = "how-does-service-discovery-work"
const val MANUAL_PATH_WEBDAV_MOUNTS = "webdav_mounts.html"
val COMMUNITY_URL = "https://github.com/bitfireAT/davx5-ose/discussions".toUri()

View file

@ -7,7 +7,7 @@ package at.bitfire.davdroid.db
import net.openid.appauth.AuthState
data class Credentials(
val userName: String? = null,
val username: String? = null,
val password: String? = null,
val certificateAlias: String? = null,
@ -18,8 +18,8 @@ data class Credentials(
override fun toString(): String {
val s = mutableListOf<String>()
if (userName != null)
s += "userName=$userName"
if (username != null)
s += "userName=$username"
if (password != null)
s += "password=*****"

View file

@ -167,8 +167,8 @@ class HttpClient private constructor(
}
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false, authStateCallback: BearerAuthInterceptor.AuthStateUpdateCallback? = null): Builder {
if (credentials.userName != null && credentials.password != null) {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName, credentials.password, insecurePreemptive)
if (credentials.username != null && credentials.password != null) {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.username, credentials.password, insecurePreemptive)
orig.addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}

View file

@ -58,7 +58,7 @@ import java.util.logging.Logger
class DavResourceFinder(
val context: Context,
private val baseURI: URI,
credentials: Credentials? = null
private val credentials: Credentials? = null
): AutoCloseable {
enum class Service(val wellKnownName: String) {
@ -124,9 +124,10 @@ class DavResourceFinder(
}
return Configuration(
cardDavConfig, calDavConfig,
encountered401,
logBuffer.toString()
cardDAV = cardDavConfig,
calDAV = calDavConfig,
encountered401 = encountered401,
logs = logBuffer.toString()
)
}

View file

@ -119,8 +119,8 @@ class AccountSettings(
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.userName != null)
bundle.putString(KEY_USERNAME, credentials.userName)
if (credentials.username != null)
bundle.putString(KEY_USERNAME, credentials.username)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
@ -193,7 +193,7 @@ class AccountSettings(
fun credentials(credentials: Credentials) {
// Basic/Digest auth
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setAndVerifyUserData(account, KEY_USERNAME, credentials.username)
accountManager.setPassword(account, credentials.password)
// client certificate

View file

@ -87,9 +87,9 @@ import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.composable.ActionCard
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import at.bitfire.davdroid.ui.composable.ActionCard
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState

View file

@ -781,7 +781,7 @@ class DebugInfoActivity : AppCompatActivity() {
val credentials = accountSettings.credentials()
val authStr = mutableListOf<String>()
if (credentials.userName != null)
if (credentials.username != null)
authStr += "user name"
if (credentials.password != null)
authStr += "password"

View file

@ -1,58 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.Context
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.TextView
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.util.DavUtils
class HomeSetAdapter(
context: Context
): ArrayAdapter<HomeSet>(context, R.layout.text_list_item, android.R.id.text1) {
init {
if (context is Application)
throw IllegalArgumentException("Pass the Activity context, otherwise dark mode won't work")
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val data = getItem(position)!!
val v: View = convertView ?: LayoutInflater.from(context).inflate(R.layout.text_list_item, parent, false)
v.findViewById<TextView>(android.R.id.text1).apply {
text = data.displayName ?: DavUtils.lastSegmentOfUrl(data.url)
}
v.findViewById<TextView>(android.R.id.text2).apply {
text = data.url.toString()
setSingleLine()
ellipsize = TextUtils.TruncateAt.START
}
return v
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
getView(position, convertView, parent)
override fun getFilter() = object: Filter() {
override fun convertResultToString(resultValue: Any?): CharSequence {
val homeSet = resultValue as HomeSet
return homeSet.url.toString()
}
override fun performFiltering(constraint: CharSequence?) = FilterResults()
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
}
}
}

View file

@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Checkbox
import androidx.compose.material.MaterialTheme
@ -37,7 +36,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
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
@ -51,11 +49,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.PackageChangedReceiver
import at.bitfire.davdroid.R
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.composable.CardWithImage
import at.bitfire.davdroid.ui.composable.RadioWithSwitch
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
@ -230,17 +229,7 @@ fun TasksCard(
stringResource(R.string.intro_tasks_tasks_org_info),
HtmlCompat.FROM_HTML_MODE_COMPACT
).toAnnotatedString()
val uriHandler = LocalUriHandler.current
ClickableText(
text = summary,
onClick = { index ->
// Get the tapped position, and check if there's any link
summary.getUrlAnnotations(index, index).firstOrNull()?.item?.url?.let { url ->
uriHandler.openUri(url)
}
}
)
ClickableTextWithLink(summary)
},
isSelected = tasksOrgSelected,
isToggled = tasksOrgInstalled,
@ -283,6 +272,7 @@ fun TasksCard(
)
Text(
text = stringResource(R.string.intro_tasks_dont_show),
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.clickable { model.setShowAgain(!showAgain) }

View file

@ -63,7 +63,6 @@ import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
@ -76,6 +75,7 @@ import at.bitfire.davdroid.ui.composable.Setting
import at.bitfire.davdroid.ui.composable.SettingsHeader
import at.bitfire.davdroid.ui.composable.SwitchSetting
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.assisted.Assisted
@ -401,13 +401,13 @@ class AccountSettingsActivity: AppCompatActivity() {
}
)
} else { // username/password
if (credentials.userName != null) {
} else { // username/password
if (credentials.username != null) {
var showUsernameDialog by remember { mutableStateOf(false) }
Setting(
icon = Icons.Default.AccountCircle,
name = stringResource(R.string.settings_username),
summary = credentials.userName,
summary = credentials.username,
onClick = {
showUsernameDialog = true
}
@ -415,9 +415,9 @@ class AccountSettingsActivity: AppCompatActivity() {
if (showUsernameDialog)
EditTextInputDialog(
title = stringResource(R.string.settings_username),
initialValue = credentials.userName ?: "",
initialValue = credentials.username ?: "",
onValueEntered = { newValue ->
onUpdateCredentials(credentials.copy(userName = newValue))
onUpdateCredentials(credentials.copy(username = newValue))
},
onDismiss = { showUsernameDialog = false }
)
@ -492,7 +492,7 @@ class AccountSettingsActivity: AppCompatActivity() {
@Preview
fun AuthenticationSettings_Preview_UsernamePassword() {
AuthenticationSettings(
credentials = Credentials(userName = "user", password = "password")
credentials = Credentials(username = "user", password = "password")
)
}
@ -500,7 +500,7 @@ class AccountSettingsActivity: AppCompatActivity() {
@Preview
fun AuthenticationSettings_Preview_UsernamePassword_ClientCertificate() {
AuthenticationSettings(
credentials = Credentials(userName = "user", password = "password", certificateAlias = "alias")
credentials = Credentials(username = "user", password = "password", certificateAlias = "alias")
)
}

View file

@ -0,0 +1,55 @@
package at.bitfire.davdroid.ui.composable
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.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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun Assistant(
nextLabel: String? = null,
nextEnabled: Boolean = true,
onNext: () -> Unit = {},
content: @Composable () -> Unit
) {
Column(Modifier.fillMaxSize()) {
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())
}
}
}
}
@Composable
@Preview
fun Assistant_Preview() {
Assistant(nextLabel = "Next") {
Text("Some Content")
}
}

View file

@ -1,45 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.composable
import android.text.method.LinkMovementMethod
import android.widget.AutoCompleteTextView
import android.widget.TextView
import androidx.core.text.HtmlCompat
import androidx.databinding.BindingAdapter
import androidx.databinding.InverseBindingAdapter
import org.apache.commons.lang3.StringUtils
object BindingAdapters {
@BindingAdapter("error")
@JvmStatic
fun setError(textView: TextView, error: String?) {
textView.error = StringUtils.trimToNull(error)
}
@BindingAdapter("html")
@JvmStatic
fun setHtml(textView: TextView, html: String?) {
if (html != null) {
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
textView.movementMethod = LinkMovementMethod.getInstance()
} else
textView.text = null
}
@BindingAdapter("unfilteredText")
@JvmStatic
fun setUnfilteredText(textView: AutoCompleteTextView, text: String?) {
if (textView.text.toString() != text)
textView.setText(text, false)
}
@InverseBindingAdapter(attribute = "unfilteredText", event = "android:textAttrChanged")
@JvmStatic
fun getUnfilteredText(textView: AutoCompleteTextView) = textView.text.toString()
}

View file

@ -1,102 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.composable
import android.content.Context
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView
import at.bitfire.davdroid.R
/**
* [android.widget.ImageView] that supports directional cropping in both vertical and
* horizontal directions instead of being restricted to center-crop. Automatically sets [ ] to MATRIX and defaults to center-crop.
*
* @author Based on source code found on https://stackoverflow.com/a/26031741, by qix (CC BY-SA 4.0).
*/
class CropImageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0)
: AppCompatImageView(context, attrs, defStyleAttr) {
companion object {
private const val DEFAULT_HORIZONTAL_OFFSET = 0.5f
private const val DEFAULT_VERTICAL_OFFSET = 0.5f
}
private var mHorizontalOffsetPercent = DEFAULT_HORIZONTAL_OFFSET
private var mVerticalOffsetPercent = DEFAULT_VERTICAL_OFFSET
init {
scaleType = ScaleType.MATRIX
if (attrs != null) {
context.theme.obtainStyledAttributes(attrs, R.styleable.CropImageView, 0, 0).apply {
mHorizontalOffsetPercent = getFloat(R.styleable.CropImageView_horizontalOffsetPercent, DEFAULT_HORIZONTAL_OFFSET)
mVerticalOffsetPercent = getFloat(R.styleable.CropImageView_verticalOffsetPercent, DEFAULT_VERTICAL_OFFSET)
}
applyCropOffset()
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
applyCropOffset()
}
/**
* Sets the crop box offset by the specified percentage values. For example, a center-crop would
* be (0.5, 0.5), a top-left crop would be (0, 0), and a bottom-center crop would be (0.5, 1)
*/
fun setCropOffset(horizontalOffsetPercent: Float, verticalOffsetPercent: Float) {
require(!(mHorizontalOffsetPercent < 0 || mVerticalOffsetPercent < 0 || mHorizontalOffsetPercent > 1 || mVerticalOffsetPercent > 1)) { "Offset values must be a float between 0.0 and 1.0" }
mHorizontalOffsetPercent = horizontalOffsetPercent
mVerticalOffsetPercent = verticalOffsetPercent
applyCropOffset()
}
private fun applyCropOffset() {
val matrix = imageMatrix
val scale: Float
val viewWidth = width - paddingLeft - paddingRight
val viewHeight = height - paddingTop - paddingBottom
var drawableWidth = 0
var drawableHeight = 0
// Allow for setting the drawable later in code by guarding ourselves here.
if (drawable != null) {
drawableWidth = drawable.intrinsicWidth
drawableHeight = drawable.intrinsicHeight
}
// Get the scale.
scale = if (drawableWidth * viewHeight > drawableHeight * viewWidth) {
// Drawable is flatter than view. Scale it to fill the view height.
// A Top/Bottom crop here should be identical in this case.
viewHeight.toFloat() / drawableHeight.toFloat()
} else {
// Drawable is taller than view. Scale it to fill the view width.
// Left/Right crop here should be identical in this case.
viewWidth.toFloat() / drawableWidth.toFloat()
}
val viewToDrawableWidth = viewWidth / scale
val viewToDrawableHeight = viewHeight / scale
val xOffset = mHorizontalOffsetPercent * (drawableWidth - viewToDrawableWidth)
val yOffset = mVerticalOffsetPercent * (drawableHeight - viewToDrawableHeight)
// Define the rect from which to take the image portion.
val drawableRect = RectF(
xOffset,
yOffset,
xOffset + viewToDrawableWidth,
yOffset + viewToDrawableHeight)
val viewRect = RectF(0f, 0f, viewWidth.toFloat(), viewHeight.toFloat())
matrix.setRectToRect(drawableRect, viewRect, Matrix.ScaleToFit.FILL)
imageMatrix = matrix
}
}

View file

@ -1,7 +1,7 @@
package at.bitfire.davdroid.ui.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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
@ -16,12 +16,13 @@ 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.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
@Composable
@ -29,19 +30,29 @@ fun PasswordTextField(
password: String,
labelText: String,
onPasswordChange: (String) -> Unit,
modifier: Modifier = Modifier,
leadingIcon: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
keyboardActions: KeyboardActions = KeyboardActions.Default,
enabled: Boolean = true,
isError: Boolean = false
) {
var passwordVisible by remember { mutableStateOf(false) }
LocalFocusManager.current.moveFocus(FocusDirection.Down)
OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
label = { Text(labelText) },
leadingIcon = leadingIcon,
isError = isError,
singleLine = true,
enabled = enabled,
modifier = modifier.focusGroup(),
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
if (passwordVisible)
@ -49,10 +60,7 @@ fun PasswordTextField(
else
Icon(Icons.Default.Visibility, stringResource(R.string.login_password_show))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
}
)
}

View file

@ -309,7 +309,7 @@ private fun BatteryOptimizationsContent(
)
Text(
text = stringResource(R.string.intro_battery_dont_show),
style = MaterialTheme.typography.caption,
style = MaterialTheme.typography.body2,
modifier = Modifier
.clickable { onChangeDontShowBattery(!dontShowBattery) }
)
@ -365,7 +365,7 @@ private fun BatteryOptimizationsContent(
)
Text(
text = stringResource(R.string.intro_autostart_dont_show),
style = MaterialTheme.typography.caption,
style = MaterialTheme.typography.body2,
modifier = Modifier
.clickable { onChangeDontShowAutostart(!dontShowAutostart) }
)

View file

@ -21,9 +21,7 @@ import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
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.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@ -32,7 +30,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.databinding.ObservableBoolean
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.Constants
@ -71,13 +68,13 @@ class OpenSourcePage : IntroPage {
@Composable
private fun Page(model: Model = viewModel()) {
var dontShow by remember { mutableStateOf(model.dontShow.get()) }
val dontShow by produceState(false) {
value = model.dontShow
}
PageContent(
dontShow = dontShow,
onChangeDontShow = {
model.dontShow.set(it)
dontShow = it
model.dontShow = it
}
)
}
@ -152,16 +149,15 @@ class OpenSourcePage : IntroPage {
const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
}
val dontShow = object: ObservableBoolean() {
override fun set(dontShowAgain: Boolean) {
var dontShow: Boolean
get() = settings.containsKey(SETTING_NEXT_DONATION_POPUP)
set(dontShowAgain) {
if (dontShowAgain) {
val nextReminder = System.currentTimeMillis() + 90*86400000L // 90 days (~ 3 months)
settings.putLong(SETTING_NEXT_DONATION_POPUP, nextReminder)
} else
settings.remove(SETTING_NEXT_DONATION_POPUP)
super.set(dontShowAgain)
}
}
}

View file

@ -1,278 +0,0 @@
/***************************************************************************************************
* 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.accounts.AccountManager
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import android.text.Editable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.davdroid.servicedetection.DavResourceFinder
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.ui.account.AccountActivity
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import java.util.logging.Level
import javax.inject.Inject
@AndroidEntryPoint
class AccountDetailsFragment : Fragment() {
@Inject lateinit var settings: SettingsManager
val loginModel by activityViewModels<LoginModel>()
val model by viewModels<Model>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = LoginAccountDetailsBinding.inflate(inflater, container, false)
v.lifecycleOwner = viewLifecycleOwner
v.details = model
val config = loginModel.configuration ?: throw IllegalStateException()
// default account name
model.name.value =
config.calDAV?.emails?.firstOrNull()
?: loginModel.suggestedAccountName
?: loginModel.credentials?.userName
?: loginModel.credentials?.certificateAlias
?: loginModel.baseURI?.host
// CardDAV-specific
v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE
if (settings.containsKey(AccountSettings.KEY_CONTACT_GROUP_METHOD))
v.contactGroupMethod.isEnabled = false
// CalDAV-specific
config.calDAV?.let {
val accountNameAdapter = ArrayAdapter(requireActivity(), android.R.layout.simple_list_item_1, it.emails)
v.accountName.setAdapter(accountNameAdapter)
}
v.createAccount.setOnClickListener {
val name = model.name.value
if (name.isNullOrBlank())
model.nameError.value = getString(R.string.login_account_name_required)
else {
// check whether account name already exists
val am = AccountManager.get(requireActivity())
if (am.getAccountsByType(getString(R.string.account_type)).any { it.name == name }) {
model.nameError.value = getString(R.string.login_account_name_already_taken)
return@setOnClickListener
}
val idx = v.contactGroupMethod.selectedItemPosition
val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx]
v.createAccountProgress.visibility = View.VISIBLE
v.createAccount.visibility = View.GONE
model.createAccount(
name,
loginModel.credentials,
config,
GroupMethod.valueOf(groupMethodName)
).observe(viewLifecycleOwner, { success ->
if (success) {
// close Create account activity
requireActivity().finish()
// open Account activity for created account
val intent = Intent(requireActivity(), AccountActivity::class.java)
val account = Account(name, getString(R.string.account_type))
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
} else {
Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show()
v.createAccountProgress.visibility = View.GONE
v.createAccount.visibility = View.VISIBLE
}
})
}
}
val forcedGroupMethod = settings.getString(AccountSettings.KEY_CONTACT_GROUP_METHOD)?.let { GroupMethod.valueOf(it) }
if (forcedGroupMethod != null) {
// contact group type forced by settings
v.contactGroupMethod.isEnabled = false
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
if (method == forcedGroupMethod.name) {
v.contactGroupMethod.setSelection(i)
break
}
}
} else {
// contact group type selectable
v.contactGroupMethod.isEnabled = true
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
// take suggestion from detection process into account
if (method == loginModel.suggestedGroupMethod.name) {
v.contactGroupMethod.setSelection(i)
break
}
}
}
return v.root
}
@HiltViewModel
class Model @Inject constructor(
application: Application,
val db: AppDatabase,
val settingsManager: SettingsManager
) : AndroidViewModel(application) {
val name = MutableLiveData<String>()
val nameError = MutableLiveData<String>()
val showApostropheWarning = MutableLiveData(false)
val context: Context get() = getApplication()
fun validateAccountName(s: Editable) {
showApostropheWarning.value = s.toString().contains('\'')
nameError.value = null
}
/**
* 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)
*/
fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData<Boolean> {
val result = MutableLiveData<Boolean>()
viewModelScope.launch(Dispatchers.Default + NonCancellable) {
val account = Account(name, 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)) {
result.postValue(false)
return@launch
}
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
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)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(name, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
result.postValue(false)
return@launch
}
result.postValue(true)
}
return result
}
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = db.serviceDao().insertOrReplace(service)
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrUpdateByUrl(collection)
}
return serviceId
}
}
}

View file

@ -0,0 +1,239 @@
package at.bitfire.davdroid.ui.setup
import android.accounts.Account
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
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.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource
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.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
@Composable
fun AccountDetailsPage(
loginInfo: LoginInfo,
foundConfig: DavResourceFinder.Configuration,
onBack: () -> Unit,
onAccountCreated: (Account) -> Unit,
model: LoginModel = viewModel()
) {
BackHandler(onBack = onBack)
val resultOrNull by model.createAccountResult.observeAsState()
var showExceptionInfo by remember { mutableStateOf(false) }
LaunchedEffect(resultOrNull) {
showExceptionInfo = resultOrNull != null
}
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
}
)
// TODO else
}
}
}
val suggestedAccountNames = foundConfig.calDAV?.emails ?: emptyList()
var accountName by remember { mutableStateOf(suggestedAccountNames.firstOrNull() ?: "") }
val forcedGroupMethod by model.forcedGroupMethod.observeAsState()
var groupMethod by remember { mutableStateOf(forcedGroupMethod ?: loginInfo.suggestedGroupMethod) }
AccountDetailsPage_Content(
suggestedAccountNames = suggestedAccountNames,
accountName = accountName,
onUpdateAccountName = { accountName = it },
onCreateAccount = {
model.createAccount(
credentials = loginInfo.credentials,
foundConfig = foundConfig,
name = accountName,
groupMethod = groupMethod
)
},
groupMethod = groupMethod,
groupMethodReadOnly = forcedGroupMethod != null,
onUpdateGroupMethod = { groupMethod = it }
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AccountDetailsPage_Content(
suggestedAccountNames: List<String>?,
accountName: String,
onUpdateAccountName: (String) -> Unit = {},
groupMethod: GroupMethod,
groupMethodReadOnly: Boolean,
onUpdateGroupMethod: (GroupMethod) -> Unit = {},
onCreateAccount: () -> Unit = {}
) {
Assistant(
nextLabel = stringResource(R.string.login_create_account),
onNext = onCreateAccount
) {
Column(Modifier.padding(8.dp)) {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = accountName,
onValueChange = onUpdateAccountName,
label = { Text(stringResource(R.string.login_account_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email
),
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
},
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
if (suggestedAccountNames != null)
for (name in suggestedAccountNames)
Text(
name,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable {
onUpdateAccountName(name)
expanded = false
}
)
}
}
// apostrophe warning
if (accountName.contains('\'') || accountName.contains('"'))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp)
)
Text(
stringResource(R.string.login_account_avoid_apostrophe),
style = MaterialTheme.typography.body1
)
}
// email address info
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 16.dp)
) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp)
)
Text(
stringResource(R.string.login_account_name_info),
style = MaterialTheme.typography.body1
)
}
// group type selector
Text(
stringResource(R.string.login_account_contact_group_method),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 16.dp)
)
val groupMethodNames = stringArrayResource(R.array.settings_contact_group_method_entries)
val groupMethodValues = stringArrayResource(R.array.settings_contact_group_method_values).map { GroupMethod.valueOf(it) }
for ((name, method) in groupMethodNames.zip(groupMethodValues)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = groupMethod == method,
enabled = !groupMethodReadOnly,
onClick = { onUpdateGroupMethod(method) }
)
var modifier = Modifier.padding(vertical = 4.dp)
if (!groupMethodReadOnly)
modifier = modifier.clickable(onClick = { onUpdateGroupMethod(method) })
Text(
name,
style = MaterialTheme.typography.body1,
modifier = modifier
)
}
}
}
}
}
@Composable
@Preview
fun AccountDetailsPage_Content_Preview() {
AccountDetailsPage_Content(
suggestedAccountNames = listOf("name1", "name2@example.com"),
accountName = "account@example.com",
groupMethod = GroupMethod.GROUP_VCARDS,
groupMethodReadOnly = false
)
}
@Composable
@Preview
fun AccountDetailsPage_Content_Preview_With_Apostrophe() {
AccountDetailsPage_Content(
suggestedAccountNames = listOf("name1", "name2@example.com"),
accountName = "account'example.com",
groupMethod = GroupMethod.CATEGORIES,
groupMethodReadOnly = true
)
}

View file

@ -1,231 +0,0 @@
/***************************************************************************************************
* 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.MailTo
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.security.KeyChain
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.LoginCredentialsFragmentBinding
import at.bitfire.davdroid.db.Credentials
import com.google.android.material.snackbar.Snackbar
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import java.net.URI
import java.net.URISyntaxException
import javax.inject.Inject
class DefaultLoginCredentialsFragment : Fragment() {
val loginModel by activityViewModels<LoginModel>()
val model by viewModels<DefaultLoginCredentialsModel>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = LoginCredentialsFragmentBinding.inflate(inflater, container, false)
v.lifecycleOwner = viewLifecycleOwner
v.model = model
// initialize model on first call
if (savedInstanceState == null)
activity?.intent?.let { model.initialize(it) }
v.loginUrlBaseUrlEdittext.setAdapter(DefaultLoginCredentialsModel.LoginUrlAdapter(requireActivity()))
v.selectCertificate.setOnClickListener {
KeyChain.choosePrivateKeyAlias(requireActivity(), { alias ->
Handler(Looper.getMainLooper()).post {
// Show a Snackbar to add a certificate if no certificate was found
// API Versions < 29 still handle this automatically
if (alias == null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
Snackbar.make(v.root, R.string.login_no_certificate_found, Snackbar.LENGTH_LONG)
.setAction(R.string.login_install_certificate) {
startActivity(KeyChain.createInstallIntent())
}
.show()
}
else
model.certificateAlias.value = alias
}
}, null, null, null, -1, model.certificateAlias.value)
}
v.login.setOnClickListener { _ ->
if (validate()) {
val nextFragment =
when {
model.loginGoogle.value == true -> GoogleLoginFragment()
model.loginNextcloud.value == true -> NextcloudLoginFlowFragment()
else -> DetectConfigurationFragment()
}
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, nextFragment, null)
.addToBackStack(null)
.commit()
}
}
return v.root
}
private fun validate(): Boolean {
var valid = false
fun validateUrl() {
model.baseUrlError.value = null
try {
val originalUrl = model.baseUrl.value.orEmpty()
val uri = URI(originalUrl)
if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) {
// http:// or https:// scheme → OK
valid = true
loginModel.baseURI = uri
} else if (uri.scheme == null) {
// empty URL scheme, assume https://
model.baseUrl.value = "https://$originalUrl"
validateUrl()
} else
model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https)
} catch (e: Exception) {
model.baseUrlError.value = e.localizedMessage
}
}
fun validatePassword(): String? {
model.passwordError.value = null
val password = model.password.value
if (password.isNullOrEmpty()) {
valid = false
model.passwordError.value = getString(R.string.login_password_required)
}
return password
}
when {
model.loginWithEmailAddress.value == true -> {
// login with email address
model.usernameError.value = null
val email = model.username.value.orEmpty()
if (email.matches(Regex(".+@.+"))) {
// already looks like an email address
try {
loginModel.baseURI = URI(MailTo.MAILTO_SCHEME, email, null)
valid = true
} catch (e: URISyntaxException) {
model.usernameError.value = e.localizedMessage
}
} else {
valid = false
model.usernameError.value = getString(R.string.login_email_address_error)
}
val password = validatePassword()
if (valid)
loginModel.credentials = Credentials(email, password, null)
}
model.loginWithUrlAndUsername.value == true -> {
validateUrl()
model.usernameError.value = null
val username = model.username.value
if (username.isNullOrEmpty()) {
valid = false
model.usernameError.value = getString(R.string.login_user_name_required)
}
val password = validatePassword()
if (valid)
loginModel.credentials = Credentials(username, password, null)
}
model.loginAdvanced.value == true -> {
validateUrl()
model.certificateAliasError.value = null
val alias = model.certificateAlias.value
if (model.loginUseClientCertificate.value == true && alias.isNullOrBlank()) {
valid = false
model.certificateAliasError.value = "" // error icon without text
}
model.usernameError.value = null
val username = model.username.value
model.passwordError.value = null
val password = model.password.value
if (model.loginUseUsernamePassword.value == true) {
if (username.isNullOrEmpty()) {
valid = false
model.usernameError.value = getString(R.string.login_user_name_required)
}
validatePassword()
}
// loginModel.credentials stays null if login is tried with Base URL only
if (valid)
loginModel.credentials = when {
// username/password and client certificate
model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == true ->
Credentials(username, password, alias)
// user/name password only
model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == false ->
Credentials(username, password)
// client certificate only
model.loginUseUsernamePassword.value == false && model.loginUseClientCertificate.value == true ->
Credentials(certificateAlias = alias)
// anonymous (neither username/password nor client certificate)
else ->
null
}
}
// some login methods don't require further input → always valid
model.loginGoogle.value == true || model.loginNextcloud.value == true -> {
valid = true
}
}
return valid
}
class Factory @Inject constructor() : LoginFragmentFactory {
override fun getFragment(intent: Intent) = DefaultLoginCredentialsFragment()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DefaultLoginCredentialsFragmentModule {
@Binds
@IntoMap
@IntKey(/* priority */ 10)
abstract fun factory(impl: Factory): LoginFragmentFactory
}
}

View file

@ -1,170 +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.Context
import android.content.Intent
import android.net.Uri
import android.text.Editable
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.RadioGroup
import androidx.annotation.MainThread
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.R
import java.io.InputStreamReader
import java.util.regex.Pattern
class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) {
private var initialized = false
val loginWithEmailAddress = MutableLiveData(true)
val loginWithUrlAndUsername = MutableLiveData(false)
val loginAdvanced = MutableLiveData(false)
val loginGoogle = MutableLiveData(false)
val loginNextcloud = MutableLiveData(false)
val baseUrl = MutableLiveData<String>()
val baseUrlError = MutableLiveData<String>()
/** user name or email address */
val username = MutableLiveData<String>()
val usernameError = MutableLiveData<String>()
val password = MutableLiveData<String>()
val passwordError = MutableLiveData<String>()
val certificateAlias = MutableLiveData<String>()
val certificateAliasError = MutableLiveData<String>()
val loginUseUsernamePassword = MutableLiveData(false)
val loginUseClientCertificate = MutableLiveData(false)
fun clearUrlError(s: Editable) {
if (s.toString() != "https://") {
baseUrlError.value = null
}
}
fun clearUsernameError(s: Editable) {
usernameError.value = null
}
fun clearPasswordError(s: Editable) {
passwordError.value = null
}
fun clearErrors(group: RadioGroup, checkedId: Int) {
usernameError.value = null
passwordError.value = null
baseUrlError.value = null
}
@MainThread
fun initialize(intent: Intent) {
if (initialized)
return
initialized = true
var givenUrl: String? = null
var givenUsername: String? = null
var givenPassword: String? = null
intent.data?.normalizeScheme()?.let { uri ->
// We've got initial login data from the Intent.
// We can't use uri.buildUpon() because this keeps the user info (it's readable, but not writable).
val realScheme = when (uri.scheme) {
"caldav", "carddav" -> "http"
"caldavs", "carddavs", "davx5" -> "https"
"http", "https" -> uri.scheme
else -> null
}
if (realScheme != null) {
val realUri = Uri.Builder()
.scheme(realScheme)
.authority(uri.host)
.path(uri.path)
.query(uri.query)
givenUrl = realUri.build().toString()
// extract user info
uri.userInfo?.split(':')?.let { userInfo ->
givenUsername = userInfo.getOrNull(0)
givenPassword = userInfo.getOrNull(1)
}
}
}
// no login data from the Intent, let's look up the extras
givenUrl ?: intent.getStringExtra(LoginActivity.EXTRA_URL)
// always prefer username/password from the extras
if (intent.hasExtra(LoginActivity.EXTRA_USERNAME))
givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME)
if (intent.hasExtra(LoginActivity.EXTRA_PASSWORD))
givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD)
if (givenUrl != null) {
loginWithUrlAndUsername.value = true
baseUrl.value = givenUrl
} else
loginWithEmailAddress.value = true
username.value = givenUsername
password.value = givenPassword
}
class LoginUrlAdapter(context: Context): ArrayAdapter<String>(context, R.layout.text_list_item, android.R.id.text1) {
/**
* list of known host names/domains (without https://), like "example.com" or "carddav.example.com"
*/
val knownUrls = mutableListOf<String>()
init {
InputStreamReader(context.assets.open("known-base-urls.txt")).use { reader ->
knownUrls.addAll(reader.readLines())
}
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val v = super.getView(position, convertView, parent)
v.findViewById<View>(android.R.id.text2).visibility = View.GONE
return v
}
override fun getFilter(): Filter = object: Filter() {
override fun performFiltering(constraint: CharSequence): FilterResults {
val str = constraint.removePrefix("https://").toString()
val results = if (str.isEmpty())
knownUrls
else {
val regex = Pattern.compile("(\\.|\\b)" + Pattern.quote(str))
knownUrls.filter { url ->
regex.matcher(url).find()
}.map { url -> "https://$url" }
}
return FilterResults().apply {
values = results
count = results.size
}
}
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
clear()
(results.values as List<String>?)?.let { suggestions ->
addAll(suggestions)
}
}
}
}
}

View file

@ -1,192 +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.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.DebugInfoActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.runInterruptible
import java.net.URI
import java.util.logging.Level
class DetectConfigurationFragment: Fragment() {
private val loginModel by activityViewModels<LoginModel>()
private val model by viewModels<DetectConfigurationModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val baseURI = loginModel.baseURI ?: return
model.result.observe(this) { result ->
// save result for next step
loginModel.configuration = result
// remove "Detecting configuration" fragment, it shouldn't come back
parentFragmentManager.popBackStack()
if (result.calDAV != null || result.cardDAV != null)
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, AccountDetailsFragment())
.addToBackStack(null)
.commit()
else
parentFragmentManager.beginTransaction()
.add(NothingDetectedFragment(), null)
.commit()
}
model.start(baseURI, loginModel.credentials)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
ComposeView(requireContext()).apply {
setContent {
AppTheme {
DetectConfigurationView()
}
// Cancel service detection only when back button is pressed
BackHandler {
model.cancel()
parentFragmentManager.popBackStack()
}
}
}
class DetectConfigurationModel(application: Application): AndroidViewModel(application) {
val scope = MainScope() + CoroutineName("DetectConfigurationModel")
var result = MutableLiveData<DavResourceFinder.Configuration>()
/**
* Starts service detection in a global scope which is independent of the ViewModel scope so
* that service detection won't be cancelled when the user for instance switches to another app.
*
* The service detection result will be posted into [result].
*/
fun start(baseURI: URI, credentials: Credentials?) {
scope.launch(Dispatchers.Default) {
runInterruptible {
try {
DavResourceFinder(getApplication(), baseURI, credentials).use { finder ->
val configuration = finder.findInitialConfiguration()
result.postValue(configuration)
}
} catch(e: Exception) {
// This shouldn't happen; instead configuration should be empty
Logger.log.log(Level.WARNING, "Uncaught exception during service detection, shouldn't happen", e)
}
}
}
}
/**
* Cancels a potentially running service detection.
*/
fun cancel() {
Logger.log.info("Aborting resource detection")
try {
scope.cancel()
} catch (ignored: IllegalStateException) { }
}
}
class NothingDetectedFragment: DialogFragment() {
val model by activityViewModels<LoginModel>()
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
var message = getString(R.string.login_no_caldav_carddav)
if (model.configuration?.encountered401 == true)
message += "\n\n" + getString(R.string.login_username_password_wrong)
return MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.login_configuration_detection)
.setIcon(R.drawable.ic_error)
.setMessage(message)
.setNeutralButton(R.string.login_view_logs) { _, _ ->
val intent = DebugInfoActivity.IntentBuilder(requireActivity())
.withLogs(model.configuration?.logs)
.build()
startActivity(intent)
}
.setPositiveButton(android.R.string.ok) { _, _ ->
// just dismiss
}
.create()
}
}
}
@Composable
fun DetectConfigurationView() {
Column(Modifier
.fillMaxWidth()
.padding(8.dp)) {
Text(
stringResource(R.string.login_configuration_detection),
style = MaterialTheme.typography.h5
)
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp))
Text(
stringResource(R.string.login_querying_server),
style = MaterialTheme.typography.body1
)
}
}
@Composable
@Preview
fun DetectConfigurationView_Preview() {
DetectConfigurationView()
}

View file

@ -0,0 +1,190 @@
package at.bitfire.davdroid.ui.setup
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.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()
) {
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) }
}
Column(Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
if (result == null)
DetectResourcesPage_InProgress()
else if (foundNothing)
DetectResourcesPage_NothingFound(
encountered401 = result?.encountered401 ?: false,
logs = result?.logs ?: ""
)
}
}
@Composable
@Preview
fun DetectResourcesPage_InProgress() {
Column(Modifier.fillMaxWidth()) {
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_configuration_detection),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
stringResource(R.string.login_querying_server),
style = MaterialTheme.typography.body1
)
}
}
}
@Composable
fun DetectResourcesPage_NothingFound(
encountered401: Boolean,
logs: String
) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_configuration_detection),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(bottom = 16.dp)
)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.CloudOff, contentDescription = null, modifier = Modifier.padding(end = 8.dp))
Text(
stringResource(R.string.login_no_service),
style = MaterialTheme.typography.h6,
modifier = Modifier.weight(1f)
)
}
Text(
stringResource(R.string.login_no_service_info),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
val urlServices = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.withStatParams("DetectResourcesPage")
.build()
ClickableTextWithLink(
HtmlCompat.fromHtml(stringResource(R.string.login_see_tested_services, urlServices), HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString(),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(vertical = 8.dp)
)
if (encountered401)
Text(
stringResource(R.string.login_check_credentials),
modifier = Modifier.padding(vertical = 8.dp),
style = MaterialTheme.typography.body1
)
if (logs.isNotBlank()) {
Text(
stringResource(R.string.login_logs_available),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(vertical = 8.dp)
)
val context = LocalContext.current
TextButton(
onClick = {
val intent = DebugInfoActivity.IntentBuilder(context)
.withLogs(logs)
.build()
context.startActivity(intent)
}
) {
Text(stringResource(R.string.login_view_logs).uppercase())
}
}
}
}
}
}
@Composable
@Preview
fun DetectResourcesPage_NothingFound() {
DetectResourcesPage_NothingFound(
encountered401 = false,
logs = "SOME LOGS"
)
}
@Composable
@Preview
fun DetectResourcesPage_NothingFound_401() {
DetectResourcesPage_NothingFound(
encountered401 = true,
logs = ""
)
}

View file

@ -1,396 +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.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.ClickableText
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.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
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.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
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.AppTheme
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.setup.GoogleLoginFragment.Companion.URI_TESTED_WITH_GOOGLE
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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
@AndroidEntryPoint
class GoogleLoginFragment(private val defaultEmail: String? = null): Fragment() {
companion object {
// 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)
}
private val loginModel by activityViewModels<LoginModel>()
private val model by viewModels<Model>()
private val authRequestContract = registerForActivityResult(object: 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) }
}) { authResponse ->
if (authResponse != null)
model.authenticate(authResponse)
else
Snackbar.make(requireView(), R.string.login_oauth_couldnt_obtain_auth_code, Snackbar.LENGTH_LONG).show()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = ComposeView(requireActivity()).apply {
setContent {
GoogleLogin(defaultEmail = defaultEmail, onLogin = { accountEmail, clientId ->
loginModel.baseURI = googleBaseUri(accountEmail)
loginModel.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)
Snackbar.make(requireView(), getString(R.string.install_browser), Snackbar.LENGTH_LONG).show()
}
})
}
}
model.credentials.observe(viewLifecycleOwner) { credentials ->
if (credentials != null) {
// pass credentials to login model
loginModel.credentials = credentials
// continue with service detection
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, DetectConfigurationFragment(), null)
.addToBackStack(null)
.commit()
// reset because setting credentials LiveData represents a one-shot action
model.credentials.value = null
}
}
return view
}
@HiltViewModel
class Model @Inject constructor(
application: Application,
val authService: AuthorizationService
): AndroidViewModel(application) {
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))
}
}
}
override fun onCleared() {
authService.dispose()
}
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun GoogleLogin(
defaultEmail: String?,
onLogin: (accountEmail: String, clientId: String?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
AppTheme {
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 16.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(URI_TESTED_WITH_GOOGLE.toString())
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
val email = rememberSaveable { mutableStateOf(defaultEmail ?: "") }
val userClientId = rememberSaveable { mutableStateOf("") }
var emailError: String? by rememberSaveable { mutableStateOf(null) }
fun login() {
val userEmail: String? = StringUtils.trimToNull(email.value.trim())
val clientId: String? = StringUtils.trimToNull(userClientId.value.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)
}
OutlinedTextField(
email.value,
singleLine = true,
onValueChange = { emailError = null; email.value = it },
isError = emailError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = { login() }
),
label = { Text(emailError ?: stringResource(R.string.login_google_account)) },
placeholder = { Text("example@gmail.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
OutlinedTextField(
userClientId.value,
singleLine = true,
onValueChange = { clientId ->
userClientId.value = clientId
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = { 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()
ClickableText(
privacyPolicyNote,
style = MaterialTheme.typography.body2,
onClick = { position ->
privacyPolicyNote.getUrlAnnotations(position, position).firstOrNull()?.let {
uriHandler.openUri(it.item.url)
}
}
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GoogleLoginFragment.GOOGLE_POLICY_URL), 0).toAnnotatedString()
ClickableText(
limitedUseNote,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 12.dp),
onClick = { position ->
limitedUseNote.getUrlAnnotations(position, position).firstOrNull()?.let {
uriHandler.openUri(it.item.url)
}
}
)
}
}
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_withDefaultEmail() {
GoogleLogin("example@example.example") { _, _ -> }
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_empty() {
GoogleLogin("") { _, _ -> }
}

View file

@ -1,22 +1,14 @@
/***************************************************************************************************
* 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.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
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
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.ui.AppTheme
import dagger.hilt.android.AndroidEntryPoint
import java.net.URI
import javax.inject.Inject
/**
@ -24,7 +16,7 @@ import javax.inject.Inject
* Fields for server/user data can be pre-filled with extras in the Intent.
*/
@AndroidEntryPoint
class LoginActivity: AppCompatActivity() {
class LoginActivity @Inject constructor(): AppCompatActivity() {
companion object {
@ -45,52 +37,92 @@ class LoginActivity: AppCompatActivity() {
*/
const val EXTRA_PASSWORD = "password"
/**
* When set, Nextcloud Login Flow will be used.
*/
const val EXTRA_LOGIN_FLOW = "loginFlow"
fun loginInfoFromIntent(intent: Intent): LoginInfo {
var givenUri: String? = null
var givenUsername: String? = null
var givenPassword: String? = null
// extract URI and optionally username/password from Intent data
intent.data?.normalizeScheme()?.let { uri ->
// We've got initial login data from the Intent.
// We can't use uri.buildUpon() because this keeps the user info (it's readable, but not writable).
val realScheme = when (uri.scheme) {
"caldav", "carddav" -> "http"
"caldavs", "carddavs", "davx5" -> "https"
"http", "https" -> uri.scheme
else -> null
}
if (realScheme != null) {
val realUri = Uri.Builder()
.scheme(realScheme)
.authority(uri.host)
.path(uri.path)
.query(uri.query)
givenUri = realUri.build().toString()
// extract user info
uri.userInfo?.split(':')?.let { userInfo ->
givenUsername = userInfo.getOrNull(0)
givenPassword = userInfo.getOrNull(1)
}
}
}
if (givenUri == null)
givenUri = intent.getStringExtra(EXTRA_URL)
// always prefer username/password from the extras
if (intent.hasExtra(EXTRA_USERNAME))
givenUsername = intent.getStringExtra(EXTRA_USERNAME)
if (intent.hasExtra(EXTRA_PASSWORD))
givenPassword = intent.getStringExtra(EXTRA_PASSWORD)
return LoginInfo(
baseUri = try {
URI(givenUri)
} catch (_: Exception) {
null
},
credentials = Credentials(
username = givenUsername,
password = givenPassword
)
)
}
}
enum class Phase {
LOGIN_TYPE,
LOGIN_DETAILS,
DETECT_RESOURCES,
ACCOUNT_DETAILS
}
@Inject
lateinit var loginFragmentFactories: Map<Int, @JvmSuppressWildcards LoginFragmentFactory>
lateinit var loginTypesProvider: LoginTypesProvider
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
addMenuProvider(object: MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_login, menu)
setContent {
AppTheme {
LoginScreen(
loginTypesProvider = loginTypesProvider,
initialLoginInfo = loginInfoFromIntent(intent),
initialLoginType = loginTypesProvider.intentToInitialLoginType(intent),
onFinish = { finish() }
)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId == R.id.help) {
UiUtils.launchUri(this@LoginActivity,
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.withStatParams("LoginActivity")
.build())
return true
}
return false
}
})
if (savedInstanceState == null) {
// first call, add first login fragment
val factories = loginFragmentFactories // get factories from hilt
.toSortedMap() // sort by Int key
.values.reversed() // take reverse-sorted values (because high priority numbers shall be processed first)
var fragment: Fragment? = null
for (factory in factories) {
Logger.log.info("Login fragment factory: $factory")
fragment = fragment ?: factory.getFragment(intent)
}
if (fragment != null) {
supportFragmentManager.beginTransaction()
.replace(android.R.id.content, fragment)
.commit()
} else
Logger.log.severe("Couldn't create LoginFragment")
}
}
}
}

View file

@ -1,14 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import androidx.fragment.app.Fragment
interface LoginFragmentFactory {
fun getFragment(intent: Intent): Fragment?
}

View file

@ -0,0 +1,15 @@
package at.bitfire.davdroid.ui.setup
import at.bitfire.davdroid.db.Credentials
import at.bitfire.vcard4android.GroupMethod
import java.net.URI
data class LoginInfo(
val baseUri: URI? = null,
val credentials: Credentials? = null,
val suggestedAccountName: String? = null,
/** group method that should be pre-selected */
val suggestedGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS
)

View file

@ -1,26 +1,196 @@
/***************************************************************************************************
* 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 android.content.ContentResolver
import android.provider.CalendarContract
import androidx.core.os.CancellationSignal
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder
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 java.net.URI
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import java.util.logging.Level
import javax.inject.Inject
class LoginModel: ViewModel() {
@HiltViewModel
class LoginModel @Inject constructor(
val context: Application,
val db: AppDatabase,
val settingsManager: SettingsManager
): ViewModel() {
var baseURI: URI? = null
var credentials: Credentials? = null
val forcedGroupMethod = settingsManager.getStringLive(AccountSettings.KEY_CONTACT_GROUP_METHOD).map { methodName ->
methodName?.let {
try {
GroupMethod.valueOf(it)
} catch (e: IllegalArgumentException) {
null
}
}
}
var configuration: DavResourceFinder.Configuration? = null
/** account name that should be used as default account name when no email addresses have been found */
var suggestedAccountName: String? = null
val foundConfig = MutableLiveData<DavResourceFinder.Configuration>()
/** group method that should be pre-selectedbr */
var suggestedGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS
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()
}
}
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)
*/
fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Boolean {
val account = Account(name, 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
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
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)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(name, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
return false
}
return true
}
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = db.serviceDao().insertOrReplace(service)
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrUpdateByUrl(collection)
}
return serviceId
}
}

View file

@ -0,0 +1,156 @@
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import android.net.Uri
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.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 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
@Composable
fun LoginScreen(
loginTypesProvider: LoginTypesProvider,
initialLoginInfo: LoginInfo = LoginInfo(),
initialLoginType: LoginType,
onFinish: () -> Unit = {}
) {
val uriHandler = LocalUriHandler.current
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) }
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
}
if (helpUri != null)
IconButton(onClick = {
// show tested-with page
uriHandler.openUri(helpUri.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 },
onContinue = {
phase = LoginActivity.Phase.LOGIN_DETAILS
}
)
LoginActivity.Phase.LOGIN_DETAILS -> {
BackHandler {
phase = LoginActivity.Phase.LOGIN_TYPE
}
selectedLoginType.Content(
snackbarHostState = snackbarHostState,
loginInfo = loginInfo,
onUpdateLoginInfo = { loginInfo = it },
onDetectResources = {
phase = LoginActivity.Phase.DETECT_RESOURCES
}
)
}
LoginActivity.Phase.DETECT_RESOURCES -> {
BackHandler(
onBack = { phase = LoginActivity.Phase.LOGIN_TYPE }
)
DetectResourcesPage(
loginInfo = loginInfo,
onSuccess = {
foundConfig = it
phase = LoginActivity.Phase.ACCOUNT_DETAILS
}
)
}
LoginActivity.Phase.ACCOUNT_DETAILS ->
foundConfig?.let {
val context = LocalContext.current
AccountDetailsPage(
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)
}
)
}
}
}
}
}

View file

@ -0,0 +1,22 @@
package at.bitfire.davdroid.ui.setup
import android.net.Uri
import androidx.compose.material.SnackbarHostState
import androidx.compose.runtime.Composable
interface LoginType {
val title: Int
/** Optional URL to a provider-specific help page. */
val helpUrl: Uri?
@Composable
fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit,
)
}

View file

@ -0,0 +1,268 @@
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
) {
LoginTypeAdvanced_Content(
snackbarHostState = snackbarHostState,
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
)
}
}
@Composable
fun LoginTypeAdvanced_Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit = {},
onLogin: () -> Unit = {}
) {
var baseUrl by remember { mutableStateOf(
loginInfo.baseUri?.takeIf {
it.scheme.equals("http", ignoreCase = true) ||
it.scheme.equals("https", ignoreCase = true)
}?.toString() ?: ""
) }
var username by remember { mutableStateOf(loginInfo.credentials?.username ?: "") }
var password by remember { mutableStateOf(loginInfo.credentials?.password ?: "") }
var certificateAlias by remember { mutableStateOf(loginInfo.credentials?.certificateAlias) }
val newLoginInfo = LoginInfo(
baseUri = try {
URI(
if (baseUrl.startsWith("http://", ignoreCase = true) || baseUrl.startsWith("https://", ignoreCase = true))
baseUrl
else
"https://$baseUrl"
)
} catch (_: Exception) {
null
},
credentials = Credentials(
username = StringUtils.trimToNull(username),
password = StringUtils.trimToNull(password)
)
)
onUpdateLoginInfo(newLoginInfo)
val ok =
newLoginInfo.baseUri != null && (
newLoginInfo.baseUri.scheme.equals("http", ignoreCase = true) ||
newLoginInfo.baseUri.scheme.equals("https",ignoreCase = true)
)
val focusRequester = remember { FocusRequester() }
Assistant(
nextLabel = stringResource(R.string.login_login),
nextEnabled = ok,
onNext = onLogin
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_advanced),
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
OutlinedTextField(
value = baseUrl,
onValueChange = { baseUrl = it },
label = { Text(stringResource(R.string.login_base_url)) },
placeholder = { Text("dav.example.com/path") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Folder, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
val manualUrl = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_ACCOUNTS_COLLECTIONS)
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
urlInfo.toAnnotatedString(),
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.login_user_name_optional)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.AccountCircle, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
PasswordTextField(
password = password,
onPasswordChange = { password = it },
labelText = stringResource(R.string.login_password_optional),
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
certificateAlias?.let { alias ->
stringResource(R.string.login_client_certificate_selected, alias)
} ?: stringResource(R.string.login_no_client_certificate_optional),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(8.dp)
)
val activity = LocalContext.current as Activity
val scope = rememberCoroutineScope()
TextButton(
onClick = {
KeyChain.choosePrivateKeyAlias(activity, { alias ->
if (alias != null)
certificateAlias = alias
else {
// Show a Snackbar to add a certificate if no certificate was found
// API Versions < 29 does that itself
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q)
scope.launch {
if (snackbarHostState.showSnackbar(
message = activity.getString(R.string.login_no_certificate_found),
actionLabel = activity.getString(R.string.login_install_certificate).uppercase()
) == SnackbarResult.ActionPerformed)
activity.startActivity(KeyChain.createInstallIntent())
}
}
}, null, null, null, -1, certificateAlias)
}
) {
Text(stringResource(R.string.login_select_certificate).uppercase())
}
}
}
Text(
stringResource(R.string.optional_label),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 16.dp)
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
@Preview
fun LoginTypeAdvancedPreview_Empty() {
LoginTypeAdvanced_Content(
snackbarHostState = SnackbarHostState(),
loginInfo = LoginInfo()
)
}
@Composable
@Preview
fun LoginTypeAdvancedPreview_AllFilled() {
LoginTypeAdvanced_Content(
snackbarHostState = SnackbarHostState(),
loginInfo = LoginInfo(
baseUri = URI("https://some-dav.example.com"),
credentials = Credentials(
username = "some-user",
password = "password",
certificateAlias = "some-alias"
)
)
)
}

View file

@ -0,0 +1,160 @@
package at.bitfire.davdroid.ui.setup
import android.net.Uri
import androidx.compose.foundation.layout.Column
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.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
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.net.MailTo
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 java.net.URI
object LoginTypeEmail : LoginType {
override val title: Int
get() = R.string.login_type_email
override val helpUrl: Uri?
get() = null
@Composable
override fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit
) {
LoginTypeEmail_Content(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
)
}
}
@Composable
fun LoginTypeEmail_Content(
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit = {},
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()
}
) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_email),
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(R.string.login_email_address)) },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Email, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
val manualUrl = Constants.MANUAL_URL.buildUpon()
.appendPath(Constants.MANUAL_PATH_ACCOUNTS_COLLECTIONS)
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
.build()
val emailInfo = HtmlCompat.fromHtml(stringResource(R.string.login_email_address_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
ClickableTextWithLink(
emailInfo.toAnnotatedString(),
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 16.dp)
)
PasswordTextField(
password = password,
onPasswordChange = { password = it },
labelText = stringResource(R.string.login_password),
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
if (ok)
onLogin()
}),
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
@Preview
fun LoginTypeEmail_Content_Preview() {
LoginTypeEmail_Content(LoginInfo())
}

View file

@ -0,0 +1,413 @@
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,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val model: Model = viewModel()
val authRequestContract = rememberLauncherForActivityResult(contract = AuthorizationContract(model)) { authResponse ->
if (authResponse != null)
model.authenticate(authResponse)
else
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.login_oauth_couldnt_obtain_auth_code))
}
}
model.credentials.observeAsState().value?.let { credentials ->
onUpdateLoginInfo(loginInfo.copy(credentials = credentials))
onDetectResources()
}
GoogleLoginScreen(
defaultEmail = loginInfo.credentials?.username ?: model.findGoogleAccount(),
onLogin = { accountEmail, clientId ->
onUpdateLoginInfo(
LoginInfo(
baseUri = googleBaseUri(accountEmail),
suggestedAccountName = accountEmail
)
)
val authRequest = authRequestBuilder(clientId)
.setScopes(*SCOPES)
.setLoginHint(accountEmail)
.setUiLocales(Locale.current.toLanguageTag())
.build()
try {
authRequestContract.launch(authRequest)
} catch (e: ActivityNotFoundException) {
Logger.log.log(Level.WARNING, "Couldn't start OAuth intent", e)
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.install_browser))
}
}
}
)
}
class AuthorizationContract(val model: Model) : ActivityResultContract<AuthorizationRequest, AuthorizationResponse?>() {
override fun createIntent(context: Context, input: AuthorizationRequest) =
model.authService.getAuthorizationRequestIntent(input)
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? =
intent?.let { AuthorizationResponse.fromIntent(it) }
}
@HiltViewModel
class Model @Inject constructor(
val context: Application,
val authService: AuthorizationService
) : ViewModel() {
val credentials = MutableLiveData<Credentials>()
fun authenticate(resp: AuthorizationResponse) = viewModelScope.launch(Dispatchers.IO) {
val authState = AuthState(resp, null) // authorization code must not be stored; exchange it to refresh token
authService.performTokenRequest(resp.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
Logger.log.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success
authState.update(tokenResponse, refreshTokenException)
// save authState (= refresh token)
credentials.postValue(Credentials(authState = authState))
}
}
}
fun findGoogleAccount(): String? {
val accountManager = AccountManager.get(context)
return accountManager
.getAccountsByType("com.google")
.map { it.name }
.firstOrNull()
}
override fun onCleared() {
authService.dispose()
}
}
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun GoogleLoginScreen(
defaultEmail: String?,
onLogin: (accountEmail: String, clientId: String?) -> Unit
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 8.dp)
)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Row {
Text(
stringResource(R.string.login_google_see_tested_with),
style = MaterialTheme.typography.body2,
)
}
Text(
stringResource(R.string.login_google_unexpected_warnings),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = {
uriHandler.openUri(LoginTypeGoogle.URI_TESTED_WITH_GOOGLE.toString())
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
var email by rememberSaveable { mutableStateOf(defaultEmail ?: "") }
var userClientId by rememberSaveable { mutableStateOf("") }
var emailError: String? by rememberSaveable { mutableStateOf(null) }
fun login() {
val userEmail: String? = StringUtils.trimToNull(email.trim())
val clientId: String? = StringUtils.trimToNull(userClientId.trim())
if (userEmail.isNullOrBlank()) {
emailError = context.getString(R.string.login_email_address_error)
return
}
// append @gmail.com, if necessary
val loginEmail =
if (userEmail.contains('@'))
userEmail
else
"$userEmail@gmail.com"
onLogin(loginEmail, clientId)
}
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
email,
singleLine = true,
onValueChange = { emailError = null; email = it },
leadingIcon = {
Icon(Icons.Default.Email, null)
},
isError = emailError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
label = { Text(emailError ?: stringResource(R.string.login_google_account)) },
placeholder = { Text("example@gmail.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
if (email.isEmpty())
focusRequester.requestFocus()
}
OutlinedTextField(
userClientId,
singleLine = true,
onValueChange = { clientId ->
userClientId = clientId
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { login() }
),
label = { Text(stringResource(R.string.login_google_client_id)) },
placeholder = { Text("[...].apps.googleusercontent.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
Button(
onClick = { login() },
modifier = Modifier
.padding(top = 8.dp)
.wrapContentSize(),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.surface
)
) {
Image(
painter = painterResource(R.drawable.google_g_logo),
contentDescription = stringResource(R.string.login_google),
modifier = Modifier.size(18.dp)
)
Text(
text = stringResource(R.string.login_google),
modifier = Modifier.padding(start = 12.dp)
)
}
Spacer(Modifier.padding(8.dp))
val privacyPolicyUrl = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_PRIVACY)
.withStatParams("GoogleLoginFragment")
.build()
val privacyPolicyNote = HtmlCompat.fromHtml(
stringResource(
R.string.login_google_client_privacy_policy,
context.getString(R.string.app_name),
privacyPolicyUrl.toString()
), 0
).toAnnotatedString()
ClickableTextWithLink(
privacyPolicyNote,
style = MaterialTheme.typography.body2
)
val limitedUseNote = HtmlCompat.fromHtml(
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
).toAnnotatedString()
ClickableTextWithLink(
limitedUseNote,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_withDefaultEmail() {
GoogleLoginScreen("example@example.example") { _, _ -> }
}
@Composable
@Preview(showBackground = true)
fun PreviewGoogleLogin_empty() {
GoogleLoginScreen("") { _, _ -> }
}

View file

@ -0,0 +1,442 @@
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.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
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.MutableLiveData
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.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 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,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit
) {
val context = LocalContext.current
val locale = Locale.current
val scope = rememberCoroutineScope()
val model = viewModel<Model>()
val checkResultCallback = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
model.checkResult()
}
val loginUrl by model.loginUrl.observeAsState()
LaunchedEffect(loginUrl) {
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))
}
}
}
}
val resultLoginInfo by model.loginInfo.observeAsState()
LaunchedEffect(resultLoginInfo) {
resultLoginInfo?.let {
onUpdateLoginInfo(it)
onDetectResources()
}
}
NextcloudLoginScreen(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
inProgress = model.inProgress.observeAsState(false).value,
error = model.error.observeAsState().value,
onLaunchLoginFlow = { entryUrl ->
model.start(entryUrl)
}
)
}
/**
* 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"
}
val loginUrl = MutableLiveData<String>()
val error = MutableLiveData<String>()
private val httpClient = HttpClient.Builder(context)
.setForeground(true)
.build()
val inProgress = MutableLiveData(false)
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
}
val loginInfo = MutableLiveData<LoginInfo>()
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 start(entryUrl: HttpUrl) {
inProgress.value = true
error.value = 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
viewModelScope.launch(Dispatchers.IO) {
try {
val json = postForJson(v2Url, "".toRequestBody())
// login URL
loginUrl.postValue(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.postValue(context.getString(R.string.login_nextcloud_login_flow_no_login_url))
} finally {
inProgress.postValue(false)
}
}
}
/**
* Called when the custom tab / browser activity is finished. If memory is low, our
* [NextcloudLoginFlowFragment] 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 checkResult() {
val pollUrl = pollUrl ?: return
val token = token ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
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.postValue(LoginInfo(
baseUri = baseUri,
credentials = Credentials(loginName, appPassword),
suggestedGroupMethod = GroupMethod.CATEGORIES
))
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Polling login URL failed", e)
error.postValue(context.getString(R.string.login_nextcloud_login_flow_no_login_data))
}
}
}
@WorkerThread
private fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = 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 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
) {
var entryUrl by remember { mutableStateOf(loginInfo.baseUri?.toString() ?: "") }
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) {
focusRequester.requestFocus()
}
if (error != null)
Card(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp)
)
Text(
error,
style = MaterialTheme.typography.body1
)
}
}
}
}
}
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview() {
NextcloudLoginScreen(
loginInfo = LoginInfo(),
onUpdateLoginInfo = {},
inProgress = false,
onLaunchLoginFlow = {}
)
}
@Composable
@Preview
fun NextcloudLoginScreen_Preview_InProgressError() {
NextcloudLoginScreen(
loginInfo = LoginInfo(),
onUpdateLoginInfo = {},
inProgress = true,
error = "Some Error",
onLaunchLoginFlow = {}
)
}

View file

@ -0,0 +1,197 @@
package at.bitfire.davdroid.ui.setup
import android.net.Uri
import androidx.compose.foundation.layout.Column
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.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
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 java.net.URI
object LoginTypeUrl : LoginType {
override val title
get() = R.string.login_type_url
override val helpUrl: Uri?
get() = null
@Composable
override fun Content(
snackbarHostState: SnackbarHostState,
loginInfo: LoginInfo,
onUpdateLoginInfo: (newLoginInfo: LoginInfo) -> Unit,
onDetectResources: () -> Unit
) {
LoginTypeUrl_Content(
loginInfo = loginInfo,
onUpdateLoginInfo = onUpdateLoginInfo,
onLogin = onDetectResources
)
}
}
@Composable
fun LoginTypeUrl_Content(
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 ?: "") }
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,
onNext = onLogin
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_type_url),
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)) },
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),
leadingIcon = {
Icon(Icons.Default.Password, null)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
if (ok)
onLogin()
}),
modifier = Modifier.fillMaxWidth()
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
@Composable
@Preview
fun LoginTypeUrl_Content_Preview() {
LoginTypeUrl_Content(LoginInfo())
}

View file

@ -0,0 +1,19 @@
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import androidx.compose.runtime.Composable
interface LoginTypesProvider {
val defaultLoginType: LoginType
fun intentToInitialLoginType(intent: Intent): LoginType
@Composable
fun LoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
onContinue: () -> Unit
)
}

View file

@ -1,479 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.setup
import android.annotation.SuppressLint
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.provider.Browser
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
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.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
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.AppTheme
import at.bitfire.davdroid.ui.UiUtils.haveCustomTabs
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntKey
import dagger.multibindings.IntoMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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
@AndroidEntryPoint
class NextcloudLoginFlowFragment: Fragment() {
companion object {
const val LOGIN_FLOW_V1_PATH = "index.php/login/flow"
const val LOGIN_FLOW_V2_PATH = "index.php/login/v2"
/** Set this to 1 to indicate that Login Flow shall be used. */
const val EXTRA_LOGIN_FLOW = "loginFlow"
/** 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 EXTRA_DAV_PATH = "davPath"
const val DAV_PATH_DEFAULT = "/remote.php/dav"
const val ARG_BASE_URL = "baseUrl"
fun newInstance(baseUrl: String? = null): NextcloudLoginFlowFragment =
NextcloudLoginFlowFragment().apply {
arguments = Bundle(1).apply {
putString(ARG_BASE_URL, baseUrl)
}
}
}
private val loginModel by activityViewModels<LoginModel>()
private val model by viewModels<Model>()
private val checkResultCallback = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val davPath = requireActivity().intent.getStringExtra(EXTRA_DAV_PATH) ?: DAV_PATH_DEFAULT
model.checkResult(davPath)
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val baseUrl = arguments?.getString(ARG_BASE_URL)
val entryUrl = (requireActivity().intent.data?.toString() ?: baseUrl)?.toHttpUrlOrNull()
val view = ComposeView(requireActivity()).apply {
setContent {
AppTheme {
NextcloudLoginComposable(
onStart = { url ->
model.start(url)
},
entryUrl = entryUrl,
inProgress = model.inProgress.observeAsState(false).value,
error = model.error.observeAsState().value
)
}
}
}
model.loginUrl.observe(viewLifecycleOwner) { loginUrl ->
if (loginUrl == null)
return@observe
val loginUri = loginUrl.toUri()
// reset URL so that the browser isn't shown another time
model.loginUrl.value = null
if (haveCustomTabs(requireActivity())) {
// Custom Tabs are available
@Suppress("DEPRECATION")
val browser = CustomTabsIntent.Builder()
.setToolbarColor(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(requireActivity().packageManager) != null)
checkResultCallback.launch(browser)
else
Snackbar.make(view, getString(R.string.install_browser), Snackbar.LENGTH_INDEFINITE).show()
}
}
model.loginData.observe(viewLifecycleOwner) { loginData ->
if (loginData == null)
return@observe
val (baseUri, credentials) = loginData
// continue to next fragment
loginModel.baseURI = baseUri
loginModel.credentials = credentials
loginModel.suggestedGroupMethod = GroupMethod.CATEGORIES
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, DetectConfigurationFragment(), null)
.addToBackStack(null)
.commit()
// reset loginData so that we can go back
model.loginData.value = null
}
if (savedInstanceState == null && entryUrl != null)
model.start(entryUrl)
return view
}
/**
* Implements Login Flow v2.
*
* @see https://docs.nextcloud.com/server/20/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
*/
class Model(
app: Application,
val state: SavedStateHandle
): AndroidViewModel(app) {
companion object {
const val STATE_POLL_URL = "poll_url"
const val STATE_TOKEN = "token"
}
val loginUrl = MutableLiveData<String>()
val error = MutableLiveData<String>()
val httpClient by lazy {
HttpClient.Builder(getApplication())
.setForeground(true)
.build()
}
val inProgress = MutableLiveData(false)
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
}
val loginData = MutableLiveData<Pair<URI, Credentials>>()
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 start(entryUrl: HttpUrl) {
inProgress.value = true
error.value = 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
viewModelScope.launch(Dispatchers.IO) {
try {
val json = postForJson(v2Url, "".toRequestBody())
// login URL
loginUrl.postValue(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.postValue(getApplication<Application>().getString(R.string.login_nextcloud_login_flow_no_login_url))
} finally {
inProgress.postValue(false)
}
}
}
/**
* Called when the custom tab / browser activity is finished. If memory is low, our
* [NextcloudLoginFlowFragment] 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 checkResult(davPath: String?) {
val pollUrl = pollUrl ?: return
val token = token ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
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 = if (davPath != null)
URI.create(serverUrl + davPath)
else
URI.create(serverUrl)
loginData.postValue(Pair(
baseUri,
Credentials(loginName, appPassword)
))
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Polling login URL failed", e)
error.postValue(getApplication<Application>().getString(R.string.login_nextcloud_login_flow_no_login_data))
}
}
}
@WorkerThread
private fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = 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 JSONObject(body.string())
}
throw DavException("Invalid Login Flow response (no body)")
}
}
class Factory @Inject constructor(): LoginFragmentFactory {
override fun getFragment(intent: Intent) =
if (intent.hasExtra(EXTRA_LOGIN_FLOW) && intent.data != null)
NextcloudLoginFlowFragment()
else
null
}
@Module
@InstallIn(SingletonComponent::class)
abstract class NextcloudLoginFlowFragmentModule {
@Binds
@IntoMap
@IntKey(/* priority */ 20)
abstract fun factory(impl: Factory): LoginFragmentFactory
}
}
@Composable
fun NextcloudLoginComposable(
entryUrl: HttpUrl?,
inProgress: Boolean,
error: String?,
onStart: (HttpUrl) -> Unit
) {
Column {
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
)
NextcloudLoginFlowComposable(
providedEntryUrl = entryUrl,
inProgress = inProgress,
error = error,
onStart = onStart
)
}
}
}
@Composable
fun NextcloudLoginFlowComposable(
providedEntryUrl: HttpUrl?,
inProgress: Boolean,
error: String?,
onStart: (HttpUrl) -> Unit = {}
) {
Column {
Text(
stringResource(R.string.login_nextcloud_login_flow),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp)
)
Text(
stringResource(R.string.login_nextcloud_login_flow_text),
modifier = Modifier.padding(vertical = 8.dp)
)
var entryUrlStr by remember { mutableStateOf(providedEntryUrl?.toString() ?: "") }
var entryUrl by remember { mutableStateOf(providedEntryUrl) }
OutlinedTextField(
value = entryUrlStr,
onValueChange = { newUrlStr ->
entryUrlStr = newUrlStr
entryUrl = try {
val withScheme =
if (!newUrlStr.startsWith("http://", true) && !newUrlStr.startsWith("https://", true))
"https://$newUrlStr"
else
newUrlStr
withScheme.toHttpUrl()
} catch (e: IllegalArgumentException) {
null
}
},
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
enabled = !inProgress,
label = {
Text(stringResource(R.string.login_nextcloud_login_flow_server_address))
},
placeholder = {
Text("cloud.example.com")
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = {
entryUrl?.let(onStart)
}
),
singleLine = true
)
Button(
onClick = {
entryUrl?.let(onStart)
},
enabled = entryUrl != null && !inProgress
) {
Text(stringResource(R.string.login_nextcloud_login_flow_sign_in))
}
if (error != null)
Text(
error,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
@Composable
@Preview
fun NextcloudLoginFlowComposable_PreviewWithError() {
NextcloudLoginFlowComposable(
providedEntryUrl = null,
inProgress = true,
error = "Something wrong happened"
)
}

View file

@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.FloatingActionButton
@ -68,6 +67,7 @@ import at.bitfire.davdroid.db.WebDavMount
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.webdav.CredentialsStore
import at.bitfire.davdroid.webdav.DavDocumentsProvider
@ -206,15 +206,10 @@ class WebdavMountsActivity: AppCompatActivity() {
),
0
).toAnnotatedString()
ClickableText(
ClickableTextWithLink(
text = text,
style = MaterialTheme.typography.body1,
modifier = Modifier.fillMaxWidth(),
onClick = { position ->
text.getUrlAnnotations(position, position + 1)
.firstOrNull()
?.let { uriHandler.openUri(it.item.url) }
}
modifier = Modifier.fillMaxWidth()
)
}
}

View file

@ -0,0 +1,30 @@
package at.bitfire.davdroid.ui.widget
import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.TextStyle
@OptIn(ExperimentalTextApi::class)
@Composable
fun ClickableTextWithLink(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default
) {
val uriHandler = LocalUriHandler.current
ClickableText(
text = text,
style = style,
modifier = modifier
) { index ->
// Get the tapped position, and check if there's any link
text.getUrlAnnotations(index, index).firstOrNull()?.item?.url?.let { url ->
uriHandler.openUri(url)
}
}
}

View file

@ -51,7 +51,7 @@ class CredentialsStore(context: Context) {
val edit = prefs.edit()
if (credentials != null)
edit.putBoolean(keyName(mountId, HAS_CREDENTIALS), true)
.putString(keyName(mountId, USER_NAME), credentials.userName)
.putString(keyName(mountId, USER_NAME), credentials.username)
.putString(keyName(mountId, PASSWORD), credentials.password)
.putString(keyName(mountId, CERTIFICATE_ALIAS), credentials.certificateAlias)
else

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/activity_margin">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.AppCompat.Medium"
android:text="@string/create_collection_creating"/>
<ProgressBar
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:indeterminate="true"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>
</LinearLayout>

View file

@ -1,128 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View"/>
<variable
name="details"
type="at.bitfire.davdroid.ui.setup.AccountDetailsFragment.Model"/>
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_margin="@dimen/activity_margin">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.MaterialComponents.Headline5"
android:text="@string/login_create_account"
android:layout_marginBottom="14dp"/>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_account_name"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
app:errorEnabled="true"
app:error="@{details.nameError}">
<!--suppress AndroidUnknownAttribute -->
<AutoCompleteTextView
android:id="@+id/accountName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
unfilteredText="@={details.name}"
android:afterTextChanged="@{details::validateAccountName}"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.AppCompat.Body2"
app:drawableStartCompat="@drawable/ic_info"
android:drawablePadding="8dp"
android:layout_marginBottom="4dp"
android:text="@string/login_account_avoid_apostrophe"
android:visibility="@{details.showApostropheWarning ? View.VISIBLE : View.GONE}"/>
<TextView
android:id="@+id/account_email_hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
style="@style/TextAppearance.AppCompat.Body2"
android:text="@string/login_account_name_info" />
<LinearLayout
android:id="@+id/carddav"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/login_account_contact_group_method"/>
<Spinner
android:id="@+id/contact_group_method"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:entries="@array/settings_contact_group_method_entries"/>
</LinearLayout>
</LinearLayout>
</ScrollView>
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/stepper_nav_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="right|center_vertical"
android:orientation="horizontal">
<ProgressBar
android:id="@+id/create_account_progress"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible"
android:indeterminate="true"
style="?android:attr/progressBarStyleHorizontal"/>
<Button
android:id="@+id/create_account"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_create_account" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</layout>

View file

@ -1,361 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © Ricki Hirner (bitfire web engineering) and other contributors.
~ All rights reserved. This program and the accompanying materials
~ are made available under the terms of the GNU Public License v3.0
~ which accompanies this distribution, and is available at
~ http://www.gnu.org/licenses/gpl.html
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="model"
type="at.bitfire.davdroid.ui.setup.DefaultLoginCredentialsModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- We don't want the keyboard up when the user arrives in this initial screen -->
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:focusable="true"
android:focusableInTouchMode="true"
android:importantForAccessibility="no"
tools:ignore="UnusedAttribute">
<requestFocus />
</View>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/activity_margin"
android:layout_weight="1">
<!--suppress AndroidUnknownAttribute -->
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:onCheckedChanged="@{model::clearErrors}"
android:orientation="vertical">
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={model.loginWithEmailAddress}"
android:paddingStart="14dp"
android:text="@string/login_type_email"
android:textAlignment="viewStart" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:visibility="@{model.loginWithEmailAddress ? View.VISIBLE : View.GONE}">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_email_address"
app:error="@{model.usernameError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginEmailAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearUsernameError}"
android:autofillHints="emailAddress"
android:inputType="textEmailAddress"
android:text="@={model.username}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_password"
app:endIconMode="password_toggle"
app:error="@{model.passwordError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginEmailPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearPasswordError}"
android:autofillHints="password"
android:fontFamily="monospace"
android:inputType="textPassword"
android:text="@={model.password}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:checked="@={model.loginWithUrlAndUsername}"
android:paddingStart="14dp"
android:text="@string/login_type_url"
android:textAlignment="viewStart" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:visibility="@{model.loginWithUrlAndUsername ? View.VISIBLE : View.GONE}">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_base_url"
app:error="@{model.baseUrlError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<AutoCompleteTextView
android:id="@+id/loginUrlBaseUrlEdittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearUrlError}"
android:inputType="textUri"
android:text="@={model.baseUrl}" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_user_name"
app:error="@{model.usernameError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginUrlUsernameEdittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearUsernameError}"
android:autofillHints="username"
android:inputType="textEmailAddress"
android:text="@={model.username}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_password"
app:endIconMode="password_toggle"
app:error="@{model.passwordError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/loginUrlPasswordEdittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearPasswordError}"
android:autofillHints="password"
android:fontFamily="monospace"
android:inputType="textPassword"
android:text="@={model.password}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:checked="@={model.loginAdvanced}"
android:paddingStart="14dp"
android:text="@string/login_type_advanced"
android:textAlignment="viewStart" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:visibility="@{model.loginAdvanced ? View.VISIBLE : View.GONE}">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_base_url"
app:error="@{model.baseUrlError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearUrlError}"
android:inputType="textUri"
android:text="@={model.baseUrl}" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/login_use_username_password"
style="@style/Widget.MaterialComponents.CompoundButton.CheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={model.loginUseUsernamePassword}"
android:text="@string/login_use_username_password"
android:textAlignment="viewStart" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="16dp"
android:visibility="@{model.loginUseUsernamePassword ? View.VISIBLE : View.GONE}" >
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_user_name"
app:error="@{model.usernameError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearUsernameError}"
android:autofillHints="username"
android:inputType="textEmailAddress"
android:text="@={model.username}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_password"
app:endIconMode="password_toggle"
app:error="@{model.passwordError}"
app:errorEnabled="true">
<!--suppress AndroidUnknownAttribute -->
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:afterTextChanged="@{model::clearPasswordError}"
android:autofillHints="password"
android:fontFamily="monospace"
android:inputType="textPassword"
android:text="@={model.password}"
tools:ignore="UnusedAttribute" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
<CheckBox
android:id="@+id/login_use_client_certificate"
style="@style/Widget.MaterialComponents.CompoundButton.CheckBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={model.loginUseClientCertificate}"
android:text="@string/login_use_client_certificate"
android:textAlignment="viewStart" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{model.loginUseClientCertificate ? View.VISIBLE : View.GONE}" >
<TextView
style="@style/Base.TextAppearance.AppCompat.Body1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="3dp"
android:paddingEnd="3dp"
android:text="@={model.certificateAlias}"
android:textSize="16sp"
app:error="@{model.certificateAliasError}"
app:errorEnabled="true" />
<Button
android:id="@+id/selectCertificate"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_select_certificate" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={model.loginGoogle}"
android:paddingStart="14dp"
android:text="@string/login_type_google"
android:textAlignment="viewStart" />
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:checked="@={model.loginNextcloud}"
android:paddingStart="14dp"
android:text="@string/login_type_nextcloud"
android:textAlignment="viewStart" />
</RadioGroup>
</ScrollView>
<androidx.cardview.widget.CardView
style="@style/stepper_nav_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/login"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/login_login" />
</androidx.cardview.widget.CardView>
</LinearLayout>
</layout>

View file

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorBackgroundFloating"
android:gravity="bottom"
android:orientation="vertical"
android:padding="@dimen/activity_margin"
android:theme="@style/ThemeOverlay.AppCompat.Dark">
<ImageView
android:id="@+id/imageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:maxWidth="48dp"
android:adjustViewBounds="true"
android:src="@mipmap/ic_launcher"
tools:ignore="ContentDescription"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/nav_header_vertical_spacing"
android:text="@string/app_name"
android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/navigation_drawer_subtitle"/>
</LinearLayout>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="8dp">
<TextView
android:id="@android:id/text1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/TextAppearance.MaterialComponents.Body1"
tools:text="Line 1"/>
<TextView
android:id="@android:id/text2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
style="@style/TextAppearance.MaterialComponents.Caption"
tools:text="Line 2"/>
</LinearLayout>

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.switchmaterial.SwitchMaterial xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/switchWidget"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

View file

@ -138,7 +138,7 @@
<string name="login_account_not_created">لم نتمكن من إنشاء الحساب</string>
<string name="login_configuration_detection">اكتشاف الضبط</string>
<string name="login_querying_server">يجري استعلام الخادم … يرجى الانتظار</string>
<string name="login_no_caldav_carddav">لم نجِد خدمة CalDAV أو CardDAV.</string>
<string name="login_no_service">لم نجِد خدمة CalDAV أو CardDAV.</string>
<string name="login_view_logs">عرض التفاصيل</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">المزامنة</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Данните за вход не могат да бъдат получени</string>
<string name="login_configuration_detection">Откриване на настройки</string>
<string name="login_querying_server">Изчакайте, запитване на сървъра…</string>
<string name="login_no_caldav_carddav">Не са открити услуги на CalDAV или CardDAV.</string>
<string name="login_username_password_wrong">Грешно потребителско име или парола?</string>
<string name="login_no_service">Не са открити услуги на CalDAV или CardDAV.</string>
<string name="login_check_credentials">Грешно потребителско име или парола?</string>
<string name="login_view_logs">Подробности</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Синхронизация</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">No s\'han pogut obtenir les dades d\'inici de sessió</string>
<string name="login_configuration_detection">Detecció de la configuració</string>
<string name="login_querying_server">Espereu, s\'està consultant el servidor…</string>
<string name="login_no_caldav_carddav">No s\'ha pogut trobar cap servei CalDAV o CardDAV.</string>
<string name="login_username_password_wrong">Nom d\'usuari (adreça de correu) / contrasenya incorrectes?</string>
<string name="login_no_service">No s\'ha pogut trobar cap servei CalDAV o CardDAV.</string>
<string name="login_check_credentials">Nom d\'usuari (adreça de correu) / contrasenya incorrectes?</string>
<string name="login_view_logs">Mostra els detalls</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronització</string>

View file

@ -241,8 +241,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Nelze získat přihlašovací data</string>
<string name="login_configuration_detection">Zjišťování nastavení</string>
<string name="login_querying_server">Chvíli strpení, probíhá dotazování serveru…</string>
<string name="login_no_caldav_carddav">Nedaří se nalézt službu CalDAV nebo CardDAV.</string>
<string name="login_username_password_wrong">Nesprávné uživatelské jméno (e-mailová adresa) nebo heslo?</string>
<string name="login_no_service">Nedaří se nalézt službu CalDAV nebo CardDAV.</string>
<string name="login_check_credentials">Nesprávné uživatelské jméno (e-mailová adresa) nebo heslo?</string>
<string name="login_view_logs">Zobrazit podrobnosti</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronizace</string>

View file

@ -236,8 +236,8 @@ Lav lagerplads. Android vil ikke synkronisere lokale ændringer med det samme, m
<string name="login_nextcloud_login_flow_no_login_data">Kunne ikke hente login-data</string>
<string name="login_configuration_detection">Check konfiguration</string>
<string name="login_querying_server">Vent, forespørger serveren…</string>
<string name="login_no_caldav_carddav">Kunne ikke finde CalDAV- eller CardDAV-tjeneste.</string>
<string name="login_username_password_wrong">Brugernavn (e-mail adresse) / adgangskode forkert?</string>
<string name="login_no_service">Kunne ikke finde CalDAV- eller CardDAV-tjeneste.</string>
<string name="login_check_credentials">Brugernavn (e-mail adresse) / adgangskode forkert?</string>
<string name="login_view_logs">Vis detaljer</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synkronisering</string>

View file

@ -249,8 +249,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Anmeldedaten konnten nicht abgerufen werden</string>
<string name="login_configuration_detection">Ressourcen-Erkennung</string>
<string name="login_querying_server">Server-Informationen werden abgerufen. Bitte warten …</string>
<string name="login_no_caldav_carddav">Es konnte weder ein CalDAV- noch ein CardDAV-Dienst gefunden werden.</string>
<string name="login_username_password_wrong">Benutzername (Email-Adresse) / Passwort falsch?</string>
<string name="login_no_service">Es konnte weder ein CalDAV- noch ein CardDAV-Dienst gefunden werden.</string>
<string name="login_check_credentials">Benutzername (Email-Adresse) / Passwort falsch?</string>
<string name="login_view_logs">Details anzeigen</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronisierung</string>

View file

@ -187,8 +187,8 @@
<string name="login_install_certificate">Εγκατάσταση πιστοποιητικού</string>
<string name="login_configuration_detection">Ανίχνευση ρυθμίσεων</string>
<string name="login_querying_server">Περιμένετε, γίνεται ερώτημα στο διακομιστή...</string>
<string name="login_no_caldav_carddav">Αδυναμία εύρεσης υπηρεσίας CalDAV ή CardDAV.</string>
<string name="login_username_password_wrong">Λάθος ονομασία χρήστη (διεύθυνση ηλ. ταχυδρομείου) / συνθηματικού</string>
<string name="login_no_service">Αδυναμία εύρεσης υπηρεσίας CalDAV ή CardDAV.</string>
<string name="login_check_credentials">Λάθος ονομασία χρήστη (διεύθυνση ηλ. ταχυδρομείου) / συνθηματικού</string>
<string name="login_view_logs">Εμφάνιση λεπτομερειών</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Συγχρονισμός</string>

View file

@ -236,8 +236,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Couldn\'t obtain login data</string>
<string name="login_configuration_detection">Configuration detection</string>
<string name="login_querying_server">Please wait, querying server…</string>
<string name="login_no_caldav_carddav">Couldn\'t find CalDAV or CardDAV service.</string>
<string name="login_username_password_wrong">Username (email address) / password wrong?</string>
<string name="login_no_service">Couldn\'t find CalDAV or CardDAV service.</string>
<string name="login_check_credentials">Username (email address) / password wrong?</string>
<string name="login_view_logs">Show details</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronisation</string>

View file

@ -207,8 +207,8 @@
<string name="login_install_certificate">Instalar certificado</string>
<string name="login_configuration_detection">Detectar configuración</string>
<string name="login_querying_server">Por favor espera, consultando al servidor…</string>
<string name="login_no_caldav_carddav">No se pudo encontrar el servicio CalDAV o CardDAV.</string>
<string name="login_username_password_wrong">¿Nombre de usuario (dirección de correo electrónico) / contraseña incorrecta?</string>
<string name="login_no_service">No se pudo encontrar el servicio CalDAV o CardDAV.</string>
<string name="login_check_credentials">¿Nombre de usuario (dirección de correo electrónico) / contraseña incorrecta?</string>
<string name="login_view_logs">Mostrar detalles</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronización</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Ezin izan dira saio-hasierako datuak lortu</string>
<string name="login_configuration_detection">Konfigurazio detekzioa</string>
<string name="login_querying_server">Mesedez itxaron, zerbitzaria kontsultatzen...</string>
<string name="login_no_caldav_carddav">Ezin izan da CalDAV edo CardDAV zerbitzua aurkitu.</string>
<string name="login_username_password_wrong">Erabiltzaile-izena (eposta) / pasahitza txarto dago?</string>
<string name="login_no_service">Ezin izan da CalDAV edo CardDAV zerbitzua aurkitu.</string>
<string name="login_check_credentials">Erabiltzaile-izena (eposta) / pasahitza txarto dago?</string>
<string name="login_view_logs">Erakutsi xehetasunak</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sinkronizazioa</string>

View file

@ -216,8 +216,8 @@
<string name="login_install_certificate">نصب گواهی</string>
<string name="login_configuration_detection">تشخیص پیکربندی</string>
<string name="login_querying_server">لطفا صبر کنید، پرس و جو سرور ...</string>
<string name="login_no_caldav_carddav">سرویس CalDAV یا CardDAV پیدا نشد.</string>
<string name="login_username_password_wrong">نام کاربری (آدرس ایمیل) / رمز عبور اشتباه است؟</string>
<string name="login_no_service">سرویس CalDAV یا CardDAV پیدا نشد.</string>
<string name="login_check_credentials">نام کاربری (آدرس ایمیل) / رمز عبور اشتباه است؟</string>
<string name="login_view_logs">نمایش جزئیات</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">همگام سازی</string>

View file

@ -239,8 +239,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Impossible d\'obtenir les données de connexion</string>
<string name="login_configuration_detection">Détection de la configuration</string>
<string name="login_querying_server">Veuillez patienter, nous interrogeons le serveur …</string>
<string name="login_no_caldav_carddav">Aucun accès possible au service CalDAV ou CardDAV.</string>
<string name="login_username_password_wrong">Nom d\'utilisateur (courriel) / mot de passe incorrect ?</string>
<string name="login_no_service">Aucun accès possible au service CalDAV ou CardDAV.</string>
<string name="login_check_credentials">Nom d\'utilisateur (courriel) / mot de passe incorrect ?</string>
<string name="login_view_logs">Afficher les détails</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronisation</string>

View file

@ -256,8 +256,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Non se obtiveron os datos de acceso</string>
<string name="login_configuration_detection">Detección da configuración</string>
<string name="login_querying_server">Agarda por favor, consultando o servidor…</string>
<string name="login_no_caldav_carddav">Non se atopou servizo CalDAV ou CardDAV.</string>
<string name="login_username_password_wrong">¿Usuaria (enderezo email) / contrasinal incorrectos?</string>
<string name="login_no_service">Non se atopou servizo CalDAV ou CardDAV.</string>
<string name="login_check_credentials">¿Usuaria (enderezo email) / contrasinal incorrectos?</string>
<string name="login_view_logs">Mostrar detalles</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronización</string>

View file

@ -176,8 +176,8 @@
<string name="login_install_certificate">Instaliraj certifikat</string>
<string name="login_configuration_detection">Detektiranje konfiguracije</string>
<string name="login_querying_server">Pričekajte, postavljanje upita poslužitelju...</string>
<string name="login_no_caldav_carddav">Nije moguće pronaći CalDAV ili CardDAV uslugu.</string>
<string name="login_username_password_wrong">Korisničko ime (adresa e-pošte) / lozinka pogrešni?</string>
<string name="login_no_service">Nije moguće pronaći CalDAV ili CardDAV uslugu.</string>
<string name="login_check_credentials">Korisničko ime (adresa e-pošte) / lozinka pogrešni?</string>
<string name="login_view_logs">Prikaži detalje</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sinkronizacija</string>

View file

@ -241,8 +241,8 @@
<string name="login_nextcloud_login_flow_no_login_data">A bejelentkezési adatok megkeresése nem sikerült</string>
<string name="login_configuration_detection">A konfiguráció felderítése</string>
<string name="login_querying_server">Kérjük, várjon, a szerver lekérdezése…</string>
<string name="login_no_caldav_carddav">Nem található CalDAV vagy CardDAV szolgáltatás.</string>
<string name="login_username_password_wrong">Felhasználónév (e-mail cím) vagy jelszó hibás?</string>
<string name="login_no_service">Nem található CalDAV vagy CardDAV szolgáltatás.</string>
<string name="login_check_credentials">Felhasználónév (e-mail cím) vagy jelszó hibás?</string>
<string name="login_view_logs">Részletek mutatása</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Szinkronizálás</string>

View file

@ -213,8 +213,8 @@
<string name="login_nextcloud_login_with_nextcloud">Accedi con Nextcloud</string>
<string name="login_configuration_detection">Rilevazione configurazione</string>
<string name="login_querying_server">Attendere, invio richiesta al server…</string>
<string name="login_no_caldav_carddav">Impossibile trovare servizi CalDAV o CardDAV.</string>
<string name="login_username_password_wrong">Nome utente (indirizzo email) / password sbagliata?</string>
<string name="login_no_service">Impossibile trovare servizi CalDAV o CardDAV.</string>
<string name="login_check_credentials">Nome utente (indirizzo email) / password sbagliata?</string>
<string name="login_view_logs">Visualizza dettagli</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronizzazione</string>

View file

@ -257,8 +257,8 @@
<string name="login_nextcloud_login_flow_no_login_data">ログイン情報を入手できませんでした</string>
<string name="login_configuration_detection">設定の検出</string>
<string name="login_querying_server">しばらくお待ちください。サーバーに問い合わせ中…</string>
<string name="login_no_caldav_carddav">CalDAV または CardDAV サービスが見つかりませんでした。</string>
<string name="login_username_password_wrong">ユーザー名 (メールアドレス) / パスワード が間違っていませんか?</string>
<string name="login_no_service">CalDAV または CardDAV サービスが見つかりませんでした。</string>
<string name="login_check_credentials">ユーザー名 (メールアドレス) / パスワード が間違っていませんか?</string>
<string name="login_view_logs">詳細を表示</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">同期</string>

View file

@ -206,8 +206,8 @@
<string name="login_install_certificate">인증서 설치</string>
<string name="login_configuration_detection">구성 탐색</string>
<string name="login_querying_server">잠시 기다려 주십시오. 서버를 쿼리하고 있습니다...</string>
<string name="login_no_caldav_carddav">CalDAV 또는 CardDAV 서비스를 찾을 수 없습니다.</string>
<string name="login_username_password_wrong">사용자 이름 (email address) / 비밀번호 가 잘못되었습니까?</string>
<string name="login_no_service">CalDAV 또는 CardDAV 서비스를 찾을 수 없습니다.</string>
<string name="login_check_credentials">사용자 이름 (email address) / 비밀번호 가 잘못되었습니까?</string>
<string name="login_view_logs">자세히</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">동기화</string>

View file

@ -111,7 +111,7 @@
<string name="login_account_not_created">Kontonavnet kan ikke opprettes</string>
<string name="login_configuration_detection">Oppdagelse av oppsett</string>
<string name="login_querying_server">Vent, spør tjener…</string>
<string name="login_no_caldav_carddav">Fant ikke CalDAV eller CardDAV-tjeneste.</string>
<string name="login_no_service">Fant ikke CalDAV eller CardDAV-tjeneste.</string>
<string name="login_view_logs">Vis fler detaljer</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synkronisering</string>

View file

@ -102,7 +102,7 @@
<string name="login_account_not_created">Kontonavnet kan ikke opprettes</string>
<string name="login_configuration_detection">Oppdagelse av oppsett</string>
<string name="login_querying_server">Vent, spør tjener…</string>
<string name="login_no_caldav_carddav">Fant ikke CalDAV eller CardDAV-tjeneste.</string>
<string name="login_no_service">Fant ikke CalDAV eller CardDAV-tjeneste.</string>
<string name="login_view_logs">Vis fler detaljer</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synkronisering</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Kan inlog-URL niet verkrijgen</string>
<string name="login_configuration_detection">Configuratie detecteren</string>
<string name="login_querying_server">Even geduld, verzoek naar server…</string>
<string name="login_no_caldav_carddav">Geen CalDAV- of CardDAV-service gevonden.</string>
<string name="login_username_password_wrong">Is gebruikersnaam (e-mailadres) / wachtwoord verkeerd?</string>
<string name="login_no_service">Geen CalDAV- of CardDAV-service gevonden.</string>
<string name="login_check_credentials">Is gebruikersnaam (e-mailadres) / wachtwoord verkeerd?</string>
<string name="login_view_logs">Details weergeven</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronisatie</string>

View file

@ -239,8 +239,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Nie udało się uzyskać danych logowania</string>
<string name="login_configuration_detection">Wykrywanie konfiguracji</string>
<string name="login_querying_server">Proszę czekać, odpytywanie serwera…</string>
<string name="login_no_caldav_carddav">Nie można znaleźć usługi CalDAV lub CardDAV.</string>
<string name="login_username_password_wrong">Błędna nazwa użytkownika (adres e-mail)/hasło?</string>
<string name="login_no_service">Nie można znaleźć usługi CalDAV lub CardDAV.</string>
<string name="login_check_credentials">Błędna nazwa użytkownika (adres e-mail)/hasło?</string>
<string name="login_view_logs">Pokaż szczegóły</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronizacja</string>

View file

@ -190,8 +190,8 @@
<string name="login_type_nextcloud">Nextcloud</string>
<string name="login_configuration_detection">Detecção de configuração</string>
<string name="login_querying_server">Aguarde, procurando servidor…</string>
<string name="login_no_caldav_carddav">Não foi possível encontrar o serviço CalDAV ou CardDAV.</string>
<string name="login_username_password_wrong">Nome de usuário (endereço de e-mail) / senha incorreto?</string>
<string name="login_no_service">Não foi possível encontrar o serviço CalDAV ou CardDAV.</string>
<string name="login_check_credentials">Nome de usuário (endereço de e-mail) / senha incorreto?</string>
<string name="login_view_logs">Mostrar detalhes</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronização</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Nu s-au putut obține datele de conectare</string>
<string name="login_configuration_detection">Detectarea configurației</string>
<string name="login_querying_server">Se interoghează serverul…</string>
<string name="login_no_caldav_carddav">Nu s-a putut găsi serviciul CalDAV sau CardDAV.</string>
<string name="login_username_password_wrong">Nume de utilizator (adresă de e-mail)/parolă greșită?</string>
<string name="login_no_service">Nu s-a putut găsi serviciul CalDAV sau CardDAV.</string>
<string name="login_check_credentials">Nume de utilizator (adresă de e-mail)/parolă greșită?</string>
<string name="login_view_logs">Afișează detaliile</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sincronizare</string>

View file

@ -245,8 +245,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Не удалось получить данные для авторизации</string>
<string name="login_configuration_detection">Обнаружение конфигурации</string>
<string name="login_querying_server">Ожидайте, выполняется запрос к серверу…</string>
<string name="login_no_caldav_carddav">Не удалось найти службу CalDAV или CardDAV.</string>
<string name="login_username_password_wrong">Имя пользователя (адрес email) / пароль неверны?</string>
<string name="login_no_service">Не удалось найти службу CalDAV или CardDAV.</string>
<string name="login_check_credentials">Имя пользователя (адрес email) / пароль неверны?</string>
<string name="login_view_logs">Показать детали</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Синхронизация</string>

View file

@ -105,7 +105,7 @@
<string name="login_account_not_created">Nie je možné vytvoriť používateľský účet</string>
<string name="login_configuration_detection">Zisťuje sa konfigurácia</string>
<string name="login_querying_server">Čakajte, prosím, zasiela sa dopyt na server...</string>
<string name="login_no_caldav_carddav">Nie je možné nájsť služby CalDAV ani CardDAV.</string>
<string name="login_no_service">Nie je možné nájsť služby CalDAV ani CardDAV.</string>
<string name="login_view_logs">Zobraziť podrobnosti</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchronizácia</string>

View file

@ -116,7 +116,7 @@
<string name="login_account_not_created">Računa ni bilo mogoče ustvariti</string>
<string name="login_configuration_detection">Zaznava konfiguracije</string>
<string name="login_querying_server">Prosim počakajte, povezava s strežnikom je v teku…</string>
<string name="login_no_caldav_carddav">CalDAV ali CardDAV storitve ni bilo mogoče najti.</string>
<string name="login_no_service">CalDAV ali CardDAV storitve ni bilo mogoče najti.</string>
<string name="login_view_logs">Pokaži podrobnosti</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sinhronizacija</string>

View file

@ -136,7 +136,7 @@
<string name="login_account_not_created">Računa ni bilo mogoče ustvariti</string>
<string name="login_configuration_detection">Zaznava konfiguracije</string>
<string name="login_querying_server">Prosim počakajte, povezava s strežnikom je v teku...</string>
<string name="login_no_caldav_carddav">CalDAV ali CardDAV storitve ni bilo mogoče najti.</string>
<string name="login_no_service">CalDAV ali CardDAV storitve ni bilo mogoče najti.</string>
<string name="login_view_logs">Pokaži podrobnosti</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Sinhronizacija</string>

View file

@ -207,8 +207,8 @@
<string name="login_google_client_id">ИД клијента (опционо)</string>
<string name="login_configuration_detection">Откривање конфигурације</string>
<string name="login_querying_server">Сачекајте, шаљем упит серверу…</string>
<string name="login_no_caldav_carddav">Не могох да нађем КалДАВ или КардДАВ услугу.</string>
<string name="login_username_password_wrong">Корисничко име (адреса е-поште) / лозинка је погрешна?</string>
<string name="login_no_service">Не могох да нађем КалДАВ или КардДАВ услугу.</string>
<string name="login_check_credentials">Корисничко име (адреса е-поште) / лозинка је погрешна?</string>
<string name="login_view_logs">Прикажи детаље</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Синхронизација</string>

View file

@ -241,8 +241,8 @@
<string name="login_nextcloud_login_flow_no_login_data">Kunde inte hämta login-data</string>
<string name="login_configuration_detection">Konfigurationsdetektering</string>
<string name="login_querying_server">Vänligen vänta, frågar efter server...</string>
<string name="login_no_caldav_carddav">Det gick inte att hitta CalDAV eller CardDAV-tjänsten.</string>
<string name="login_username_password_wrong">Användarnamn (e-postadress) / lösenord är fel?</string>
<string name="login_no_service">Det gick inte att hitta CalDAV eller CardDAV-tjänsten.</string>
<string name="login_check_credentials">Användarnamn (e-postadress) / lösenord är fel?</string>
<string name="login_view_logs">Visa detaljer</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synkronisering</string>

View file

@ -121,7 +121,7 @@
<string name="login_account_not_created">Kōnto niy mogło być stworzōne</string>
<string name="login_configuration_detection">Wykrywanie kōnfiguracyje</string>
<string name="login_querying_server">Czekej, ôdpytowanie serwera…</string>
<string name="login_no_caldav_carddav">Niy idzie znojść usugi CalDAV abo CardDAV.</string>
<string name="login_no_service">Niy idzie znojść usugi CalDAV abo CardDAV.</string>
<string name="login_view_logs">Pokoż informacyje</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Synchrōnizacyjo</string>

View file

@ -68,7 +68,7 @@
<string name="login_account_not_created">Hesap yaratılamadı</string>
<string name="login_configuration_detection">Konfigürasyon keşfi</string>
<string name="login_querying_server">Lütfen bekle, sunucu sorgulanıyor…</string>
<string name="login_no_caldav_carddav">CalDAV veya CardDAV servisi bulunamadı.</string>
<string name="login_no_service">CalDAV veya CardDAV servisi bulunamadı.</string>
<string name="login_view_logs">Detayları göster</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Senkronizasyon</string>

View file

@ -149,8 +149,8 @@
<string name="login_install_certificate">Встановити сертифікат</string>
<string name="login_configuration_detection">Виявлення конфігурації</string>
<string name="login_querying_server">Будь ласка, зачекайте, запит до серверу…</string>
<string name="login_no_caldav_carddav">Не вдалося знайти CalDAV чи CardDAV сервіс.</string>
<string name="login_username_password_wrong">Невірне імʼя користувача (електронна адреса)/пароль?</string>
<string name="login_no_service">Не вдалося знайти CalDAV чи CardDAV сервіс.</string>
<string name="login_check_credentials">Невірне імʼя користувача (електронна адреса)/пароль?</string>
<string name="login_view_logs">Показати деталі</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Синхронізація</string>

View file

@ -207,8 +207,8 @@
<string name="login_install_certificate">Cài đặt chứng chỉ</string>
<string name="login_configuration_detection">Dò tìm thiết lập</string>
<string name="login_querying_server">Vui lòng đợi, đang truy vấn máy chủ…</string>
<string name="login_no_caldav_carddav">Không thể tìm dịch vụ CalDAV hoặc CardDAV.</string>
<string name="login_username_password_wrong">Tên người dùng (địa chỉ email) / mật khẩu bị sai?</string>
<string name="login_no_service">Không thể tìm dịch vụ CalDAV hoặc CardDAV.</string>
<string name="login_check_credentials">Tên người dùng (địa chỉ email) / mật khẩu bị sai?</string>
<string name="login_view_logs">Hiện chi tiết</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">Đồng bộ hoá</string>

View file

@ -131,7 +131,7 @@
<string name="login_account_not_created">無法建立帳號</string>
<string name="login_configuration_detection">設定錯誤</string>
<string name="login_querying_server">請稍待,正在詢問伺服器…</string>
<string name="login_no_caldav_carddav">找不到 CalDAV 或 CardDAV 服務。</string>
<string name="login_no_service">找不到 CalDAV 或 CardDAV 服務。</string>
<string name="login_view_logs">顯示詳細訊息</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">同步設定</string>

View file

@ -257,8 +257,8 @@
<string name="login_nextcloud_login_flow_no_login_data">无法获得登陆数据</string>
<string name="login_configuration_detection">正在配置</string>
<string name="login_querying_server">正在与服务器通信,请稍等…</string>
<string name="login_no_caldav_carddav">找不到 CalDAV 或 CardDAV 服务。</string>
<string name="login_username_password_wrong">用户名(邮箱地址)或密码错误?</string>
<string name="login_no_service">找不到 CalDAV 或 CardDAV 服务。</string>
<string name="login_check_credentials">用户名(邮箱地址)或密码错误?</string>
<string name="login_view_logs">显示详情</string>
<!--AccountSettingsActivity-->
<string name="settings_sync">同步</string>

View file

@ -16,6 +16,7 @@
<string name="manage_accounts">Manage accounts</string>
<string name="navigate_up">Navigate up</string>
<string name="no_internet_sync_scheduled">No Internet, scheduling sync</string>
<string name="optional_label">* optional</string>
<string name="options_menu">Options menu</string>
<string name="share">Share</string>
<string name="sync_started">Synchronization started</string>
@ -240,31 +241,35 @@
<!-- AddAccountActivity -->
<string name="login_title">Add account</string>
<string name="login_generic_login">Generic login</string>
<string name="login_provider_login">Provider-specific login</string>
<string name="login_continue">Continue</string>
<string name="login_login">Login</string>
<string name="login_type_email">Login with email address</string>
<string name="login_email_address">Email address</string>
<string name="login_email_address_error">Valid email address required</string>
<string name="login_email_address_info"><![CDATA[The email domain is used as base URL. <a href="%s">Services are discovered</a> using DNS records and well-known URLs.]]></string>
<string name="login_password">Password</string>
<string name="login_password_hide">Hide password</string>
<string name="login_password_show">Show password</string>
<string name="login_password_required">Password required</string>
<string name="login_password_optional">Password*</string>
<string name="login_type_url">Login with URL and user name</string>
<string name="login_url_must_be_http_or_https">URL must begin with http(s)://</string>
<string name="login_user_name">User name</string>
<string name="login_user_name_required">User name required</string>
<string name="login_user_name_optional">User name*</string>
<string name="login_base_url">Base URL</string>
<string name="login_base_url_info"><![CDATA[The base URL will be checked directly, but <a href="%s">services are also discovered</a> using DNS records and well-known URLs.]]></string>
<string name="login_select_certificate">Select certificate</string>
<string name="login_login">Login</string>
<string name="login_create_account">Create account</string>
<string name="login_account_name">Account name</string>
<string name="login_account_avoid_apostrophe">Use of apostrophes (\'), have been reported to cause problems on some devices.</string>
<string name="login_account_avoid_apostrophe">Usage of apostrophes (\') seems to cause problems on some devices.</string>
<string name="login_account_name_info">Use your email address as account name because Android will use the account name as ORGANIZER field for events you create. You can\'t have two accounts with the same name.</string>
<string name="login_account_contact_group_method">Contact group method:</string>
<string name="login_account_name_required">Account name required</string>
<string name="login_account_name_already_taken">Account name already taken</string>
<string name="login_account_not_created">Account could not be created</string>
<string name="login_type_advanced">Advanced login (special use cases)</string>
<string name="login_use_username_password">Use username/password</string>
<string name="login_use_client_certificate">Use client certificate</string>
<string name="login_type_advanced">Advanced login</string>
<string name="login_no_client_certificate_optional">No client certificate*</string>
<string name="login_client_certificate_selected">Client certificate: %s</string>
<string name="login_no_certificate_found">No certificate found</string>
<string name="login_install_certificate">Install certificate</string>
<string name="login_type_google">Google Contacts / Calendar</string>
@ -278,7 +283,6 @@
<string name="login_oauth_couldnt_obtain_auth_code">Couldn\'t obtain authorization code</string>
<string name="login_type_nextcloud">Nextcloud</string>
<string name="login_nextcloud_login_with_nextcloud">Login with Nextcloud</string>
<string name="login_nextcloud_login_flow">Login Flow</string>
<string name="login_nextcloud_login_flow_text">This will start the Nextcloud Login Flow in a Web browser.</string>
<string name="login_nextcloud_login_flow_server_address">Nextcloud server address</string>
<string name="login_nextcloud_login_flow_sign_in">Sign in</string>
@ -287,9 +291,12 @@
<string name="login_configuration_detection">Configuration detection</string>
<string name="login_querying_server">Please wait, querying server…</string>
<string name="login_no_caldav_carddav">Couldn\'t find CalDAV or CardDAV service.</string>
<string name="login_username_password_wrong">Username (email address) / password wrong?</string>
<string name="login_view_logs">Show details</string>
<string name="login_no_service">Couldn\'t find CalDAV or CardDAV service.</string>
<string name="login_no_service_info">The base URL doesn\'t seem to be an accessible CalDAV/CardDAV URL and service detection was not successful.</string>
<string name="login_see_tested_services"><![CDATA[Please see the manual of your service provider and <a href="%s">our list of tested services</a> and their base URLs.</a>]]></string>
<string name="login_check_credentials">Please also double-check authentication (usually username and password).</string>
<string name="login_logs_available">Further technical information is available in the logs.</string>
<string name="login_view_logs">View logs</string>
<!-- AccountSettingsActivity -->
<string name="settings_sync">Synchronization</string>

View file

@ -57,8 +57,6 @@
<item name="android:textColorSecondary">@color/secondaryTextColor</item>
<item name="android:colorBackground">@color/backgroundColor</item>
<item name="switchPreferenceCompatStyle">@style/MySwitchPreference</item>
</style>
<style name="AppTheme.NoActionBar">
@ -73,13 +71,4 @@
<item name="cardElevation">4dp</item>
</style>
<!-- widgets -->
<color name="cardview_background">@color/secondaryLightColor</color>
<style name="MySwitchPreference" parent="@style/Preference.SwitchPreferenceCompat.Material">
<item name="widgetLayout">@layout/view_preference_switch</item>
</style>
</resources>

View file

@ -11,6 +11,8 @@ import at.bitfire.davdroid.ui.OseAccountsDrawerHandler
import at.bitfire.davdroid.ui.intro.BatteryOptimizationsPage
import at.bitfire.davdroid.ui.intro.IntroPage
import at.bitfire.davdroid.ui.intro.IntroPageFactory
import at.bitfire.davdroid.ui.setup.LoginTypesProvider
import at.bitfire.davdroid.ui.setup.StandardLoginTypesProvider
import dagger.Binds
import dagger.Module
import dagger.Provides
@ -29,6 +31,9 @@ interface OseFlavorModules {
@Binds
fun appLicenseInfoProvider(impl: OpenSourceLicenseInfoProvider): AboutActivity.AppLicenseInfoProvider
@Binds
fun loginTypesProvider(impl: StandardLoginTypesProvider): LoginTypesProvider
}
@Module

View file

@ -0,0 +1,91 @@
package at.bitfire.davdroid.ui.setup
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.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 at.bitfire.davdroid.ui.composable.Assistant
@Composable
fun StandardLoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
onContinue: () -> Unit = {}
) {
Assistant(
nextLabel = stringResource(R.string.login_continue),
nextEnabled = true,
onNext = onContinue
) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_generic_login),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(vertical = 8.dp)
)
for (type in StandardLoginTypesProvider.genericLoginTypes)
LoginTypeSelector(
title = stringResource(type.title),
selected = type == selectedLoginType,
onSelect = { onSelectLoginType(type) }
)
Text(
stringResource(R.string.login_provider_login),
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)
)
for (type in StandardLoginTypesProvider.specificLoginTypes)
LoginTypeSelector(
title = stringResource(type.title),
selected = type == selectedLoginType,
onSelect = { onSelectLoginType(type) }
)
}
}
}
@Composable
fun LoginTypeSelector(
title: String,
selected: Boolean,
onSelect: () -> Unit = {}
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = onSelect)
) {
RadioButton(
selected = selected,
onClick = onSelect
)
Text(
title,
style = MaterialTheme.typography.body1,
modifier = Modifier.weight(1f)
)
}
}
}
@Composable
@Preview
fun LoginScreen_Preview() {
LoginScreen(
loginTypesProvider = StandardLoginTypesProvider(),
initialLoginType = LoginTypeUrl
)
}

View file

@ -0,0 +1,39 @@
package at.bitfire.davdroid.ui.setup
import android.content.Intent
import androidx.compose.runtime.Composable
import javax.inject.Inject
class StandardLoginTypesProvider @Inject constructor() : LoginTypesProvider {
companion object {
val genericLoginTypes = listOf(
LoginTypeUrl,
LoginTypeEmail,
LoginTypeAdvanced
)
val specificLoginTypes = listOf(
LoginTypeGoogle,
LoginTypeNextcloud
)
}
override val defaultLoginType = LoginTypeUrl
override fun intentToInitialLoginType(intent: Intent) =
if (intent.hasExtra(LoginActivity.EXTRA_LOGIN_FLOW))
LoginTypeNextcloud
else
LoginTypeUrl
@Composable
override fun LoginTypePage(selectedLoginType: LoginType, onSelectLoginType: (LoginType) -> Unit, onContinue: () -> Unit) {
StandardLoginTypePage(
selectedLoginType = selectedLoginType,
onSelectLoginType = onSelectLoginType,
onContinue = onContinue
)
}
}

View file

@ -6,7 +6,6 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.mikepenz.aboutLibraries) apply false
}

View file

@ -130,7 +130,5 @@ room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
android-application = { id = "com.android.application", version.ref = "android-agp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
# remove when ui.widget.BindingAdapters are not required anymore
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
mikepenz-aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "mikepenz-aboutLibraries" }