Update Tasks.org sign in

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

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

@ -829,18 +829,6 @@
license: The Apache Software License, Version 2.0 license: The Apache Software License, Version 2.0
licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
url: http://developer.android.com/tools/extras/support-library.html 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:+ - artifact: com.etebase:client:+
name: client name: client
copyrightHolder: Tom Hacohen 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", "client_id": "1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf:/oauth2redirect", "redirect_uri": "com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf:/oauth2redirect",
"authorization_scope": "openid email profile", "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": "", "authorization_endpoint_uri": "",
"token_endpoint_uri": "", "token_endpoint_uri": "",
"registration_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", "client_id": "363426363175-rg39b1q2302l6mlkup40l4d6ids4osiv.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.363426363175-rg39b1q2302l6mlkup40l4d6ids4osiv:/oauth2redirect", "redirect_uri": "com.googleusercontent.apps.363426363175-rg39b1q2302l6mlkup40l4d6ids4osiv:/oauth2redirect",
"authorization_scope": "openid email profile", "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": "", "authorization_endpoint_uri": "",
"token_endpoint_uri": "", "token_endpoint_uri": "",
"registration_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", "client_id": "363426363175-jdrijf7hql9030klgjcjlpi6k5spviif.apps.googleusercontent.com",
"redirect_uri": "com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif:/oauth2redirect", "redirect_uri": "com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif:/oauth2redirect",
"authorization_scope": "openid email profile", "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": "", "authorization_endpoint_uri": "",
"token_endpoint_uri": "", "token_endpoint_uri": "",
"registration_endpoint_uri": "", "registration_endpoint_uri": "",

@ -173,6 +173,12 @@
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="@string/google_oauth_scheme" /> <data android:scheme="@string/google_oauth_scheme" />
</intent-filter> </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>
<activity <activity

@ -1977,34 +1977,6 @@
"url": "http://developer.android.com/tools/extras/support-library.html", "url": "http://developer.android.com/tools/extras/support-library.html",
"libraryName": "browser" "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": { "artifactId": {
"name": "client", "name": "client",

@ -14,28 +14,10 @@
package org.tasks.auth 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 net.openid.appauth.*
import org.json.JSONException
import timber.log.Timber
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import java.util.concurrent.locks.ReentrantLock
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class AuthStateManager {
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()
private val currentAuthState = AtomicReference<AuthState>() private val currentAuthState = AtomicReference<AuthState>()
fun signOut() { fun signOut() {
@ -56,7 +38,7 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
if (currentAuthState.get() != null) { if (currentAuthState.get() != null) {
return currentAuthState.get() return currentAuthState.get()
} }
val state = readState() val state = AuthState()
return if (currentAuthState.compareAndSet(null, state)) { return if (currentAuthState.compareAndSet(null, state)) {
state state
} else { } else {
@ -65,7 +47,6 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
} }
fun replace(state: AuthState): AuthState { fun replace(state: AuthState): AuthState {
writeState(state)
currentAuthState.set(state) currentAuthState.set(state)
return state return state
} }
@ -99,40 +80,4 @@ class AuthStateManager @Inject constructor(@ApplicationContext private val conte
current.update(response) current.update(response)
return replace(current) 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 import kotlin.coroutines.suspendCoroutine
class AuthorizationService constructor( class AuthorizationService constructor(
val iss: String,
context: Context, context: Context,
private val authStateManager: AuthStateManager, debugConnectionBuilder: DebugConnectionBuilder
val configuration: Configuration
) { ) {
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( private val authorizationService = AuthorizationService(
context, context,
AppAuthConfiguration.Builder() AppAuthConfiguration.Builder()
@ -62,21 +73,8 @@ class AuthorizationService constructor(
} }
} }
suspend fun getFreshToken(): String? { companion object {
val authState = authStateManager.current const val ISS_GOOGLE = "google"
if (!authState.isAuthorized) { const val ISS_GITHUB = "github"
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))
}
}
}
}
} }
} }

