Support OAuth for Google (bitfireAT/davx5#289)

* Proof of concept (without auth state storage)
* Implement Bearer authentication, create network package
* Properly create/dispose AuthService
* Use proper ActivityResultContract
* Integrate into default login activity
* Change client ID to davx5integration@gmail.com
* Google Login: adapt login view
* Fix tests
* Don't allow empty Google account
* Move strings to resources
This commit is contained in:
Ricki Hirner 2023-06-11 13:17:08 +02:00
parent 1b3fde0854
commit 5b38943205
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
46 changed files with 546 additions and 147 deletions

View file

@ -28,6 +28,10 @@ android {
buildConfigField "String", "userAgent", "\"DAVx5\""
manifestPlaceholders = [
'appAuthRedirectScheme': applicationId
]
testInstrumentationRunner "at.bitfire.davdroid.CustomTestRunner"
kapt {
@ -123,7 +127,7 @@ dependencies {
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.fragment:fragment-ktx:1.6.0'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
@ -135,6 +139,7 @@ dependencies {
implementation 'androidx.work:work-runtime-ktx:2.8.1'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'com.google.android.material:material:1.9.0'
implementation 'net.openid:appauth:0.11.1'
// Jetpack Compose
def composeBom = platform("androidx.compose:compose-bom:${versions.composeBom}")
@ -142,6 +147,8 @@ dependencies {
androidTestImplementation composeBom
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.runtime:runtime-livedata'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1'
// Jetpack Room

View file

@ -6,9 +6,10 @@ package at.bitfire.davdroid.db
import android.security.NetworkSecurityPolicy
import androidx.test.filters.SmallTest
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -43,7 +44,7 @@ class CollectionTest {
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
Assume.assumeTrue(NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted)
}

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import android.os.Build
import androidx.test.filters.SdkSuppress

View file

@ -2,9 +2,12 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
@ -13,16 +16,23 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assume
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class HttpClientTest {
lateinit var server: MockWebServer
lateinit var httpClient: HttpClient
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Before
fun setUp() {
httpClient = HttpClient.Builder().build()
hiltRule.inject()
httpClient = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build()
server = MockWebServer()
server.start(30000)

View file

@ -10,7 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.property.AddressbookHomeSet
import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder.Configuration.ServiceInfo
@ -68,7 +68,7 @@ class DavResourceFinderTest {
loginModel.credentials = Credentials("mock", "12345")
finder = DavResourceFinder(InstrumentationRegistry.getInstrumentation().targetContext, loginModel)
client = HttpClient.Builder()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, loginModel.credentials!!)
.build()

View file

@ -12,7 +12,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.WorkManager
import androidx.work.testing.WorkManagerTestInitHelper
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
@ -109,7 +109,7 @@ class RefreshCollectionsWorkerTest {
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
client = HttpClient.Builder()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, loginModel.credentials!!)
.build()

View file

@ -7,7 +7,7 @@ package at.bitfire.davdroid.webdav
import android.content.Context
import android.security.NetworkSecurityPolicy
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
@ -65,7 +65,7 @@ class DavDocumentsProviderTest {
loginModel.baseURI = URI.create("/")
loginModel.credentials = Credentials("mock", "12345")
client = HttpClient.Builder()
client = HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext)
.addAuthentication(null, loginModel.credentials!!)
.build()

View file

@ -5,6 +5,7 @@
package at.bitfire.davdroid
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.SettingsManager
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest

View file

@ -18,7 +18,7 @@ import at.bitfire.dav4jvm.PropStat
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.SyncState
@ -98,7 +98,7 @@ class SyncManagerTest {
account,
arrayOf(),
"TestAuthority",
HttpClient.Builder().build(),
HttpClient.Builder(InstrumentationRegistry.getInstrumentation().targetContext).build(),
SyncResult(),
collection,
server

View file

@ -9,7 +9,7 @@ import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import androidx.test.platform.app.InstrumentationRegistry
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest

View file

@ -11,7 +11,7 @@ import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.MultiResponseCallback
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.settings.AccountSettings
@ -23,14 +23,14 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
class TestSyncManager(
context: Context,
account: Account,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
val mockWebServer: MockWebServer
context: Context,
account: Account,
extras: Array<String>,
authority: String,
httpClient: HttpClient,
syncResult: SyncResult,
localCollection: LocalTestCollection,
val mockWebServer: MockWebServer
): SyncManager<LocalTestResource, LocalTestCollection, DavCollection>(context, account, AccountSettings(context, account), httpClient, extras, authority, syncResult, localCollection) {
override fun prepare(): Boolean {

View file

@ -4,15 +4,32 @@
package at.bitfire.davdroid.db
import net.openid.appauth.AuthState
data class Credentials(
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null
val userName: String? = null,
val password: String? = null,
val certificateAlias: String? = null,
val authState: AuthState? = null
) {
override fun toString(): String {
val maskedPassword = "*****".takeIf { password != null }
return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)"
val s = mutableListOf<String>()
if (userName != null)
s += "userName=$userName"
if (password != null)
s += "password=*****"
if (certificateAlias != null)
s += "certificateAlias=$certificateAlias"
if (authState != null)
s += "authState=${authState.jsonSerializeString()}"
return "Credentials(" + s.joinToString(", ") + ")"
}
}

View file

@ -1,8 +1,8 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import android.net.DnsResolver
import android.os.Build

View file

@ -0,0 +1,69 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
import okhttp3.Interceptor
import okhttp3.Response
import java.util.logging.Level
/**
* Sends an OAuth Bearer token authorization as described in RFC 6750.
*/
class BearerAuthInterceptor(
private val accessToken: String
): Interceptor {
companion object {
fun fromAuthState(authService: AuthorizationService, authState: AuthState, callback: AuthStateUpdateCallback? = null): BearerAuthInterceptor? {
return runBlocking {
val accessTokenFuture = CompletableDeferred<String>()
authState.performActionWithFreshTokens(authService) { accessToken: String?, _: String?, ex: AuthorizationException? ->
if (accessToken != null) {
// persist updated AuthState
callback?.onUpdate(authState)
// emit access token
accessTokenFuture.complete(accessToken)
}
else {
Logger.log.log(Level.WARNING, "Couldn't obtain access token", ex)
accessTokenFuture.cancel()
}
}
// return value
try {
BearerAuthInterceptor(accessTokenFuture.await())
} catch (ignored: CancellationException) {
null
}
}
}
}
override fun intercept(chain: Interceptor.Chain): Response {
Logger.log.finer("Authenticating request with access token")
val rq = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(rq)
}
fun interface AuthStateUpdateCallback {
fun onUpdate(authState: AuthState)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import android.net.Uri
import at.bitfire.davdroid.BuildConfig
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
object GoogleOAuth {
// davx5integration@gmail.com
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() =
AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth/redirect")
)
fun createAuthService(context: Context) = AuthorizationService(context)
}

View file

@ -1,8 +1,8 @@
/***************************************************************************************************
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
*/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import android.content.Context
import android.os.Build
@ -10,6 +10,7 @@ import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.settings.AccountSettings
@ -19,6 +20,8 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import okhttp3.*
import okhttp3.brotli.BrotliInterceptor
import okhttp3.internal.tls.OkHostnameVerifier
@ -37,7 +40,8 @@ import javax.net.ssl.*
class HttpClient private constructor(
val okHttpClient: OkHttpClient,
private val certManager: CustomCertManager?
private val certManager: CustomCertManager? = null,
private var authService: AuthorizationService? = null
): AutoCloseable {
@EntryPoint
@ -79,14 +83,15 @@ class HttpClient private constructor(
.addInterceptor(UserAgentInterceptor)
}
override fun close() {
authService?.dispose()
okHttpClient.cache?.close()
certManager?.close()
}
class Builder(
val context: Context? = null,
val context: Context,
accountSettings: AccountSettings? = null,
val logger: java.util.logging.Logger? = Logger.log,
val loggerLevel: HttpLoggingInterceptor.Level = HttpLoggingInterceptor.Level.BODY
@ -97,6 +102,7 @@ class HttpClient private constructor(
}
private var appInForeground = false
private var authService: AuthorizationService? = null
private var certManagerProducer: CertManagerProducer? = null
private var certificateAlias: String? = null
private var offerCompression: Boolean = false
@ -114,60 +120,68 @@ class HttpClient private constructor(
orig.addNetworkInterceptor(loggingInterceptor)
}
if (context != null) {
val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager()
val settings = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).settingsManager()
// custom proxy support
try {
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settings.getString(Settings.PROXY_HOST),
settings.getInt(Settings.PROXY_PORT)
)
}
val proxy =
when (proxyTypeValue) {
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
// custom proxy support
try {
val proxyTypeValue = settings.getInt(Settings.PROXY_TYPE)
if (proxyTypeValue != Settings.PROXY_TYPE_SYSTEM) {
// we set our own proxy
val address by lazy { // lazy because not required for PROXY_TYPE_NONE
InetSocketAddress(
settings.getString(Settings.PROXY_HOST),
settings.getInt(Settings.PROXY_PORT)
)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
val proxy =
when (proxyTypeValue) {
Settings.PROXY_TYPE_NONE -> Proxy.NO_PROXY
Settings.PROXY_TYPE_HTTP -> Proxy(Proxy.Type.HTTP, address)
Settings.PROXY_TYPE_SOCKS -> Proxy(Proxy.Type.SOCKS, address)
else -> throw IllegalArgumentException("Invalid proxy type")
}
orig.proxy(proxy)
Logger.log.log(Level.INFO, "Using proxy setting", proxy)
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Can't set proxy, ignoring", e)
}
customCertManager() {
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
CustomCertManager(context, true /*BuildConfig.customCertsUI*/, trustSystemCerts)
}
customCertManager {
// by default, use a CustomCertManager that respects the "distrust system certificates" setting
val trustSystemCerts = !settings.getBoolean(Settings.DISTRUST_SYSTEM_CERTIFICATES)
CustomCertManager(context, true /*BuildConfig.customCertsUI*/, trustSystemCerts)
}
// use account settings for authentication and cookies
accountSettings?.let {
addAuthentication(null, it.credentials())
}
if (accountSettings != null)
addAuthentication(null, accountSettings.credentials(), authStateCallback = { authState: AuthState ->
accountSettings.credentials(Credentials(authState = authState))
})
}
constructor(context: Context, host: String?, credentials: Credentials?): this(context) {
constructor(context: Context, host: String?, credentials: Credentials?) : this(context) {
if (credentials != null)
addAuthentication(host, credentials)
}
fun addAuthentication(host: String?, credentials: Credentials, insecurePreemptive: Boolean = false): Builder {
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)
orig.addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
if (credentials.certificateAlias != null)
certificateAlias = credentials.certificateAlias
credentials.authState?.let { authState ->
val newAuthService = GoogleOAuth.createAuthService(context)
authService = newAuthService
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
orig.addNetworkInterceptor(bearerAuthInterceptor)
}
}
return this
}
@ -218,8 +232,6 @@ class HttpClient private constructor(
var keyManager: KeyManager? = null
certificateAlias?.let { alias ->
val context = requireNotNull(context)
// get provider certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
@ -248,30 +260,33 @@ class HttpClient private constructor(
orig.protocols(listOf(Protocol.HTTP_1_1))
}
if (certManagerProducer != null || keyManager != null) {
val certManager = certManagerProducer?.certManager()
certManager?.appInForeground = appInForeground
val certManager =
if (certManagerProducer != null || keyManager != null) {
val manager = certManagerProducer?.certManager()
manager?.appInForeground = appInForeground
val trustManager = certManager ?: { // fall back to system default trust manager
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()
val trustManager = manager ?: { // fall back to system default trust manager
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier)
?: OkHostnameVerifier
val hostnameVerifier = manager?.hostnameVerifier(OkHostnameVerifier)
?: OkHostnameVerifier
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
orig.sslSocketFactory(sslContext.socketFactory, trustManager)
orig.hostnameVerifier(hostnameVerifier)
return HttpClient(orig.build(), certManager)
} else
return HttpClient(orig.build(), null)
manager
} else
null
return HttpClient(orig.build(), certManager = certManager, authService = authService)
}
}

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid
package at.bitfire.davdroid.network
import okhttp3.Cookie
import okhttp3.CookieJar

View file

@ -12,9 +12,10 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.UnauthorizedException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.StringHandler
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.ui.setup.LoginModel
import at.bitfire.davdroid.util.DavUtils
import okhttp3.HttpUrl
@ -451,11 +452,11 @@ class DavResourceFinder(
) {
data class ServiceInfo(
var principal: HttpUrl? = null,
val homeSets: MutableSet<HttpUrl> = HashSet(),
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
var principal: HttpUrl? = null,
val homeSets: MutableSet<HttpUrl> = HashSet(),
val collections: MutableMap<HttpUrl, Collection> = HashMap(),
val emails: MutableList<String> = LinkedList()
val emails: MutableList<String> = LinkedList()
)
override fun toString(): String {

View file

@ -43,7 +43,6 @@ import at.bitfire.dav4jvm.property.ResourceType
import at.bitfire.dav4jvm.property.Source
import at.bitfire.dav4jvm.property.SupportedAddressData
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
@ -52,6 +51,7 @@ import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Principal
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker.Companion.ARG_SERVICE_ID
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings

View file

@ -23,6 +23,7 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import net.openid.appauth.AuthState
import org.apache.commons.lang3.StringUtils
import java.util.logging.Level
@ -63,6 +64,9 @@ class AccountSettings(
const val KEY_USERNAME = "user_name"
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
/** OAuth [AuthState] (serialized as JSON) */
const val KEY_AUTH_STATE = "auth_state"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
@ -111,14 +115,18 @@ class AccountSettings(
var currentlyUpdating = false
fun initialUserData(credentials: Credentials?): Bundle {
val bundle = Bundle(2)
val bundle = Bundle()
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
if (credentials.userName != null)
bundle.putString(KEY_USERNAME, credentials.userName)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
if (credentials.authState != null)
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
}
return bundle
@ -176,13 +184,24 @@ class AccountSettings(
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS),
accountManager.getUserData(account, KEY_AUTH_STATE)?.let { json ->
AuthState.jsonDeserialize(json)
}
)
fun credentials(credentials: Credentials) {
// Basic/Digest auth
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setPassword(account, credentials.password)
// client certificate
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
// OAuth
accountManager.setUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString())
}
@ -475,4 +494,4 @@ class AccountSettings(
}
}
}
}

View file

@ -12,7 +12,7 @@ import android.content.SyncResult
import android.content.pm.PackageManager
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -9,7 +9,7 @@ import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.provider.CalendarContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger

View file

@ -9,7 +9,7 @@ import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.provider.ContactsContract
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.settings.AccountSettings

View file

@ -17,7 +17,7 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.DavUtils.sameTypeAs
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -10,7 +10,7 @@ import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger

View file

@ -27,6 +27,7 @@ import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.db.SyncStats
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.*
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.DebugInfoActivity

View file

@ -8,7 +8,7 @@ import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger

View file

@ -10,7 +10,7 @@ import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Build
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger

View file

@ -13,7 +13,7 @@ import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.SyncState
import at.bitfire.davdroid.log.Logger

View file

@ -16,7 +16,7 @@ import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection

View file

@ -14,7 +14,7 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.*
import at.bitfire.dav4jvm.DavResource
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection

View file

@ -67,11 +67,18 @@ class DefaultLoginCredentialsFragment : Fragment() {
}
v.login.setOnClickListener {
if (validate())
if (validate()) {
val nextFragment =
if (model.loginGoogle.value == true)
GoogleLoginFragment()
else
DetectConfigurationFragment()
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, DetectConfigurationFragment(), null)
.addToBackStack(null)
.commit()
.replace(android.R.id.content, nextFragment, null)
.addToBackStack(null)
.commit()
}
}
return v.root
@ -194,6 +201,10 @@ class DefaultLoginCredentialsFragment : Fragment() {
null
}
}
model.loginGoogle.value == true -> {
valid = true
}
}
return valid

View file

@ -25,9 +25,10 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) {
private var initialized = false
val loginWithEmailAddress = MutableLiveData<Boolean>()
val loginWithUrlAndUsername = MutableLiveData<Boolean>()
val loginAdvanced = MutableLiveData<Boolean>()
val loginWithEmailAddress = MutableLiveData(true)
val loginWithUrlAndUsername = MutableLiveData(false)
val loginAdvanced = MutableLiveData(false)
val loginGoogle = MutableLiveData(false)
val baseUrl = MutableLiveData<String>()
val baseUrlError = MutableLiveData<String>()
@ -42,14 +43,9 @@ class DefaultLoginCredentialsModel(app: Application): AndroidViewModel(app) {
val certificateAlias = MutableLiveData<String>()
val certificateAliasError = MutableLiveData<String>()
val loginUseUsernamePassword = MutableLiveData<Boolean>()
val loginUseClientCertificate = MutableLiveData<Boolean>()
val loginUseUsernamePassword = MutableLiveData(false)
val loginUseClientCertificate = MutableLiveData(false)
init {
loginWithEmailAddress.value = true
loginUseClientCertificate.value = false
loginUseUsernamePassword.value = false
}
fun clearUrlError(s: Editable) {
if (s.toString() != "https://") {

View file

@ -28,13 +28,13 @@ import kotlin.concurrent.thread
class DetectConfigurationFragment: Fragment() {
val loginModel by activityViewModels<LoginModel>()
val model by viewModels<DetectConfigurationModel>()
private val loginModel by activityViewModels<LoginModel>()
private val model by viewModels<DetectConfigurationModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.detectConfiguration(loginModel).observe(this, { result ->
model.detectConfiguration(loginModel).observe(this) { result ->
// save result for next step
loginModel.configuration = result
@ -50,7 +50,7 @@ class DetectConfigurationFragment: Fragment() {
parentFragmentManager.beginTransaction()
.add(NothingDetectedFragment(), null)
.commit()
})
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
@ -127,4 +127,4 @@ class DetectConfigurationFragment: Fragment() {
}
}
}

View file

@ -0,0 +1,197 @@
/*
* 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.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
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.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.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.GoogleOAuth
import at.bitfire.davdroid.ui.UiUtils
import com.google.accompanist.themeadapter.material.MdcTheme
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.TokenResponse
import java.net.URI
class GoogleLoginFragment: Fragment() {
companion object {
fun googleBaseUri(googleAccount: String): URI =
URI("https", "www.google.com", "/calendar/dav/$googleAccount/events/", 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
Logger.log.warning("Couldn't obtain authorization code")
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = ComposeView(requireActivity())
v.setContent {
GoogleLogin()
}
model.credentials.observe(viewLifecycleOwner) { credentials ->
loginModel.credentials = credentials
// continue with service detection
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, DetectConfigurationFragment(), null)
.addToBackStack(null)
.commit()
}
return v
}
@Composable
@Preview
fun GoogleLogin() {
MdcTheme {
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)) {
Text(
stringResource(R.string.login_google_guide),
modifier = Modifier.padding(vertical = 8.dp))
Button(
onClick = {
UiUtils.launchUri(requireActivity(), Uri.parse("https://www.davx5.com/tested-with/google"))
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
val email = remember { mutableStateOf("") }
val emailError = remember { mutableStateOf<Boolean>(false) }
OutlinedTextField(
email.value,
singleLine = true,
onValueChange = { account ->
email.value = account
loginModel.baseURI = googleBaseUri(account)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
label = { Text(stringResource(R.string.login_google_account)) },
isError = emailError.value,
placeholder = { Text("example@gmail.com") },
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
)
Button({
val valid = email.value.orEmpty().contains('@')
emailError.value = !valid
if (valid) {
val authRequest = GoogleOAuth.authRequestBuilder()
.setScopes(*GoogleOAuth.SCOPES)
.setLoginHint(email.value)
.build()
authRequestContract.launch(authRequest)
}
}, modifier = Modifier.wrapContentSize()) {
Text(stringResource(R.string.login_login))
}
Text(
stringResource(R.string.login_google_disclaimer, getString(R.string.app_name)),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 24.dp))
}
}
}
class Model(application: Application): AndroidViewModel(application) {
val authService = GoogleOAuth.createAuthService(getApplication())
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()
}
}
}

View file

@ -24,7 +24,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.log.Logger
@ -271,10 +271,10 @@ class NextcloudLoginFlowFragment: Fragment() {
class Factory @Inject constructor(): LoginCredentialsFragmentFactory {
override fun getFragment(intent: Intent) =
if (intent.hasExtra(EXTRA_LOGIN_FLOW))
NextcloudLoginFlowFragment()
else
null
if (intent.hasExtra(EXTRA_LOGIN_FLOW))
NextcloudLoginFlowFragment()
else
null
}

View file

@ -18,7 +18,7 @@ import androidx.lifecycle.lifecycleScope
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.App
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityAddWebdavMountBinding
import at.bitfire.davdroid.db.AppDatabase

View file

@ -8,7 +8,7 @@ import android.content.Context
import android.net.ConnectivityManager
import android.os.Build
import androidx.core.content.getSystemService
import at.bitfire.davdroid.Android10Resolver
import at.bitfire.davdroid.network.Android10Resolver
import at.bitfire.davdroid.log.Logger
import okhttp3.HttpUrl
import okhttp3.MediaType

View file

@ -30,8 +30,8 @@ import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.MemoryCookieStore
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.network.MemoryCookieStore
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.WebDavDocument

View file

@ -7,7 +7,7 @@ package at.bitfire.davdroid.webdav
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import okhttp3.HttpUrl
import java.util.*
import java.util.concurrent.Callable

View file

@ -22,6 +22,7 @@ import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.*
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.ui.NotificationUtils
import at.bitfire.davdroid.ui.NotificationUtils.notifyIfPossible
import at.bitfire.davdroid.util.DavUtils

View file

@ -12,7 +12,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.NotificationUtils

View file

@ -312,9 +312,18 @@
</LinearLayout>
</LinearLayout>
<RadioButton
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:checked="@={model.loginGoogle}"
android:paddingStart="14dp"
android:text="@string/login_type_google"
android:textAlignment="viewStart" />
</RadioGroup>
</ScrollView>

View file

@ -292,6 +292,10 @@
<string name="login_use_client_certificate">Use client certificate</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>
<string name="login_google_guide">Please read our guide about the login to Google. There may be unexpected warnings which are explained there.</string>
<string name="login_google_account">Google account</string>
<string name="login_google_disclaimer">%s is not affiliated to, nor has it been authorized, sponsored or otherwise approved by Google LLC.</string>
<string name="login_configuration_detection">Configuration detection</string>
<string name="login_querying_server">Please wait, querying server…</string>