mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-10-04 18:33:49 +00:00
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:
parent
014c94a031
commit
079c3efdfd
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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=*****"
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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?) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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) }
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) }
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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 = ""
|
||||
)
|
||||
}
|
|
@ -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("") { _, _ -> }
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
156
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt
Normal file
156
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreen.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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("") { _, _ -> }
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
197
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypeUrl.kt
Normal file
197
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginTypeUrl.kt
Normal 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())
|
||||
}
|
|
@ -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
|
||||
)
|
||||
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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" />
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in a new issue