Add CaldavClientProvider with support for tokens

pull/1208/head
Alex Baker 4 years ago
parent 50c62a4114
commit 75d130556c

@ -211,6 +211,7 @@ dependencies {
googleplayImplementation("com.google.android.gms:play-services-maps:17.0.0")
googleplayImplementation("com.google.android.libraries.places:places:2.4.0")
googleplayImplementation("com.android.billingclient:billing:1.2.2")
googleplayImplementation("com.google.android.gms:play-services-auth:18.1.0")
androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.hilt}")
kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.hilt}")

@ -12,8 +12,8 @@ import java.io.IOException
import javax.inject.Inject
class DebugNetworkInterceptor @Inject constructor(@param:ApplicationContext private val context: Context) {
fun add(builder: OkHttpClient.Builder) {
builder.addNetworkInterceptor(FlipperOkhttpInterceptor(getNetworkPlugin(context)))
fun apply(builder: OkHttpClient.Builder?) {
builder?.addNetworkInterceptor(FlipperOkhttpInterceptor(getNetworkPlugin(context)))
}
@Throws(IOException::class)

@ -2,8 +2,7 @@ package org.tasks.billing
@Suppress("UNUSED_PARAMETER")
class Purchase(json: String?) {
val sku: String?
get() = null
val sku: String = ""
fun toJson(): String? {
return null
@ -20,4 +19,6 @@ class Purchase(json: String?) {
val isProSubscription: Boolean
get() = false
val purchaseToken = ""
}

@ -1,13 +1,17 @@
package org.tasks.gtasks
import android.app.Activity
import android.content.Intent
import com.todoroo.astrid.activity.MainActivity
import io.reactivex.disposables.Disposable
import io.reactivex.disposables.Disposables
import org.tasks.auth.OauthSignIn
import javax.inject.Inject
@Suppress("UNUSED_PARAMETER")
class PlayServices @Inject constructor() {
val signInIntent: Intent? = null
val isPlayServicesAvailable: Boolean
get() = false
@ -23,4 +27,8 @@ class PlayServices @Inject constructor() {
fun check(mainActivity: MainActivity?): Disposable {
return Disposables.empty()
}
fun getSignedInAccount(): OauthSignIn? = null
fun signInFromIntent(data: Intent?): OauthSignIn? = null
}

@ -0,0 +1,16 @@
package org.tasks.auth
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
class GoogleSignInAccount(
private val account: GoogleSignInAccount
) : OauthSignIn {
override val id: String?
get() = account.id
override val idToken: String?
get() = account.idToken
override val email: String?
get() = account.email
}

@ -2,11 +2,21 @@ package org.tasks.gtasks
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.widget.Toast
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Tasks
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.tasks.R
import org.tasks.auth.GoogleSignInAccount
import org.tasks.auth.OauthSignIn
import org.tasks.data.LocationDao
import org.tasks.preferences.Preferences
import timber.log.Timber
@ -53,6 +63,40 @@ class PlayServices @Inject constructor(
}
}
suspend fun getSignedInAccount(): OauthSignIn? {
return withContext(Dispatchers.IO) {
try {
Tasks
.await(client.silentSignIn())
?.let { GoogleSignInAccount(it) }
} catch (e: Exception) {
Timber.e(e)
null
}
}
}
fun signInFromIntent(data: Intent?): OauthSignIn? = try {
GoogleSignIn
.getSignedInAccountFromIntent(data)
.getResult(ApiException::class.java)
?.let { GoogleSignInAccount(it) }
} catch (e: ApiException) {
Timber.e(e)
null
}
val signInIntent: Intent
get() = client.signInIntent
private val client: GoogleSignInClient
get() = GoogleSignIn.getClient(
context,
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestEmail()
.requestIdToken(context.getString(R.string.google_sign_in))
.build())
private val status: String
get() = GoogleApiAvailability.getInstance().getErrorString(result)

@ -0,0 +1,7 @@
package org.tasks.auth
interface OauthSignIn {
val idToken: String?
val email: String?
val id: String?
}

@ -4,8 +4,13 @@ import androidx.hilt.lifecycle.ViewModelInject
import org.tasks.ui.CompletableViewModel
class AddCaldavAccountViewModel @ViewModelInject constructor(
private val client: CaldavClient) : CompletableViewModel<String>() {
private val provider: CaldavClientProvider
) : CompletableViewModel<String>() {
suspend fun addAccount(url: String, username: String, password: String) {
run { client.setForeground().forUrl(url, username, password).homeSet() }
run {
provider
.forUrl(url, username, password)
.setForeground()
.homeSet(username, password) }
}
}

@ -1,9 +1,7 @@
package org.tasks.caldav
import android.content.Context
import androidx.annotation.WorkerThread
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.Response.HrefRelation
@ -16,20 +14,13 @@ import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.*
import at.bitfire.dav4jvm.property.ResourceType.Companion.CALENDAR
import com.todoroo.astrid.helper.UUIDHelper
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavCalendar
import org.tasks.preferences.Preferences
import org.tasks.security.KeyStoreEncryption
import org.tasks.ui.DisplayableException
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
@ -40,95 +31,31 @@ import java.io.StringWriter
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.net.ssl.SSLContext
class CaldavClient {
private val encryption: KeyStoreEncryption
private val preferences: Preferences
private val interceptor: DebugNetworkInterceptor
val httpClient: OkHttpClient?
private val httpUrl: HttpUrl?
private val context: Context
private val basicDigestAuthHandler: BasicDigestAuthHandler?
private var foreground = false
@Inject
internal constructor(
@ApplicationContext context: Context,
encryption: KeyStoreEncryption,
preferences: Preferences,
interceptor: DebugNetworkInterceptor) {
this.context = context
this.encryption = encryption
this.preferences = preferences
this.interceptor = interceptor
httpClient = null
httpUrl = null
basicDigestAuthHandler = null
}
class CaldavClient(
private val provider: CaldavClientProvider,
private val customCertManager: CustomCertManager,
val httpClient: OkHttpClient,
private val httpUrl: HttpUrl?
) {
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
suspend fun forAccount(account: CaldavAccount) =
provider.forAccount(account)
private constructor(
context: Context,
encryption: KeyStoreEncryption,
preferences: Preferences,
interceptor: DebugNetworkInterceptor,
@Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
suspend fun forUrl(
url: String?,
username: String,
password: String,
foreground: Boolean) {
this.context = context
this.encryption = encryption
this.preferences = preferences
this.interceptor = interceptor
val customCertManager = CustomCertManager(context)
customCertManager.appInForeground = foreground
val hostnameVerifier = customCertManager.hostnameVerifier(OkHostnameVerifier)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(customCertManager), null)
basicDigestAuthHandler = BasicDigestAuthHandler(null, username, password)
val builder = OkHttpClient()
.newBuilder()
.addNetworkInterceptor(basicDigestAuthHandler)
.authenticator(basicDigestAuthHandler)
.cookieJar(MemoryCookieStore())
.followRedirects(false)
.followSslRedirects(true)
.sslSocketFactory(sslContext.socketFactory, customCertManager)
.hostnameVerifier(hostnameVerifier)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
if (preferences.isFlipperEnabled) {
interceptor.add(builder)
}
httpClient = builder.build()
httpUrl = url?.toHttpUrlOrNull()
}
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
suspend fun forAccount(account: CaldavAccount): CaldavClient {
return forUrl(account.url, account.username!!, account.getPassword(encryption))
}
@Throws(NoSuchAlgorithmException::class, KeyManagementException::class)
suspend fun forCalendar(account: CaldavAccount, calendar: CaldavCalendar): CaldavClient {
return forUrl(calendar.url, account.username!!, account.getPassword(encryption))
}
@Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
suspend fun forUrl(url: String?, username: String, password: String): CaldavClient = withContext(Dispatchers.IO) {
CaldavClient(
context, encryption, preferences, interceptor, url, username, password, foreground)
}
token: String? = null
): CaldavClient = provider.forUrl(url, username, password, token)
@WorkerThread
@Throws(DavException::class, IOException::class)
private fun tryFindPrincipal(link: String): String? {
val url = httpUrl!!.resolve(link)
Timber.d("Checking for principal: %s", url)
val davResource = DavResource(httpClient!!, url!!)
val davResource = DavResource(httpClient, url!!)
val responses = ArrayList<Response>()
davResource.propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
responses.add(response)
@ -149,7 +76,7 @@ class CaldavClient {
@WorkerThread
@Throws(DavException::class, IOException::class)
private fun findHomeset(): String {
val davResource = DavResource(httpClient!!, httpUrl!!)
val davResource = DavResource(httpClient, httpUrl!!)
val responses = ArrayList<Response>()
davResource.propfind(0, CalendarHomeSet.NAME) { response, _ ->
responses.add(response)
@ -169,7 +96,11 @@ class CaldavClient {
}
@Throws(IOException::class, DavException::class, NoSuchAlgorithmException::class, KeyManagementException::class)
suspend fun homeSet(): String = withContext(Dispatchers.IO) {
suspend fun homeSet(
username: String? = null,
password: String? = null,
token: String? = null
): String = withContext(Dispatchers.IO) {
var principal: String? = null
try {
principal = tryFindPrincipal("/.well-known/caldav")
@ -182,16 +113,17 @@ class CaldavClient {
if (principal == null) {
principal = tryFindPrincipal("")
}
forUrl(
provider.forUrl(
(if (isNullOrEmpty(principal)) httpUrl else httpUrl!!.resolve(principal!!)).toString(),
basicDigestAuthHandler!!.username,
basicDigestAuthHandler.password)
username,
password,
token)
.findHomeset()
}
@Throws(IOException::class, DavException::class)
suspend fun calendars(): List<Response> = withContext(Dispatchers.IO) {
val davResource = DavResource(httpClient!!, httpUrl!!)
val davResource = DavResource(httpClient, httpUrl!!)
val responses = ArrayList<Response>()
davResource.propfind(
1,
@ -226,12 +158,12 @@ class CaldavClient {
@Throws(IOException::class, HttpException::class)
suspend fun deleteCollection() = withContext(Dispatchers.IO) {
DavResource(httpClient!!, httpUrl!!).delete(null) {}
DavResource(httpClient, httpUrl!!).delete(null) {}
}
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
suspend fun makeCollection(displayName: String, color: Int): String = withContext(Dispatchers.IO) {
val davResource = DavResource(httpClient!!, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!)
val davResource = DavResource(httpClient, httpUrl!!.resolve(UUIDHelper.newUUID() + "/")!!)
val mkcolString = getMkcolString(displayName, color)
davResource.mkCol(mkcolString) {}
davResource.location.toString()
@ -239,7 +171,7 @@ class CaldavClient {
@Throws(IOException::class, XmlPullParserException::class, HttpException::class)
suspend fun updateCollection(displayName: String, color: Int): String = withContext(Dispatchers.IO) {
val davResource = PatchableDavResource(httpClient!!, httpUrl!!)
val davResource = PatchableDavResource(httpClient, httpUrl!!)
davResource.propPatch(getPropPatchString(displayName, color)) {}
davResource.location.toString()
}
@ -328,7 +260,7 @@ class CaldavClient {
}
fun setForeground(): CaldavClient {
foreground = true
customCertManager.appInForeground = true
return this
}
}

@ -0,0 +1,107 @@
package org.tasks.caldav
import android.content.Context
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Authenticator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor
import org.tasks.billing.Inventory
import org.tasks.data.CaldavAccount
import org.tasks.gtasks.PlayServices
import org.tasks.preferences.Preferences
import org.tasks.security.KeyStoreEncryption
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.net.ssl.SSLContext
class CaldavClientProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val encryption: KeyStoreEncryption,
private val preferences: Preferences,
private val interceptor: DebugNetworkInterceptor,
private val playServices: PlayServices,
private val inventory: Inventory
) {
suspend fun forUrl(
url: String?,
username: String? = null,
password: String? = null,
token: String? = null): CaldavClient {
val auth = getAuthInterceptor(username = username, password = password, token = token)
val customCertManager = newCertManager()
return CaldavClient(
this,
customCertManager,
createHttpClient(auth, customCertManager),
url?.toHttpUrlOrNull()
)
}
suspend fun forAccount(account: CaldavAccount, url: String? = account.url): CaldavClient {
val auth = getAuthInterceptor(account)
val customCertManager = newCertManager()
return CaldavClient(
this,
customCertManager,
createHttpClient(auth, customCertManager),
url?.toHttpUrlOrNull()
)
}
private suspend fun newCertManager() = withContext(Dispatchers.Default) {
CustomCertManager(context)
}
private suspend fun getAuthInterceptor(
account: CaldavAccount? = null,
username: String? = account?.username,
password: String? = account?.getPassword(encryption),
token: String? = null
): Interceptor? {
return when {
account?.isTasksOrg == true -> playServices.getSignedInAccount()?.let {
TokenInterceptor(it.idToken!!, inventory)
}
username?.isNotBlank() == true && password?.isNotBlank() == true ->
BasicDigestAuthHandler(null, username, password)
token?.isNotBlank() == true ->
TokenInterceptor(token, inventory)
else -> null
}
}
private fun createHttpClient(auth: Interceptor?, customCertManager: CustomCertManager, foreground: Boolean = false): OkHttpClient {
customCertManager.appInForeground = foreground
val hostnameVerifier = customCertManager.hostnameVerifier(OkHostnameVerifier)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(customCertManager), null)
val builder = OkHttpClient()
.newBuilder()
.cookieJar(MemoryCookieStore())
.followRedirects(false)
.followSslRedirects(true)
.sslSocketFactory(sslContext.socketFactory, customCertManager)
.hostnameVerifier(hostnameVerifier)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
auth?.let {
builder.addNetworkInterceptor(it)
if (it is Authenticator) {
builder.authenticator(it)
}
}
if (preferences.isFlipperEnabled) {
interceptor.apply(builder)
}
return builder.build()
}
}

@ -54,7 +54,7 @@ class CaldavSynchronizer @Inject constructor(
private val taskDeleter: TaskDeleter,
private val inventory: Inventory,
private val firebase: Firebase,
private val client: CaldavClient,
private val provider: CaldavClientProvider,
private val iCal: iCalendar) {
companion object {
init {
@ -105,7 +105,7 @@ class CaldavSynchronizer @Inject constructor(
@Throws(IOException::class, DavException::class, KeyManagementException::class, NoSuchAlgorithmException::class)
private suspend fun synchronize(account: CaldavAccount) {
val caldavClient = client.forAccount(account)
val caldavClient = provider.forAccount(account)
val resources = caldavClient.calendars()
val urls = resources.map { it.href.toString() }.toHashSet()
Timber.d("Found calendars: %s", urls)
@ -132,7 +132,7 @@ class CaldavSynchronizer @Inject constructor(
caldavDao.update(calendar)
localBroadcastManager.broadcastRefreshList()
}
sync(calendar, resource, caldavClient.httpClient!!)
sync(calendar, resource, caldavClient.httpClient)
}
setError(account, "")
}

@ -5,8 +5,9 @@ import org.tasks.data.CaldavAccount
import org.tasks.ui.CompletableViewModel
class CreateCalendarViewModel @ViewModelInject constructor(
private val client: CaldavClient): CompletableViewModel<String?>() {
private val provider: CaldavClientProvider
): CompletableViewModel<String?>() {
suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) {
run { client.forAccount(account).makeCollection(name, color) }
run { provider.forAccount(account).makeCollection(name, color) }
}
}

@ -6,8 +6,11 @@ import org.tasks.data.CaldavCalendar
import org.tasks.ui.ActionViewModel
class DeleteCalendarViewModel @ViewModelInject constructor(
private val client: CaldavClient) : ActionViewModel() {
private val provider: CaldavClientProvider
) : ActionViewModel() {
suspend fun deleteCalendar(account: CaldavAccount, calendar: CaldavCalendar) {
run { client.forCalendar(account, calendar).deleteCollection() }
run {
calendar.url?.let { provider.forAccount(account, it).deleteCollection() }
}
}
}

@ -0,0 +1,25 @@
package org.tasks.caldav
import okhttp3.Interceptor
import okhttp3.Response
import org.tasks.billing.Inventory
class TokenInterceptor(
private val token: String,
private val inventory: Inventory
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder().header(AUTHORIZATION, "Bearer $token")
inventory.subscription?.let {
builder.header(SKU, it.sku)
builder.header(TOKEN, it.purchaseToken)
}
return chain.proceed(builder.build())
}
companion object {
private const val AUTHORIZATION = "Authorization"
private const val SKU = "tasks-sku"
private const val TOKEN = "tasks-token"
}
}

@ -4,8 +4,9 @@ import androidx.hilt.lifecycle.ViewModelInject
import org.tasks.ui.CompletableViewModel
class UpdateCaldavAccountViewModel @ViewModelInject constructor(
private val client: CaldavClient) : CompletableViewModel<String>() {
private val provider: CaldavClientProvider
) : CompletableViewModel<String>() {
suspend fun updateCaldavAccount(url: String, username: String, password: String) {
run { client.forUrl(url, username, password).homeSet() }
run { provider.forUrl(url, username, password).homeSet(username, password) }
}
}

@ -6,8 +6,11 @@ import org.tasks.data.CaldavCalendar
import org.tasks.ui.CompletableViewModel
class UpdateCalendarViewModel @ViewModelInject constructor(
private val client: CaldavClient) : CompletableViewModel<String?>() {
private val provider: CaldavClientProvider
) : CompletableViewModel<String?>() {
suspend fun updateCalendar(account: CaldavAccount, calendar: CaldavCalendar, name: String, color: Int) {
run { client.forCalendar(account, calendar).updateCollection(name, color) }
run {
calendar.url?.let { provider.forAccount(account, it).updateCollection(name, color) }
}
}
}

@ -104,7 +104,7 @@ class EteSyncClient {
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
if (preferences.isFlipperEnabled) {
interceptor.add(builder)
interceptor.apply(builder)
}
httpClient = builder.build()
httpUrl = url?.toHttpUrlOrNull()

@ -115,7 +115,7 @@ class Preferences @JvmOverloads constructor(
}
fun setPurchases(purchases: Collection<Purchase>) {
setPurchases(purchases.map(Purchase::toJson).toHashSet())
setPurchases(purchases.mapNotNull(Purchase::toJson).toHashSet())
}
fun setPurchases(set: HashSet<String>) {

@ -6,7 +6,7 @@ import okhttp3.OkHttpClient
import javax.inject.Inject
class DebugNetworkInterceptor @Inject constructor() {
fun add(builder: OkHttpClient.Builder?) {}
fun apply(builder: OkHttpClient.Builder?) {}
fun <T> execute(httpRequest: HttpRequest?, responseClass: Class<T>?): T? = null
fun <T> report(httpResponse: HttpResponse?, responseClass: Class<T>?, start: Long, finish: Long): T? = null
}
Loading…
Cancel
Save