Update Tasks.org sign in

* Replace JWT with session authentication
* Add support for GitHub sign in
pull/1244/head
Alex Baker 3 years ago
parent bacee7c781
commit 3a34721b12

@ -203,7 +203,6 @@ dependencies {
implementation("com.etesync:journalmanager:1.1.1")
implementation("com.etebase:client:2.3.2")
implementation("com.github.QuadFlask:colorpicker:0.0.15")
implementation("androidx.security:security-crypto:1.1.0-alpha02")
implementation("com.github.openid:AppAuth-Android:27b62d5")
// https://github.com/mapbox/mapbox-gl-native-android/issues/316

@ -829,18 +829,6 @@
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html
- artifact: androidx.security:security-crypto:+
name: security-crypto
copyrightHolder: Android Open Source Project
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: https://developer.android.com/jetpack/androidx/releases/security#1.1.0-alpha02
- artifact: com.google.crypto.tink:tink-android:+
name: tink-android
copyrightHolder: Google Inc.
license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://github.com/google/tink
- artifact: com.etebase:client:+
name: client
copyrightHolder: Tom Hacohen

@ -0,0 +1,11 @@
{
"client_id": "a50fdbf3e289a7fb2fc6",
"redirect_uri": "org.tasks.github.a50fdbf3e289a7fb2fc6://oauth2redirect",
"authorization_scope": "none",
"discovery_uri": "https://caldav.tasks.org/oauth/github-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",
"user_info_endpoint_uri": "",
"https_required": true
}

@ -2,7 +2,7 @@
"client_id": "1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf:/oauth2redirect",
"authorization_scope": "openid email profile",
"discovery_uri": "https://accounts.google.com/.well-known/openid-configuration",
"discovery_uri": "https://caldav.tasks.org/oauth/google-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",

@ -0,0 +1,11 @@
{
"client_id": "a50fdbf3e289a7fb2fc6",
"redirect_uri": "org.tasks.github.a50fdbf3e289a7fb2fc6://oauth2redirect",
"authorization_scope": "none",
"discovery_uri": "https://caldav.tasks.org/oauth/github-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",
"user_info_endpoint_uri": "",
"https_required": true
}

@ -2,7 +2,7 @@
"client_id": "363426363175-rg39b1q2302l6mlkup40l4d6ids4osiv.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.363426363175-rg39b1q2302l6mlkup40l4d6ids4osiv:/oauth2redirect",
"authorization_scope": "openid email profile",
"discovery_uri": "https://accounts.google.com/.well-known/openid-configuration",
"discovery_uri": "https://caldav.tasks.org/oauth/google-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",

@ -0,0 +1,11 @@
{
"client_id": "a50fdbf3e289a7fb2fc6",
"redirect_uri": "org.tasks.github.a50fdbf3e289a7fb2fc6://oauth2redirect",
"authorization_scope": "none",
"discovery_uri": "https://caldav.tasks.org/oauth/github-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",
"user_info_endpoint_uri": "",
"https_required": true
}

@ -2,7 +2,7 @@
"client_id": "363426363175-jdrijf7hql9030klgjcjlpi6k5spviif.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif:/oauth2redirect",
"authorization_scope": "openid email profile",
"discovery_uri": "https://accounts.google.com/.well-known/openid-configuration",
"discovery_uri": "https://caldav.tasks.org/oauth/google-configuration",
"authorization_endpoint_uri": "",
"token_endpoint_uri": "",
"registration_endpoint_uri": "",

@ -173,6 +173,12 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="@string/google_oauth_scheme" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.tasks.github.a50fdbf3e289a7fb2fc6" />
</intent-filter>
</activity>
<activity

@ -1977,34 +1977,6 @@
"url": "http://developer.android.com/tools/extras/support-library.html",
"libraryName": "browser"
},
{
"artifactId": {
"name": "security-crypto",
"group": "androidx.security",
"version": "+"
},
"copyrightHolder": "Android Open Source Project",
"copyrightStatement": "Copyright &copy; Android Open Source Project. All rights reserved.",
"license": "The Apache Software License, Version 2.0",
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"normalizedLicense": "apache2",
"url": "https://developer.android.com/jetpack/androidx/releases/security#1.1.0-alpha02",
"libraryName": "security-crypto"
},
{
"artifactId": {
"name": "tink-android",
"group": "com.google.crypto.tink",
"version": "+"
},
"copyrightHolder": "Google Inc.",
"copyrightStatement": "Copyright &copy; Google Inc. All rights reserved.",
"license": "The Apache Software License, Version 2.0",
"licenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt",
"normalizedLicense": "apache2",
"url": "http://github.com/google/tink",
"libraryName": "tink-android"
},
{
"artifactId": {
"name": "client",

@ -14,28 +14,10 @@
package org.tasks.auth
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.*
import org.json.JSONException
import timber.log.Timber
import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthStateManager @Inject constructor(@ApplicationContext private val context: Context) {
private val prefs = EncryptedSharedPreferences.create(
context,
STORE_NAME,
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val prefsLock = ReentrantLock()
class AuthStateManager {
private val currentAuthState = AtomicReference<AuthState>()
fun signOut() {
@ -56,7 +38,7 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
if (currentAuthState.get() != null) {
return currentAuthState.get()
}
val state = readState()
val state = AuthState()
return if (currentAuthState.compareAndSet(null, state)) {
state
} else {
@ -65,7 +47,6 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
}
fun replace(state: AuthState): AuthState {
writeState(state)
currentAuthState.set(state)
return state
}
@ -99,40 +80,4 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
current.update(response)
return replace(current)
}
private fun readState(): AuthState {
prefsLock.lock()
return try {
val currentState = prefs.getString(KEY_STATE, null)
?: return AuthState()
try {
AuthState.jsonDeserialize(currentState)
} catch (ex: JSONException) {
Timber.w("Failed to deserialize stored auth state - discarding")
AuthState()
}
} finally {
prefsLock.unlock()
}
}
private fun writeState(state: AuthState?) {
prefsLock.lock()
try {
val editor = prefs.edit()
if (state == null) {
editor.remove(KEY_STATE)
} else {
editor.putString(KEY_STATE, state.jsonSerializeString())
}
check(editor.commit()) { "Failed to write state to shared prefs" }
} finally {
prefsLock.unlock()
}
}
companion object {
private const val STORE_NAME = "AuthState"
private const val KEY_STATE = "state"
}
}

@ -12,10 +12,21 @@ import net.openid.appauth.browser.AnyBrowserMatcher
import kotlin.coroutines.suspendCoroutine
class AuthorizationService constructor(
val iss: String,
context: Context,
private val authStateManager: AuthStateManager,
val configuration: Configuration
debugConnectionBuilder: DebugConnectionBuilder
) {
val isGitHub = iss == ISS_GITHUB
val authStateManager = AuthStateManager()
val configuration = Configuration(
context,
when (iss) {
ISS_GOOGLE -> Configuration.GOOGLE_CONFIG
ISS_GITHUB -> Configuration.GITHUB_CONFIG
else -> throw IllegalArgumentException()
},
debugConnectionBuilder
)
private val authorizationService = AuthorizationService(
context,
AppAuthConfiguration.Builder()
@ -62,21 +73,8 @@ class AuthorizationService constructor(
}
}
suspend fun getFreshToken(): String? {
val authState = authStateManager.current
if (!authState.isAuthorized) {
return null
}
return withContext(Dispatchers.IO) {
suspendCoroutine { cont ->
authState.performActionWithFreshTokens(authorizationService) { _, idToken, exception ->
if (exception == null) {
cont.resumeWith(Result.success(idToken))
} else {
cont.resumeWith(Result.failure(exception))
}
}
}
}
companion object {
const val ISS_GOOGLE = "google"
const val ISS_GITHUB = "github"
}
}

@ -1,21 +0,0 @@
package org.tasks.auth
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.auth.Configuration.Companion.GOOGLE_CONFIG
import javax.inject.Inject
class AuthorizationServiceProvider @Inject constructor(
@ApplicationContext context: Context,
authStateManager: AuthStateManager
){
val google = AuthorizationService(
context,
authStateManager,
Configuration(context, GOOGLE_CONFIG)
)
fun dispose() {
google.dispose()
}
}

@ -24,6 +24,7 @@ import okio.buffer
import okio.source
import org.json.JSONException
import org.json.JSONObject
import org.tasks.BuildConfig
import org.tasks.R
import java.io.IOException
import java.nio.charset.StandardCharsets
@ -35,7 +36,8 @@ import java.nio.charset.StandardCharsets
*/
class Configuration constructor(
private val context: Context,
private val authConfig: Int
private val authConfig: Int,
debugConnectionBuilder: DebugConnectionBuilder
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private var configJson: JSONObject? = null
@ -90,7 +92,10 @@ class Configuration constructor(
val redirectUri: Uri
get() = mRedirectUri!!
val connectionBuilder: ConnectionBuilder = DefaultConnectionBuilder.INSTANCE
val connectionBuilder: ConnectionBuilder = when {
BuildConfig.DEBUG -> debugConnectionBuilder
else -> DefaultConnectionBuilder.INSTANCE
}
private val lastKnownConfigHash: String?
get() = prefs.getString(KEY_LAST_HASH, null)
@ -207,6 +212,7 @@ class Configuration constructor(
private const val PREFS_NAME = "config"
private const val KEY_LAST_HASH = "lastHash"
const val GOOGLE_CONFIG = R.raw.google_config
const val GITHUB_CONFIG = R.raw.github_config
}
init {

@ -0,0 +1,69 @@
/*
* Copyright 2016 The AppAuth for Android Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.tasks.auth
import android.content.Context
import android.net.Uri
import at.bitfire.cert4android.CustomCertManager
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.Preconditions
import net.openid.appauth.connectivity.ConnectionBuilder
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor
import org.tasks.preferences.Preferences
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
/**
* Creates [HttpURLConnection] instances using the default, platform-provided
* mechanism, with sensible production defaults.
*/
class DebugConnectionBuilder @Inject constructor(
@ApplicationContext private val context: Context,
private val interceptor: DebugNetworkInterceptor,
private val preferences: Preferences,
) : ConnectionBuilder {
var appInForeground: Boolean = true
@Throws(IOException::class)
override fun openConnection(uri: Uri): HttpURLConnection {
Preconditions.checkNotNull(uri, "url must not be null")
Preconditions.checkArgument(HTTPS_SCHEME == uri.scheme,
"only https connections are permitted")
val customCertManager = CustomCertManager(context)
customCertManager.appInForeground = appInForeground
val hostnameVerifier = customCertManager.hostnameVerifier(OkHostnameVerifier)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(customCertManager), null)
val conn = URL(uri.toString()).openConnection() as HttpsURLConnection
conn.connectTimeout = CONNECTION_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS
conn.instanceFollowRedirects = false
conn.hostnameVerifier = hostnameVerifier
conn.sslSocketFactory = sslContext.socketFactory
return conn
}
companion object {
private val CONNECTION_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(15).toInt()
private val READ_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10).toInt()
private const val HTTPS_SCHEME = "https"
}
}

@ -5,11 +5,14 @@ import org.json.JSONObject
class IdToken(idToken: String) {
private val parts: List<String> = idToken.split(".")
val json = JSONObject(String(Base64.decode(parts[1], Base64.DEFAULT)))
private val json = JSONObject(String(Base64.decode(parts[1], Base64.DEFAULT)))
val email: String
get() = json.getString("email")
val email: String?
get() = json.optString("email").takeIf { it.isNotBlank() }
val sub: String
get() = json.getString("sub")
val login: String?
get() = json.optString("login").takeIf { it.isNotBlank() }
}

@ -15,6 +15,11 @@ package org.tasks.auth
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.viewModels
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
@ -30,6 +35,7 @@ import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseDialog
import org.tasks.billing.PurchaseDialog.Companion.FRAG_TAG_PURCHASE_DIALOG
import org.tasks.billing.PurchaseDialog.Companion.newPurchaseDialog
import org.tasks.dialogs.DialogBuilder
import org.tasks.injection.InjectingAppCompatActivity
import org.tasks.themes.ThemeColor
import timber.log.Timber
@ -50,10 +56,9 @@ import javax.inject.Inject
*/
@AndroidEntryPoint
class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHandler {
@Inject lateinit var authorizationServiceProvider: AuthorizationServiceProvider
@Inject lateinit var authStateManager: AuthStateManager
@Inject lateinit var themeColor: ThemeColor
@Inject lateinit var inventory: Inventory
@Inject lateinit var dialogBuilder: DialogBuilder
private val viewModel: SignInViewModel by viewModels()
@ -63,16 +68,68 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
private var mAuthIntentLatch = CountDownLatch(1)
private val mExecutor: ExecutorService = newSingleThreadExecutor()
lateinit var authService: AuthorizationService
lateinit var configuration: Configuration
private lateinit var authService: AuthorizationService
private val configuration: Configuration
get() = authService.configuration
private val authStateManager: AuthStateManager
get() = authService.authStateManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.error.observe(this, this::handleError)
authService = authorizationServiceProvider.google
configuration = authService.configuration
val titles = resources.getStringArray(R.array.sign_in_titles)
val summaries = resources.getStringArray(R.array.sign_in_summaries)
val typedArray = resources.obtainTypedArray(R.array.sign_in_icons)
val icons = IntArray(typedArray.length())
for (i in icons.indices) {
icons[i] = typedArray.getResourceId(i, 0)
}
typedArray.recycle()
val adapter = object : BaseAdapter() {
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = layoutInflater.inflate(R.layout.simple_list_item_2_themed, null)
val icon = view.findViewById<ImageView>(R.id.image_view)
icon.setImageResource(icons[position])
view.findViewById<TextView>(R.id.text2).text = titles[position]
view.findViewById<TextView>(R.id.text1).text = summaries[position]
if (position == 1) {
icon.drawable.setTint(getColor(R.color.icon_tint))
}
return view
}
override fun getCount() = titles.size
override fun getItem(position: Int) = titles[position]
override fun getItemId(position: Int): Long = position.toLong()
}
val autoSelect = intent.getIntExtra(EXTRA_SELECT_SERVICE, -1)
if (autoSelect >= 0 && autoSelect < titles.size) {
selectService(autoSelect)
} else {
dialogBuilder.newDialog()
.setAdapter(adapter) { _, which -> selectService(which) }
.setOnCancelListener { finish() }
.show()
}
}
private fun selectService(which: Int) {
viewModel.initializeAuthService(when (which) {
0 -> AuthorizationService.ISS_GOOGLE
1 -> AuthorizationService.ISS_GITHUB
else -> throw IllegalArgumentException()
})
viewModel.authService?.let { startAuthorization(it) }
}
private fun startAuthorization(authService: AuthorizationService) {
this.authService = authService
if (authStateManager.current.isAuthorized &&
!configuration.hasConfigurationChanged()) {
@ -94,23 +151,17 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
private fun handleError(e: Throwable) {
if (e is HttpException && e.code == 402) {
newPurchaseDialog(tasksPayment = true)
newPurchaseDialog(tasksPayment = true, github = authService.isGitHub)
.show(supportFragmentManager, FRAG_TAG_PURCHASE_DIALOG)
} else {
returnError(e.message)
}
}
override fun onStop() {
super.onStop()
mExecutor.shutdownNow()
}
override fun onDestroy() {
super.onDestroy()
authService.dispose()
mExecutor.shutdownNow()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -118,7 +169,7 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
if (resultCode == RESULT_OK) {
lifecycleScope.launch {
val account = try {
viewModel.handleResult(data!!)
viewModel.handleResult(authService, data!!)
} catch (e: Exception) {
returnError(e.message)
}
@ -302,13 +353,14 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
companion object {
const val EXTRA_ERROR = "extra_error"
const val EXTRA_SELECT_SERVICE = "extra_select_service"
private const val RC_AUTH = 100
}
override fun onPurchaseDialogDismissed() {
if (inventory.subscription?.isTasksSubscription == true) {
lifecycleScope.launch {
val account = viewModel.setupAccount(authStateManager.current)
val account = viewModel.setupAccount(authService)
if (account != null) {
setResult(RESULT_OK)
finish()

@ -7,28 +7,38 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.todoroo.astrid.helper.UUIDHelper
import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod
import net.openid.appauth.GrantTypeValues
import net.openid.appauth.TokenRequest
import org.tasks.R
import org.tasks.caldav.CaldavClientProvider
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao
import org.tasks.security.KeyStoreEncryption
import timber.log.Timber
class SignInViewModel @ViewModelInject constructor(
@ApplicationContext private val context: Context,
private val authStateManager: AuthStateManager,
private val authorizationServiceProvider: AuthorizationServiceProvider,
private val provider: CaldavClientProvider,
private val caldavDao: CaldavDao
private val caldavDao: CaldavDao,
private val encryption: KeyStoreEncryption,
private val debugConnectionBuilder: DebugConnectionBuilder
) : ViewModel() {
val error = MutableLiveData<Throwable>()
suspend fun handleResult(intent: Intent): CaldavAccount? {
var authService: AuthorizationService? = null
fun initializeAuthService(iss: String) {
authService?.dispose()
authService = AuthorizationService(iss, context, debugConnectionBuilder)
}
suspend fun handleResult(authService: AuthorizationService, intent: Intent): CaldavAccount? {
val response = AuthorizationResponse.fromIntent(intent)
val ex = AuthorizationException.fromIntent(intent)
val authStateManager = authService.authStateManager
if (response != null || ex != null) {
authStateManager.updateAfterAuthorization(response, ex)
@ -36,7 +46,7 @@ class SignInViewModel @ViewModelInject constructor(
if (response?.authorizationCode != null) {
authStateManager.updateAfterAuthorization(response, ex)
exchangeAuthorizationCode(response)
exchangeAuthorizationCode(authService, response)
}
ex?.let {
@ -46,32 +56,36 @@ class SignInViewModel @ViewModelInject constructor(
return authStateManager.current
.takeIf { it.isAuthorized }
?.let { setupAccount(it) }
?.let { setupAccount(authService) }
}
suspend fun setupAccount(auth: AuthState): CaldavAccount? {
val tokenString = auth.idToken ?: return null
val idToken = IdToken(tokenString)
val username = "google_${idToken.sub}"
suspend fun setupAccount(authService: AuthorizationService): CaldavAccount? {
val auth = authService.authStateManager.current
val tokenString = auth.accessToken ?: return null
val idToken = auth.idToken?.let { IdToken(it) } ?: return null
try {
val homeSet = provider
.forUrl(
"${context.getString(R.string.tasks_caldav_url)}/google_login",
context.getString(R.string.tasks_caldav_url),
token = tokenString
)
.setForeground()
.homeSet(token = tokenString)
val username = "${authService.iss}_${idToken.sub}"
val password = encryption.encrypt(tokenString)
return caldavDao.getAccount(CaldavAccount.TYPE_TASKS, username)
?.apply {
error = null
this.password = password
caldavDao.update(this)
}
?: CaldavAccount().apply {
accountType = CaldavAccount.TYPE_TASKS
uuid = UUIDHelper.newUUID()
url = homeSet
this.username = username
name = idToken.email
this.password = password
url = homeSet
name = idToken.email ?: idToken.login
caldavDao.insert(this)
}
} catch (e: Exception) {
@ -80,24 +94,49 @@ class SignInViewModel @ViewModelInject constructor(
return null
}
private suspend fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) {
val request = authorizationResponse.createTokenExchangeRequest()
private suspend fun exchangeAuthorizationCode(
authService: AuthorizationService,
authorizationResponse: AuthorizationResponse
) {
val authStateManager = authService.authStateManager
val request = if (authService.isGitHub) {
authorizationResponse.createGithubTokenRequest()
} else {
authorizationResponse.createTokenExchangeRequest()
}
val clientAuthentication = try {
authStateManager.current.clientAuthentication
} catch (ex: UnsupportedAuthenticationMethod) {
throw ex
}
try {
authorizationServiceProvider
.google
.performTokenRequest(request, clientAuthentication)?.let {
authStateManager.updateAfterTokenResponse(it, null)
if (authStateManager.current.isAuthorized) {
Timber.d("Authorization successful")
}
}
authService.performTokenRequest(request, clientAuthentication)?.let {
authStateManager.updateAfterTokenResponse(it, null)
if (authStateManager.current.isAuthorized) {
Timber.d("Authorization successful")
}
}
} catch (e: AuthorizationException) {
Timber.e(e)
authStateManager.updateAfterTokenResponse(null, e)
}
}
override fun onCleared() {
authService?.dispose()
}
companion object {
fun AuthorizationResponse.createGithubTokenRequest(): TokenRequest {
checkNotNull(authorizationCode) { "authorizationCode not available for exchange request" }
return TokenRequest
.Builder(request.configuration, request.clientId)
.setGrantType(GrantTypeValues.AUTHORIZATION_CODE)
.setRedirectUri(request.redirectUri)
.setCodeVerifier(request.codeVerifier)
.setAuthorizationCode(authorizationCode)
.setAdditionalParameters(emptyMap())
.build()
}
}
}

@ -9,6 +9,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.method.LinkMovementMethod
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@ -77,11 +78,34 @@ class PurchaseDialog : DialogFragment(), OnPurchasesUpdated {
setWaitScreen(BuildConfig.FLAVOR != "generic")
return dialogBuilder.newDialog()
.setView(binding.root)
.show()
return if (BuildConfig.FLAVOR == "generic") {
if (isGitHub) {
getPurchaseDialog()
} else {
getMessageDialog(R.string.no_google_play_subscription)
}
} else {
if (isGitHub) {
getMessageDialog(R.string.insufficient_sponsorship)
} else {
getPurchaseDialog()
}
}
}
private fun getPurchaseDialog(): AlertDialog =
dialogBuilder.newDialog().setView(binding.root).show()
private fun getMessageDialog(res: Int): AlertDialog =
dialogBuilder.newDialog()
.setMessage(res)
.setPositiveButton(R.string.ok, null)
.setNeutralButton(R.string.help) { _, _ ->
val url = Uri.parse(getString(R.string.subscription_help_url))
startActivity(Intent(Intent.ACTION_VIEW, url))
}
.show()
private fun updateText() {
var benefits = "### ${getString(R.string.upgrade_header)}"
benefits += if (nameYourPrice) {
@ -268,20 +292,29 @@ class PurchaseDialog : DialogFragment(), OnPurchasesUpdated {
private val isTasksPayment: Boolean
get() = arguments?.getBoolean(EXTRA_TASKS_PAYMENT, false) ?: false
private val isGitHub: Boolean
get() = arguments?.getBoolean(EXTRA_GITHUB, false) ?: false
companion object {
private const val EXTRA_PRICE = "extra_price"
private const val EXTRA_PRICE_CHANGED = "extra_price_changed"
private const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price"
private const val EXTRA_TASKS_PAYMENT = "extra_tasks_payment"
private const val EXTRA_GITHUB = "extra_github"
@JvmStatic
val FRAG_TAG_PURCHASE_DIALOG = "frag_tag_purchase_dialog"
@JvmStatic
@JvmOverloads
fun newPurchaseDialog(tasksPayment: Boolean = false): PurchaseDialog {
fun newPurchaseDialog(
tasksPayment: Boolean = false,
github: Boolean = BuildConfig.FLAVOR == "generic"
): PurchaseDialog {
val dialog = PurchaseDialog()
val args = Bundle()
args.putBoolean(EXTRA_TASKS_PAYMENT, tasksPayment)
args.putBoolean(EXTRA_GITHUB, github)
dialog.arguments = args
return dialog
}

@ -13,8 +13,4 @@ class AddCaldavAccountViewModel @ViewModelInject constructor(
.setForeground()
.homeSet(username, password) }
}
override fun onCleared() {
provider.dispose()
}
}

@ -41,14 +41,6 @@ class CaldavClient(
suspend fun forAccount(account: CaldavAccount) =
provider.forAccount(account)
@Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
suspend fun forUrl(
url: String?,
username: String,
password: String,
token: String? = null
): CaldavClient = provider.forUrl(url, username, password, token)
@WorkerThread
@Throws(DavException::class, IOException::class)
private fun tryFindPrincipal(link: String): String? {

@ -12,7 +12,6 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor
import org.tasks.auth.AuthorizationServiceProvider
import org.tasks.billing.Inventory
import org.tasks.data.CaldavAccount
import org.tasks.preferences.Preferences
@ -26,8 +25,7 @@ class CaldavClientProvider @Inject constructor(
private val encryption: KeyStoreEncryption,
private val preferences: Preferences,
private val interceptor: DebugNetworkInterceptor,
private val inventory: Inventory,
private val authorizationServiceProvider: AuthorizationServiceProvider
private val inventory: Inventory
) {
suspend fun forUrl(
url: String?,
@ -59,7 +57,7 @@ class CaldavClientProvider @Inject constructor(
CustomCertManager(context)
}
private suspend fun getAuthInterceptor(
private fun getAuthInterceptor(
account: CaldavAccount? = null,
username: String? = account?.username,
password: String? = account?.getPassword(encryption),
@ -67,9 +65,8 @@ class CaldavClientProvider @Inject constructor(
): Interceptor? {
return when {
account?.isTasksOrg == true ->
authorizationServiceProvider
.google
.getFreshToken()
account.password
?.let { encryption.decrypt(it) }
?.let { TokenInterceptor(it, inventory) }
username?.isNotBlank() == true && password?.isNotBlank() == true ->
BasicDigestAuthHandler(null, username, password)
@ -106,8 +103,4 @@ class CaldavClientProvider @Inject constructor(
return builder.build()
}
fun dispose() {
authorizationServiceProvider.dispose()
}
}

@ -30,7 +30,6 @@ import org.tasks.analytics.Firebase
import org.tasks.billing.Inventory
import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS
import org.tasks.data.CaldavCalendar
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
@ -65,15 +64,17 @@ class CaldavSynchronizer @Inject constructor(
suspend fun sync(account: CaldavAccount) {
Thread.currentThread().contextClassLoader = context.classLoader
if (account.accountType != TYPE_TASKS) {
if (!inventory.hasPro) {
setError(account, context.getString(R.string.requires_pro_subscription))
return
}
if (isNullOrEmpty(account.password)) {
setError(account, context.getString(R.string.password_required))
return
}
if (!inventory.hasPro && !account.isTasksOrg) {
setError(account, context.getString(R.string.requires_pro_subscription))
return
}
if (isNullOrEmpty(account.password)) {
setError(account, context.getString(if (account.isTasksOrg) {
R.string.authentication_required
} else {
R.string.password_required
}))
return
}
try {
synchronize(account)
@ -107,8 +108,6 @@ class CaldavSynchronizer @Inject constructor(
} catch (e: DavException) {
setError(account, e.message)
firebase.reportException(e)
} finally {
provider.dispose()
}
}

@ -10,8 +10,4 @@ class CreateCalendarViewModel @ViewModelInject constructor(
suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) {
run { provider.forAccount(account).makeCollection(name, color) }
}
override fun onCleared() {
provider.dispose()
}
}

@ -13,8 +13,4 @@ class DeleteCalendarViewModel @ViewModelInject constructor(
calendar.url?.let { provider.forAccount(account, it).deleteCollection() }
}
}
override fun onCleared() {
provider.dispose()
}
}

@ -9,8 +9,4 @@ class UpdateCaldavAccountViewModel @ViewModelInject constructor(
suspend fun updateCaldavAccount(url: String, username: String, password: String) {
run { provider.forUrl(url, username, password).homeSet(username, password) }
}
override fun onCleared() {
provider.dispose()
}
}

@ -13,8 +13,4 @@ class UpdateCalendarViewModel @ViewModelInject constructor(
calendar.url?.let { provider.forAccount(account, it).updateCollection(name, color) }
}
}
override fun onCleared() {
provider.dispose()
}
}

@ -170,7 +170,7 @@ class CaldavAccount : Parcelable {
fun isTasksSubscription(context: Context): Boolean {
val caldavUrl = context.getString(R.string.tasks_caldav_url)
return url?.startsWith("https://${caldavUrl}/calendars/") == true &&
return url?.startsWith("${caldavUrl}/calendars/") == true &&
!isPaymentRequired() &&
!isLoggedOut()
}

@ -64,6 +64,12 @@ public class AlertDialogBuilder {
return this;
}
public AlertDialogBuilder setAdapter(
ListAdapter adapter, DialogInterface.OnClickListener onClickListener) {
builder.setAdapter(adapter, onClickListener);
return this;
}
public AlertDialogBuilder setView(View dialogView) {
builder.setView(dialogView);
return this;

@ -35,10 +35,6 @@ class MigrateLocalWork @WorkerInject constructor(
return Result.success()
}
override fun destroy() {
clientProvider.dispose()
}
companion object {
const val EXTRA_ACCOUNT = "extra_account"
}

@ -9,8 +9,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.BuildConfig
import org.tasks.R
import org.tasks.auth.AuthStateManager
import org.tasks.auth.IdToken
import org.tasks.auth.SignInActivity
import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao
@ -28,7 +26,6 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
@Inject lateinit var appWidgetManager: AppWidgetManager
@Inject lateinit var preferences: Preferences
@Inject lateinit var authStateManager: AuthStateManager
@Inject lateinit var caldavDao: CaldavDao
private val viewModel: PreferencesViewModel by activityViewModels()
@ -71,20 +68,11 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
val accounts = caldavDao.getAccounts(CaldavAccount.TYPE_TASKS)
if (accounts.isEmpty()) {
pref.setOnPreferenceClickListener { signIn() }
pref.summary = getString(R.string.sign_in_with_google)
pref.summary = getString(R.string.not_signed_in)
return
}
val idToken = authStateManager.current
.takeIf { it.isAuthorized }
?.idToken
?.let { IdToken(it) }
val account = idToken
?.let { token -> accounts.firstOrNull { it.username == "google_${token.sub}" } }
?: accounts.first().apply {
// auth state doesn't match any accounts
authStateManager.signOut()
}
pref.summary = idToken?.email ?: account.name
val account = accounts.first()
pref.summary = account.name
if (!account.error.isNullOrBlank()) {
pref.drawable = ContextCompat

@ -4,6 +4,7 @@ import android.app.Activity.RESULT_OK
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
@ -13,7 +14,6 @@ import kotlinx.coroutines.launch
import org.tasks.BuildConfig
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.auth.AuthStateManager
import org.tasks.auth.SignInActivity
import org.tasks.billing.BillingClient
import org.tasks.billing.Inventory
@ -29,7 +29,6 @@ import javax.inject.Inject
@AndroidEntryPoint
class TasksAccount : InjectingPreferenceFragment() {
@Inject lateinit var authStateManager: AuthStateManager
@Inject lateinit var taskDeleter: TaskDeleter
@Inject lateinit var billingClient: BillingClient
@Inject lateinit var inventory: Inventory
@ -69,10 +68,7 @@ class TasksAccount : InjectingPreferenceFragment() {
}
findPreference(R.string.upgrade_to_pro).setOnPreferenceClickListener {
PurchaseDialog
.newPurchaseDialog(this, REQUEST_PURCHASE)
.show(parentFragmentManager, PurchaseDialog.FRAG_TAG_PURCHASE_DIALOG)
false
showPurchaseDialog()
}
findPreference(R.string.button_unsubscribe).setOnPreferenceClickListener {
@ -90,19 +86,26 @@ class TasksAccount : InjectingPreferenceFragment() {
false
}
findPreference(R.string.sign_in_with_google).setOnPreferenceClickListener {
activity?.startActivityForResult(
Intent(activity, SignInActivity::class.java),
Synchronization.REQUEST_TASKS_ORG)
false
if (isGitHubAccount) {
findPreference(R.string.upgrade_to_pro).isVisible = false
findPreference(R.string.button_unsubscribe).isVisible = false
findPreference(R.string.refresh_purchases).isVisible = false
}
refreshUi()
}
private fun showPurchaseDialog(): Boolean {
PurchaseDialog
.newPurchaseDialog(this, REQUEST_PURCHASE)
.show(parentFragmentManager, PurchaseDialog.FRAG_TAG_PURCHASE_DIALOG)
return false
}
private fun removeAccount() = lifecycleScope.launch {
// try to delete session from caldav.tasks.org
taskDeleter.delete(caldavAccount)
authStateManager.signOut()
inventory.updateTasksSubscription()
activity?.onBackPressed()
}
@ -121,9 +124,67 @@ class TasksAccount : InjectingPreferenceFragment() {
localBroadcastManager.unregisterReceiver(purchaseReceiver)
}
private val isGitHubAccount: Boolean
get() = caldavAccount.username?.startsWith("github") == true
private fun refreshUi() {
(findPreference(R.string.sign_in_with_google) as IconPreference).apply {
isVisible = caldavAccount.isLoggedOut()
if (caldavAccount.error.isNullOrBlank()) {
isVisible = false
return
}
isVisible = true
when {
caldavAccount.isPaymentRequired() -> {
val subscription = inventory.subscription
if (isGitHubAccount) {
title = null
setSummary(R.string.insufficient_sponsorship)
if (BuildConfig.FLAVOR == "googleplay") {
onPreferenceClickListener = null
} else {
setOnPreferenceClickListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.url_sponsor))))
false
}
}
} else {
setOnPreferenceClickListener {
showPurchaseDialog()
}
if (subscription == null) {
setTitle(R.string.upgrade_to_pro)
setSummary(R.string.your_subscription_expired)
} else {
setTitle(R.string.manage_subscription)
setSummary(R.string.insufficient_subscription)
}
}
}
caldavAccount.isLoggedOut() -> {
setTitle(if (isGitHubAccount) {
R.string.sign_in_with_github
} else {
R.string.sign_in_with_google
})
setSummary(R.string.authentication_required)
setOnPreferenceClickListener {
activity?.startActivityForResult(
Intent(activity, SignInActivity::class.java)
.putExtra(
SignInActivity.EXTRA_SELECT_SERVICE,
if (isGitHubAccount) 1 else 0
),
Synchronization.REQUEST_TASKS_ORG)
false
}
}
else -> {
this.title = null
this.summary = caldavAccount.error
this.onPreferenceClickListener = null
}
}
iconVisible = true
}
@ -140,30 +201,20 @@ class TasksAccount : InjectingPreferenceFragment() {
}
val subscription = inventory.subscription
findPreference(R.string.upgrade_to_pro).apply {
if (caldavAccount.isPaymentRequired()) {
if (subscription == null) {
setTitle(R.string.upgrade_to_pro)
setSummary(R.string.your_subscription_expired)
} else {
setTitle(R.string.manage_subscription)
setSummary(R.string.insufficient_subscription)
}
title = getString(
if (subscription == null) {
R.string.upgrade_to_pro
} else {
R.string.manage_subscription
})
summary = if (subscription == null) {
null
} else {
title = getString(
if (subscription == null) {
R.string.upgrade_to_pro
} else {
R.string.manage_subscription
})
summary = if (subscription == null) {
null
} else {
val price = getString(
if (subscription.isMonthly) R.string.price_per_month else R.string.price_per_year,
(subscription.subscriptionPrice!! - .01).toString()
)
getString(R.string.current_subscription, price)
}
val price = getString(
if (subscription.isMonthly) R.string.price_per_month else R.string.price_per_year,
(subscription.subscriptionPrice!! - .01).toString()
)
getString(R.string.current_subscription, price)
}
}
findPreference(R.string.button_unsubscribe).isEnabled = inventory.subscription != null

@ -238,4 +238,19 @@
<item>1</item>
<item>2</item>
</string-array>
<string-array name="sign_in_titles">
<item>@string/google_play_subscribers</item>
<item>@string/github_sponsors</item>
</string-array>
<string-array name="sign_in_summaries">
<item>@string/sign_in_with_google</item>
<item>@string/sign_in_with_github</item>
</string-array>
<array name="sign_in_icons">
<item>@drawable/ic_google</item>
<item>@drawable/ic_octocat</item>
</array>
</resources>

@ -662,6 +662,8 @@ File %1$s contained %2$s.\n\n
<string name="logged_in">Logged in %s</string>
<string name="your_subscription_expired">Your subscription has expired. Subscribe now to resume service.</string>
<string name="insufficient_subscription">Insufficient subscription level. Please upgrade your subscription to resume service.</string>
<string name="insufficient_sponsorship">No eligible GitHub sponsorship found</string>
<string name="no_google_play_subscription">No eligible Google Play subscription found</string>
<string name="price_per_year">$%s/year</string>
<string name="price_per_year_abbreviated">$%s/yr</string>
<string name="price_per_month">$%s/month</string>
@ -670,7 +672,11 @@ File %1$s contained %2$s.\n\n
<string name="purchases_updated">Purchases updated</string>
<string name="follow_reddit">Follow r/tasks</string>
<string name="authorization_cancelled">Authorization cancelled</string>
<string name="not_signed_in">Not signed in</string>
<string name="google_play_subscribers">Google Play subscribers</string>
<string name="github_sponsors">GitHub Sponsors</string>
<string name="sign_in_with_google">Sign in with Google</string>
<string name="sign_in_with_github">Sign in with GitHub</string>
<string name="authentication_required">Authentication required</string>
<string name="github_sponsor">Sponsor</string>
<string name="migrate">Migrate</string>

@ -394,10 +394,6 @@
+| \--- com.squareup.okhttp3:logging-interceptor:3.12.1 -> 3.12.7 (*)
++--- com.github.QuadFlask:colorpicker:0.0.15
+| \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*)
++--- androidx.security:security-crypto:1.1.0-alpha02
+| +--- androidx.annotation:annotation:1.1.0
+| +--- com.google.crypto.tink:tink-android:1.4.0
+| \--- androidx.collection:collection:1.1.0 (*)
+\--- com.github.openid:AppAuth-Android:27b62d5
+ +--- androidx.browser:browser:1.2.0
+ | +--- androidx.core:core:1.1.0 -> 1.3.2 (*)

@ -506,10 +506,6 @@
+| \--- com.squareup.okhttp3:logging-interceptor:3.12.1 -> 3.12.7 (*)
++--- com.github.QuadFlask:colorpicker:0.0.15
+| \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*)
++--- androidx.security:security-crypto:1.1.0-alpha02
+| +--- androidx.annotation:annotation:1.1.0
+| +--- com.google.crypto.tink:tink-android:1.4.0
+| \--- androidx.collection:collection:1.1.0 (*)
+\--- com.github.openid:AppAuth-Android:27b62d5
+ +--- androidx.browser:browser:1.2.0
+ | +--- androidx.core:core:1.1.0 -> 1.3.2 (*)

Loading…
Cancel
Save