@ -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 okio.source
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import java.io.IOException import java.io.IOException
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
@ -35,7 +36,8 @@ import java.nio.charset.StandardCharsets
*/ */
class Configuration constructor( class Configuration constructor(
private val context: Context, 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 val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private var configJson: JSONObject? = null private var configJson: JSONObject? = null
@ -90,7 +92,10 @@ class Configuration constructor(
val redirectUri: Uri val redirectUri: Uri
get() = mRedirectUri!! get() = mRedirectUri!!
val connectionBuilder: ConnectionBuilder = DefaultConnectionBuilder.INSTANCE val connectionBuilder: ConnectionBuilder = when {
BuildConfig.DEBUG -> debugConnectionBuilder
else -> DefaultConnectionBuilder.INSTANCE
}
private val lastKnownConfigHash: String? private val lastKnownConfigHash: String?
get() = prefs.getString(KEY_LAST_HASH, null) get() = prefs.getString(KEY_LAST_HASH, null)
@ -207,6 +212,7 @@ class Configuration constructor(
private const val PREFS_NAME = "config" private const val PREFS_NAME = "config"
private const val KEY_LAST_HASH = "lastHash" private const val KEY_LAST_HASH = "lastHash"
const val GOOGLE_CONFIG = R.raw.google_config const val GOOGLE_CONFIG = R.raw.google_config
const val GITHUB_CONFIG = R.raw.github_config
} }
init { 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) { class IdToken(idToken: String) {
private val parts: List<String> = idToken.split(".") 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 val email: String?
get() = json.getString("email") get() = json.optString("email").takeIf { it.isNotBlank() }
val sub: String val sub: String
get() = json.getString("sub") 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.content.Intent
import android.os.Bundle 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.activity.viewModels
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.MainThread import androidx.annotation.MainThread
@ -30,6 +35,7 @@ import org.tasks.billing.Inventory
import org.tasks.billing.PurchaseDialog import org.tasks.billing.PurchaseDialog
import org.tasks.billing.PurchaseDialog.Companion.FRAG_TAG_PURCHASE_DIALOG import org.tasks.billing.PurchaseDialog.Companion.FRAG_TAG_PURCHASE_DIALOG
import org.tasks.billing.PurchaseDialog.Companion.newPurchaseDialog import org.tasks.billing.PurchaseDialog.Companion.newPurchaseDialog
import org.tasks.dialogs.DialogBuilder
import org.tasks.injection.InjectingAppCompatActivity import org.tasks.injection.InjectingAppCompatActivity
import org.tasks.themes.ThemeColor import org.tasks.themes.ThemeColor
import timber.log.Timber import timber.log.Timber
@ -50,10 +56,9 @@ import javax.inject.Inject
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHandler { class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHandler {
@Inject lateinit var authorizationServiceProvider: AuthorizationServiceProvider
@Inject lateinit var authStateManager: AuthStateManager
@Inject lateinit var themeColor: ThemeColor @Inject lateinit var themeColor: ThemeColor
@Inject lateinit var inventory: Inventory @Inject lateinit var inventory: Inventory
@Inject lateinit var dialogBuilder: DialogBuilder
private val viewModel: SignInViewModel by viewModels() private val viewModel: SignInViewModel by viewModels()
@ -63,16 +68,68 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
private var mAuthIntentLatch = CountDownLatch(1) private var mAuthIntentLatch = CountDownLatch(1)
private val mExecutor: ExecutorService = newSingleThreadExecutor() private val mExecutor: ExecutorService = newSingleThreadExecutor()
lateinit var authService: AuthorizationService private lateinit var authService: AuthorizationService
lateinit var configuration: Configuration
private val configuration: Configuration
get() = authService.configuration
private val authStateManager: AuthStateManager
get() = authService.authStateManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.error.observe(this, this::handleError) viewModel.error.observe(this, this::handleError)
authService = authorizationServiceProvider.google val titles = resources.getStringArray(R.array.sign_in_titles)
configuration = authService.configuration 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 && if (authStateManager.current.isAuthorized &&
!configuration.hasConfigurationChanged()) { !configuration.hasConfigurationChanged()) {
@ -94,23 +151,17 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
private fun handleError(e: Throwable) { private fun handleError(e: Throwable) {
if (e is HttpException && e.code == 402) { if (e is HttpException && e.code == 402) {
newPurchaseDialog(tasksPayment = true) newPurchaseDialog(tasksPayment = true, github = authService.isGitHub)
.show(supportFragmentManager, FRAG_TAG_PURCHASE_DIALOG) .show(supportFragmentManager, FRAG_TAG_PURCHASE_DIALOG)
} else { } else {
returnError(e.message) returnError(e.message)
} }
} }
override fun onStop() {
super.onStop()
mExecutor.shutdownNow()
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
authService.dispose() mExecutor.shutdownNow()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -118,7 +169,7 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
if (resultCode == RESULT_OK) { if (resultCode == RESULT_OK) {
lifecycleScope.launch { lifecycleScope.launch {
val account = try { val account = try {
viewModel.handleResult(data!!) viewModel.handleResult(authService, data!!)
} catch (e: Exception) { } catch (e: Exception) {
returnError(e.message) returnError(e.message)
} }
@ -302,13 +353,14 @@ class SignInActivity : InjectingAppCompatActivity(), PurchaseDialog.PurchaseHand
companion object { companion object {
const val EXTRA_ERROR = "extra_error" const val EXTRA_ERROR = "extra_error"
const val EXTRA_SELECT_SERVICE = "extra_select_service"
private const val RC_AUTH = 100 private const val RC_AUTH = 100
} }
override fun onPurchaseDialogDismissed() { override fun onPurchaseDialogDismissed() {
if (inventory.subscription?.isTasksSubscription == true) { if (inventory.subscription?.isTasksSubscription == true) {
lifecycleScope.launch { lifecycleScope.launch {
val account = viewModel.setupAccount(authStateManager.current) val account = viewModel.setupAccount(authService)
if (account != null) { if (account != null) {
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()

@ -7,28 +7,38 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.todoroo.astrid.helper.UUIDHelper import com.todoroo.astrid.helper.UUIDHelper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod
import net.openid.appauth.GrantTypeValues
import net.openid.appauth.TokenRequest
import org.tasks.R import org.tasks.R
import org.tasks.caldav.CaldavClientProvider import org.tasks.caldav.CaldavClientProvider
import org.tasks.data.CaldavAccount import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
import org.tasks.security.KeyStoreEncryption
import timber.log.Timber import timber.log.Timber
class SignInViewModel @ViewModelInject constructor( class SignInViewModel @ViewModelInject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val authStateManager: AuthStateManager,
private val authorizationServiceProvider: AuthorizationServiceProvider,
private val provider: CaldavClientProvider, private val provider: CaldavClientProvider,
private val caldavDao: CaldavDao private val caldavDao: CaldavDao,
private val encryption: KeyStoreEncryption,
private val debugConnectionBuilder: DebugConnectionBuilder
) : ViewModel() { ) : ViewModel() {
val error = MutableLiveData<Throwable>() 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 response = AuthorizationResponse.fromIntent(intent)
val ex = AuthorizationException.fromIntent(intent) val ex = AuthorizationException.fromIntent(intent)
val authStateManager = authService.authStateManager
if (response != null || ex != null) { if (response != null || ex != null) {
authStateManager.updateAfterAuthorization(response, ex) authStateManager.updateAfterAuthorization(response, ex)
@ -36,7 +46,7 @@ class SignInViewModel @ViewModelInject constructor(
if (response?.authorizationCode != null) { if (response?.authorizationCode != null) {
authStateManager.updateAfterAuthorization(response, ex) authStateManager.updateAfterAuthorization(response, ex)
exchangeAuthorizationCode(response) exchangeAuthorizationCode(authService, response)
} }
ex?.let { ex?.let {
@ -46,32 +56,36 @@ class SignInViewModel @ViewModelInject constructor(
return authStateManager.current return authStateManager.current
.takeIf { it.isAuthorized } .takeIf { it.isAuthorized }
?.let { setupAccount(it) } ?.let { setupAccount(authService) }
} }
suspend fun setupAccount(auth: AuthState): CaldavAccount? { suspend fun setupAccount(authService: AuthorizationService): CaldavAccount? {
val tokenString = auth.idToken ?: return null val auth = authService.authStateManager.current
val idToken = IdToken(tokenString) val tokenString = auth.accessToken ?: return null
val username = "google_${idToken.sub}" val idToken = auth.idToken?.let { IdToken(it) } ?: return null
try { try {
val homeSet = provider val homeSet = provider
.forUrl( .forUrl(
"${context.getString(R.string.tasks_caldav_url)}/google_login", context.getString(R.string.tasks_caldav_url),
token = tokenString token = tokenString
) )
.setForeground() .setForeground()
.homeSet(token = tokenString) .homeSet(token = tokenString)
val username = "${authService.iss}_${idToken.sub}"
val password = encryption.encrypt(tokenString)
return caldavDao.getAccount(CaldavAccount.TYPE_TASKS, username) return caldavDao.getAccount(CaldavAccount.TYPE_TASKS, username)
?.apply { ?.apply {
error = null error = null
this.password = password
caldavDao.update(this) caldavDao.update(this)
} }
?: CaldavAccount().apply { ?: CaldavAccount().apply {
accountType = CaldavAccount.TYPE_TASKS accountType = CaldavAccount.TYPE_TASKS
uuid = UUIDHelper.newUUID() uuid = UUIDHelper.newUUID()
url = homeSet
this.username = username this.username = username
name = idToken.email this.password = password
url = homeSet
name = idToken.email ?: idToken.login
caldavDao.insert(this) caldavDao.insert(this)
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -80,24 +94,49 @@ class SignInViewModel @ViewModelInject constructor(
return null return null
} }
private suspend fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) { private suspend fun exchangeAuthorizationCode(
val request = authorizationResponse.createTokenExchangeRequest() authService: AuthorizationService,
authorizationResponse: AuthorizationResponse
) {
val authStateManager = authService.authStateManager
val request = if (authService.isGitHub) {
authorizationResponse.createGithubTokenRequest()
} else {
authorizationResponse.createTokenExchangeRequest()
}
val clientAuthentication = try { val clientAuthentication = try {
authStateManager.current.clientAuthentication authStateManager.current.clientAuthentication
} catch (ex: UnsupportedAuthenticationMethod) { } catch (ex: UnsupportedAuthenticationMethod) {
throw ex throw ex
} }
try { try {
authorizationServiceProvider authService.performTokenRequest(request, clientAuthentication)?.let {
.google authStateManager.updateAfterTokenResponse(it, null)
.performTokenRequest(request, clientAuthentication)?.let { if (authStateManager.current.isAuthorized) {
authStateManager.updateAfterTokenResponse(it, null) Timber.d("Authorization successful")
if (authStateManager.current.isAuthorized) { }
Timber.d("Authorization successful") }
}
}
} catch (e: AuthorizationException) { } catch (e: AuthorizationException) {
Timber.e(e)
authStateManager.updateAfterTokenResponse(null, 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.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -77,11 +78,34 @@ class PurchaseDialog : DialogFragment(), OnPurchasesUpdated {
setWaitScreen(BuildConfig.FLAVOR != "generic") setWaitScreen(BuildConfig.FLAVOR != "generic")
return dialogBuilder.newDialog() return if (BuildConfig.FLAVOR == "generic") {
.setView(binding.root) if (isGitHub) {
.show() 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() { private fun updateText() {
var benefits = "### ${getString(R.string.upgrade_header)}" var benefits = "### ${getString(R.string.upgrade_header)}"
benefits += if (nameYourPrice) { benefits += if (nameYourPrice) {
@ -268,20 +292,29 @@ class PurchaseDialog : DialogFragment(), OnPurchasesUpdated {
private val isTasksPayment: Boolean private val isTasksPayment: Boolean
get() = arguments?.getBoolean(EXTRA_TASKS_PAYMENT, false) ?: false get() = arguments?.getBoolean(EXTRA_TASKS_PAYMENT, false) ?: false
private val isGitHub: Boolean
get() = arguments?.getBoolean(EXTRA_GITHUB, false) ?: false
companion object { companion object {
private const val EXTRA_PRICE = "extra_price" private const val EXTRA_PRICE = "extra_price"
private const val EXTRA_PRICE_CHANGED = "extra_price_changed" private const val EXTRA_PRICE_CHANGED = "extra_price_changed"
private const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price" private const val EXTRA_NAME_YOUR_PRICE = "extra_name_your_price"
private const val EXTRA_TASKS_PAYMENT = "extra_tasks_payment" private const val EXTRA_TASKS_PAYMENT = "extra_tasks_payment"
private const val EXTRA_GITHUB = "extra_github"
@JvmStatic @JvmStatic
val FRAG_TAG_PURCHASE_DIALOG = "frag_tag_purchase_dialog" val FRAG_TAG_PURCHASE_DIALOG = "frag_tag_purchase_dialog"
@JvmStatic @JvmStatic
@JvmOverloads @JvmOverloads
fun newPurchaseDialog(tasksPayment: Boolean = false): PurchaseDialog { fun newPurchaseDialog(
tasksPayment: Boolean = false,
github: Boolean = BuildConfig.FLAVOR == "generic"
): PurchaseDialog {
val dialog = PurchaseDialog() val dialog = PurchaseDialog()
val args = Bundle() val args = Bundle()
args.putBoolean(EXTRA_TASKS_PAYMENT, tasksPayment) args.putBoolean(EXTRA_TASKS_PAYMENT, tasksPayment)
args.putBoolean(EXTRA_GITHUB, github)
dialog.arguments = args dialog.arguments = args
return dialog return dialog
} }

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

@ -41,14 +41,6 @@ class CaldavClient(
suspend fun forAccount(account: CaldavAccount) = suspend fun forAccount(account: CaldavAccount) =
provider.forAccount(account) 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 @WorkerThread
@Throws(DavException::class, IOException::class) @Throws(DavException::class, IOException::class)
private fun tryFindPrincipal(link: String): String? { private fun tryFindPrincipal(link: String): String? {

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

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

@ -10,8 +10,4 @@ class CreateCalendarViewModel @ViewModelInject constructor(
suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) { suspend fun createCalendar(account: CaldavAccount, name: String, color: Int) {
run { provider.forAccount(account).makeCollection(name, color) } 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() } 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) { suspend fun updateCaldavAccount(url: String, username: String, password: String) {
run { provider.forUrl(url, username, password).homeSet(username, password) } 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) } 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 { fun isTasksSubscription(context: Context): Boolean {
val caldavUrl = context.getString(R.string.tasks_caldav_url) val caldavUrl = context.getString(R.string.tasks_caldav_url)
return url?.startsWith("https://${caldavUrl}/calendars/") == true && return url?.startsWith("${caldavUrl}/calendars/") == true &&
!isPaymentRequired() && !isPaymentRequired() &&
!isLoggedOut() !isLoggedOut()
} }

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

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

@ -9,8 +9,6 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.tasks.BuildConfig import org.tasks.BuildConfig
import org.tasks.R import org.tasks.R
import org.tasks.auth.AuthStateManager
import org.tasks.auth.IdToken
import org.tasks.auth.SignInActivity import org.tasks.auth.SignInActivity
import org.tasks.data.CaldavAccount import org.tasks.data.CaldavAccount
import org.tasks.data.CaldavDao import org.tasks.data.CaldavDao
@ -28,7 +26,6 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
@Inject lateinit var appWidgetManager: AppWidgetManager @Inject lateinit var appWidgetManager: AppWidgetManager
@Inject lateinit var preferences: Preferences @Inject lateinit var preferences: Preferences
@Inject lateinit var authStateManager: AuthStateManager
@Inject lateinit var caldavDao: CaldavDao @Inject lateinit var caldavDao: CaldavDao
private val viewModel: PreferencesViewModel by activityViewModels() private val viewModel: PreferencesViewModel by activityViewModels()
@ -71,20 +68,11 @@ class MainSettingsFragment : InjectingPreferenceFragment() {
val accounts = caldavDao.getAccounts(CaldavAccount.TYPE_TASKS) val accounts = caldavDao.getAccounts(CaldavAccount.TYPE_TASKS)
if (accounts.isEmpty()) { if (accounts.isEmpty()) {
pref.setOnPreferenceClickListener { signIn() } pref.setOnPreferenceClickListener { signIn() }
pref.summary = getString(R.string.sign_in_with_google) pref.summary = getString(R.string.not_signed_in)
return return
} }
val idToken = authStateManager.current val account = accounts.first()
.takeIf { it.isAuthorized } pref.summary = account.name
?.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
if (!account.error.isNullOrBlank()) { if (!account.error.isNullOrBlank()) {
pref.drawable = ContextCompat pref.drawable = ContextCompat

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

@ -238,4 +238,19 @@
<item>1</item> <item>1</item>
<item>2</item> <item>2</item>
</string-array> </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> </resources>

@ -662,6 +662,8 @@ File %1$s contained %2$s.\n\n
<string name="logged_in">Logged in %s</string> <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="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_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">$%s/year</string>
<string name="price_per_year_abbreviated">$%s/yr</string> <string name="price_per_year_abbreviated">$%s/yr</string>
<string name="price_per_month">$%s/month</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="purchases_updated">Purchases updated</string>
<string name="follow_reddit">Follow r/tasks</string> <string name="follow_reddit">Follow r/tasks</string>
<string name="authorization_cancelled">Authorization cancelled</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_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="authentication_required">Authentication required</string>
<string name="github_sponsor">Sponsor</string> <string name="github_sponsor">Sponsor</string>
<string name="migrate">Migrate</string> <string name="migrate">Migrate</string>

@ -394,10 +394,6 @@
+| \--- com.squareup.okhttp3:logging-interceptor:3.12.1 -> 3.12.7 (*) +| \--- com.squareup.okhttp3:logging-interceptor:3.12.1 -> 3.12.7 (*)
++--- com.github.QuadFlask:colorpicker:0.0.15 ++--- com.github.QuadFlask:colorpicker:0.0.15
+| \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*) +| \--- 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 +\--- com.github.openid:AppAuth-Android:27b62d5
+ +--- androidx.browser:browser:1.2.0 + +--- androidx.browser:browser:1.2.0
+ | +--- androidx.core:core:1.1.0 -> 1.3.2 (*) + | +--- 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.squareup.okhttp3:logging-interceptor:3.12.1 -> 3.12.7 (*)
++--- com.github.QuadFlask:colorpicker:0.0.15 ++--- com.github.QuadFlask:colorpicker:0.0.15
+| \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*) +| \--- 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 +\--- com.github.openid:AppAuth-Android:27b62d5
+ +--- androidx.browser:browser:1.2.0 + +--- androidx.browser:browser:1.2.0
+ | +--- androidx.core:core:1.1.0 -> 1.3.2 (*) + | +--- androidx.core:core:1.1.0 -> 1.3.2 (*)

Loading…
Cancel
Save