diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7d9962587..6822dfedc 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -93,6 +93,10 @@ android {
resValue("string", "mapbox_key", tasks_mapbox_key_debug ?: "")
resValue("string", "google_key", tasks_google_key_debug ?: "")
isTestCoverageEnabled = project.hasProperty("coverage")
+
+ setManifestPlaceholders(mapOf(
+ "appAuthRedirectScheme" to "com.googleusercontent.apps.1006257750459-vf4mvft1b3rfda8b4c4bl4k4418abqlf"
+ ))
}
getByName("release") {
val tasks_mapbox_key: String? by project
@@ -102,6 +106,10 @@ android {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard.pro")
signingConfig = signingConfigs.getByName("release")
+
+ setManifestPlaceholders(mapOf(
+ "appAuthRedirectScheme" to "com.googleusercontent.apps.363426363175-jdrijf7hql9030klgjcjlpi6k5spviif"
+ ))
}
}
@@ -202,6 +210,8 @@ dependencies {
implementation("com.mapbox.mapboxsdk:mapbox-sdk-services:5.3.0")
implementation("com.etesync:journalmanager:1.1.1")
implementation("com.github.QuadFlask:colorpicker:0.0.15")
+ implementation("androidx.security:security-crypto:1.1.0-alpha02")
+ implementation("net.openid:appauth:0.7.1")
// https://github.com/mapbox/mapbox-gl-native-android/issues/316
genericImplementation("com.mapbox.mapboxsdk:mapbox-android-sdk:7.4.1")
@@ -216,7 +226,6 @@ dependencies {
googleplayImplementation("com.google.android.gms:play-services-maps:17.0.0")
googleplayImplementation("com.google.android.libraries.places:places:2.4.0")
googleplayImplementation("com.android.billingclient:billing:1.2.2")
- googleplayImplementation("com.google.android.gms:play-services-auth:19.0.0")
androidTestImplementation("com.google.dagger:hilt-android-testing:${Versions.hilt}")
kaptAndroidTest("com.google.dagger:hilt-compiler:${Versions.hilt}")
diff --git a/app/licenses.yml b/app/licenses.yml
index dd06aab9a..29f727d4b 100644
--- a/app/licenses.yml
+++ b/app/licenses.yml
@@ -823,3 +823,69 @@
name: commonmark
copyrightHolder: Atlassian and others
license: BSD 2-Clause
+- artifact: net.openid:appauth:+
+ name: appauth
+ copyrightHolder: The AppAuth for Android Authors
+ license: The Apache Software License, Version 2.0
+ licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
+ url: https://github.com/openid/AppAuth-Android
+- artifact: androidx.browser:browser:+
+ name: browser
+ copyrightHolder: Android Open Source Project
+ 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.legacy:legacy-support-core-ui:+
+ name: legacy-support-core-ui
+ copyrightHolder: Android Open Source Project
+ 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.legacy:legacy-support-core-utils:+
+ name: legacy-support-core-utils
+ copyrightHolder: Android Open Source Project
+ 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.slidingpanelayout:slidingpanelayout:+
+ name: slidingpanelayout
+ copyrightHolder: Android Open Source Project
+ 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.asynclayoutinflater:asynclayoutinflater:+
+ name: asynclayoutinflater
+ copyrightHolder: Android Open Source Project
+ 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.documentfile:documentfile:+
+ name: documentfile
+ copyrightHolder: Android Open Source Project
+ 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.localbroadcastmanager:localbroadcastmanager:+
+ name: localbroadcastmanager
+ copyrightHolder: Android Open Source Project
+ 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.print:print:+
+ name: print
+ copyrightHolder: Android Open Source Project
+ 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: Apache License, Version 2.0
+ licenseUrl: http://www.apache.org/licenses/LICENSE-2.0.txt
+ url: http://github.com/google/tink
diff --git a/app/src/debug/res/raw/auth_config.json b/app/src/debug/res/raw/auth_config.json
new file mode 100644
index 000000000..1f8499225
--- /dev/null
+++ b/app/src/debug/res/raw/auth_config.json
@@ -0,0 +1,11 @@
+{
+ "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",
+ "authorization_endpoint_uri": "",
+ "token_endpoint_uri": "",
+ "registration_endpoint_uri": "",
+ "user_info_endpoint_uri": "",
+ "https_required": true
+}
diff --git a/app/src/debug/res/values/keys.xml b/app/src/debug/res/values/keys.xml
index 085259b22..c6252a632 100644
--- a/app/src/debug/res/values/keys.xml
+++ b/app/src/debug/res/values/keys.xml
@@ -2,7 +2,6 @@
Tasks Debug
https://192.168.1.120:8443
- 1006257750459-3jt0e32kbqgug7hkluqe26d5mbno92no.apps.googleusercontent.com
Strict mode - Thread
Strict mode - VM
LeakCanary
diff --git a/app/src/generic/java/org/tasks/gtasks/PlayServices.kt b/app/src/generic/java/org/tasks/gtasks/PlayServices.kt
index 0da8832a4..f5c58f65d 100644
--- a/app/src/generic/java/org/tasks/gtasks/PlayServices.kt
+++ b/app/src/generic/java/org/tasks/gtasks/PlayServices.kt
@@ -1,17 +1,13 @@
package org.tasks.gtasks
import android.app.Activity
-import android.content.Intent
import com.todoroo.astrid.activity.MainActivity
import io.reactivex.disposables.Disposable
import io.reactivex.disposables.Disposables
-import org.tasks.auth.OauthSignIn
import javax.inject.Inject
@Suppress("UNUSED_PARAMETER")
class PlayServices @Inject constructor() {
- val signInIntent: Intent? = null
-
val isPlayServicesAvailable: Boolean
get() = false
@@ -27,8 +23,4 @@ class PlayServices @Inject constructor() {
fun check(mainActivity: MainActivity?): Disposable {
return Disposables.empty()
}
-
- fun getSignedInAccount(): OauthSignIn? = null
-
- fun signInFromIntent(data: Intent?): OauthSignIn? = null
}
\ No newline at end of file
diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml
index 5b04ba4ff..568ab3743 100644
--- a/app/src/googleplay/AndroidManifest.xml
+++ b/app/src/googleplay/AndroidManifest.xml
@@ -15,10 +15,6 @@
-
-
diff --git a/app/src/googleplay/java/org/tasks/auth/GoogleSignInAccount.kt b/app/src/googleplay/java/org/tasks/auth/GoogleSignInAccount.kt
deleted file mode 100644
index 9ab77a8ed..000000000
--- a/app/src/googleplay/java/org/tasks/auth/GoogleSignInAccount.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.tasks.auth
-
-import com.google.android.gms.auth.api.signin.GoogleSignInAccount
-
-class GoogleSignInAccount(
- private val account: GoogleSignInAccount
-) : OauthSignIn {
- override val id: String?
- get() = account.id
-
- override val idToken: String?
- get() = account.idToken
-
- override val email: String?
- get() = account.email
-}
\ No newline at end of file
diff --git a/app/src/googleplay/java/org/tasks/auth/SignInActivity.kt b/app/src/googleplay/java/org/tasks/auth/SignInActivity.kt
deleted file mode 100644
index 0f0718144..000000000
--- a/app/src/googleplay/java/org/tasks/auth/SignInActivity.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.tasks.auth
-
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.viewModels
-import androidx.lifecycle.lifecycleScope
-import at.bitfire.dav4jvm.exception.HttpException
-import dagger.hilt.android.AndroidEntryPoint
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import org.tasks.R
-import org.tasks.analytics.Firebase
-import org.tasks.billing.PurchaseDialog.Companion.FRAG_TAG_PURCHASE_DIALOG
-import org.tasks.billing.PurchaseDialog.Companion.newPurchaseDialog
-import org.tasks.caldav.CaldavClientProvider
-import org.tasks.data.CaldavAccount
-import org.tasks.gtasks.PlayServices
-import org.tasks.injection.InjectingAppCompatActivity
-import org.tasks.ui.Toaster
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class SignInActivity : InjectingAppCompatActivity() {
-
- @Inject lateinit var toaster: Toaster
- @Inject lateinit var provider: CaldavClientProvider
- @Inject lateinit var playServices: PlayServices
- @Inject lateinit var firebase: Firebase
-
- val viewModel: SignInViewModel by viewModels()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- viewModel.observe(this, this::onSignIn, this::onError)
-
- lifecycleScope.launch {
- playServices
- .getSignedInAccount()
- ?.let { validate(it) }
- ?: startActivityForResult(playServices.signInIntent, RC_SIGN_IN)
- }
- }
-
- private fun onSignIn(account: CaldavAccount?) {
- account?.let { toaster.longToast(getString(R.string.logged_in, it.name)) }
- finish()
- }
-
- private fun onError(t: Throwable) {
- if (t is HttpException && t.code == 402) {
- newPurchaseDialog(true).show(supportFragmentManager, FRAG_TAG_PURCHASE_DIALOG)
- } else {
- firebase.reportException(t)
- toaster.longToast(t.message)
- finish()
- }
- }
-
- override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
- if (requestCode == RC_SIGN_IN) {
- playServices
- .signInFromIntent(data)
- ?.let { validate(it) }
- } else {
- super.onActivityResult(requestCode, resultCode, data)
- }
- }
-
- private fun validate(account: OauthSignIn) = lifecycleScope.launch(Dispatchers.IO) {
- viewModel.validate(account.id!!, account.email!!, account.idToken!!)
- }
-
- companion object {
- private const val RC_SIGN_IN = 10000
- }
-}
\ No newline at end of file
diff --git a/app/src/googleplay/java/org/tasks/auth/SignInViewModel.kt b/app/src/googleplay/java/org/tasks/auth/SignInViewModel.kt
deleted file mode 100644
index 00766e871..000000000
--- a/app/src/googleplay/java/org/tasks/auth/SignInViewModel.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package org.tasks.auth
-
-import android.content.Context
-import androidx.hilt.lifecycle.ViewModelInject
-import com.todoroo.astrid.helper.UUIDHelper
-import dagger.hilt.android.qualifiers.ApplicationContext
-import org.tasks.R
-import org.tasks.caldav.CaldavClientProvider
-import org.tasks.data.CaldavAccount
-import org.tasks.data.CaldavAccount.Companion.TYPE_TASKS
-import org.tasks.data.CaldavDao
-import org.tasks.ui.CompletableViewModel
-
-class SignInViewModel @ViewModelInject constructor(
- @ApplicationContext private val context: Context,
- private val provider: CaldavClientProvider,
- private val caldavDao: CaldavDao
-) : CompletableViewModel() {
-
- suspend fun validate(id: String, email: String, idToken: String) {
- run {
- val homeSet = provider
- .forUrl(
- "${context.getString(R.string.tasks_caldav_url)}/google_login",
- token = idToken
- )
- .setForeground()
- .homeSet(token = idToken)
- val username = "google_$id"
- caldavDao.getAccount(TYPE_TASKS, username)
- ?.apply {
- error = null
- caldavDao.update(this)
- }
- ?: CaldavAccount().apply {
- accountType = TYPE_TASKS
- uuid = UUIDHelper.newUUID()
- url = homeSet
- this.username = username
- name = email
- caldavDao.insert(this)
- }
- }
- }
-}
diff --git a/app/src/googleplay/java/org/tasks/gtasks/PlayServices.kt b/app/src/googleplay/java/org/tasks/gtasks/PlayServices.kt
index a058b3dec..029e42937 100644
--- a/app/src/googleplay/java/org/tasks/gtasks/PlayServices.kt
+++ b/app/src/googleplay/java/org/tasks/gtasks/PlayServices.kt
@@ -2,21 +2,11 @@ package org.tasks.gtasks
import android.app.Activity
import android.content.Context
-import android.content.Intent
import android.widget.Toast
-import com.google.android.gms.auth.api.signin.GoogleSignIn
-import com.google.android.gms.auth.api.signin.GoogleSignInClient
-import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
-import com.google.android.gms.common.api.ApiException
-import com.google.android.gms.tasks.Tasks
import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
import org.tasks.R
-import org.tasks.auth.GoogleSignInAccount
-import org.tasks.auth.OauthSignIn
import org.tasks.data.LocationDao
import org.tasks.preferences.Preferences
import timber.log.Timber
@@ -63,40 +53,6 @@ class PlayServices @Inject constructor(
}
}
- suspend fun getSignedInAccount(): OauthSignIn? {
- return withContext(Dispatchers.IO) {
- try {
- Tasks
- .await(client.silentSignIn())
- ?.let { GoogleSignInAccount(it) }
- } catch (e: Exception) {
- Timber.e(e)
- null
- }
- }
- }
-
- fun signInFromIntent(data: Intent?): OauthSignIn? = try {
- GoogleSignIn
- .getSignedInAccountFromIntent(data)
- .getResult(ApiException::class.java)
- ?.let { GoogleSignInAccount(it) }
- } catch (e: ApiException) {
- Timber.e(e)
- null
- }
-
- val signInIntent: Intent
- get() = client.signInIntent
-
- private val client: GoogleSignInClient
- get() = GoogleSignIn.getClient(
- context,
- GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
- .requestEmail()
- .requestIdToken(context.getString(R.string.google_sign_in))
- .build())
-
private val status: String
get() = GoogleApiAvailability.getInstance().getErrorString(result)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 30234d52b..d3ef2fd59 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -134,6 +134,19 @@
android:name="android.hardware.touchscreen"
android:required="false"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/licenses.json b/app/src/main/assets/licenses.json
index eae8dfe63..436d01396 100644
--- a/app/src/main/assets/licenses.json
+++ b/app/src/main/assets/licenses.json
@@ -1962,6 +1962,160 @@
"license": "BSD 2-Clause",
"normalizedLicense": "bsd_2_clauses",
"libraryName": "commonmark"
+ },
+ {
+ "artifactId": {
+ "name": "appauth",
+ "group": "net.openid",
+ "version": "+"
+ },
+ "copyrightHolder": "The AppAuth for Android Authors",
+ "copyrightStatement": "Copyright © The AppAuth for Android Authors. 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://github.com/openid/AppAuth-Android",
+ "libraryName": "appauth"
+ },
+ {
+ "artifactId": {
+ "name": "browser",
+ "group": "androidx.browser",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "browser"
+ },
+ {
+ "artifactId": {
+ "name": "legacy-support-core-ui",
+ "group": "androidx.legacy",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "legacy-support-core-ui"
+ },
+ {
+ "artifactId": {
+ "name": "legacy-support-core-utils",
+ "group": "androidx.legacy",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "legacy-support-core-utils"
+ },
+ {
+ "artifactId": {
+ "name": "slidingpanelayout",
+ "group": "androidx.slidingpanelayout",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "slidingpanelayout"
+ },
+ {
+ "artifactId": {
+ "name": "asynclayoutinflater",
+ "group": "androidx.asynclayoutinflater",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "asynclayoutinflater"
+ },
+ {
+ "artifactId": {
+ "name": "documentfile",
+ "group": "androidx.documentfile",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "documentfile"
+ },
+ {
+ "artifactId": {
+ "name": "localbroadcastmanager",
+ "group": "androidx.localbroadcastmanager",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "localbroadcastmanager"
+ },
+ {
+ "artifactId": {
+ "name": "print",
+ "group": "androidx.print",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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": "http://developer.android.com/tools/extras/support-library.html",
+ "libraryName": "print"
+ },
+ {
+ "artifactId": {
+ "name": "security-crypto",
+ "group": "androidx.security",
+ "version": "+"
+ },
+ "copyrightHolder": "Android Open Source Project",
+ "copyrightStatement": "Copyright © 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 © Google Inc. All rights reserved.",
+ "license": "Apache 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"
}
]
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/AuthStateManager.kt b/app/src/main/java/org/tasks/auth/AuthStateManager.kt
new file mode 100644
index 000000000..632598911
--- /dev/null
+++ b/app/src/main/java/org/tasks/auth/AuthStateManager.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2017 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 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()
+ private val currentAuthState = AtomicReference()
+
+ val current: AuthState
+ get() {
+ if (currentAuthState.get() != null) {
+ return currentAuthState.get()
+ }
+ val state = readState()
+ return if (currentAuthState.compareAndSet(null, state)) {
+ state
+ } else {
+ currentAuthState.get()
+ }
+ }
+
+ fun replace(state: AuthState): AuthState {
+ writeState(state)
+ currentAuthState.set(state)
+ return state
+ }
+
+ fun updateAfterAuthorization(
+ response: AuthorizationResponse?,
+ ex: AuthorizationException?
+ ): AuthState {
+ val current = current
+ current.update(response, ex)
+ return replace(current)
+ }
+
+ fun updateAfterTokenResponse(
+ response: TokenResponse?,
+ ex: AuthorizationException?
+ ): AuthState {
+ val current = current
+ current.update(response, ex)
+ return replace(current)
+ }
+
+ fun updateAfterRegistration(
+ response: RegistrationResponse?,
+ ex: AuthorizationException?
+ ): AuthState {
+ val current = current
+ if (ex != null) {
+ return current
+ }
+ 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"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/AuthorizationService.kt b/app/src/main/java/org/tasks/auth/AuthorizationService.kt
new file mode 100644
index 000000000..f5bbde12c
--- /dev/null
+++ b/app/src/main/java/org/tasks/auth/AuthorizationService.kt
@@ -0,0 +1,91 @@
+package org.tasks.auth
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.browser.customtabs.CustomTabsIntent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import net.openid.appauth.*
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.browser.AnyBrowserMatcher
+import javax.inject.Inject
+import kotlin.coroutines.suspendCoroutine
+
+class AuthorizationService @Inject constructor(
+ @ApplicationContext context: Context,
+ private val authStateManager: AuthStateManager,
+ configuration: Configuration
+) {
+ private val authorizationService = AuthorizationService(
+ context,
+ AppAuthConfiguration.Builder()
+ .setBrowserMatcher(AnyBrowserMatcher.INSTANCE)
+ .setConnectionBuilder(configuration.connectionBuilder)
+ .build())
+
+ fun dispose() {
+ authorizationService.dispose()
+ }
+
+ fun getAuthorizationRequestIntent(
+ request: AuthorizationRequest,
+ customTabsIntent: CustomTabsIntent
+ ): Intent {
+ return authorizationService.getAuthorizationRequestIntent(request, customTabsIntent)
+ }
+
+ fun createCustomTabsIntent(uri: Uri, color: Int): CustomTabsIntent {
+ return authorizationService
+ .createCustomTabsIntentBuilder(uri)
+ .setToolbarColor(color)
+ .build()
+ }
+
+ fun performRegistrationRequest(
+ request: RegistrationRequest,
+ callback: (RegistrationResponse?, AuthorizationException?) -> Unit
+ ) {
+ authorizationService.performRegistrationRequest(request, callback)
+ }
+
+ suspend fun performTokenRequest(request: TokenRequest, clientAuthentication: ClientAuthentication): TokenResponse? {
+ return withContext(Dispatchers.IO) {
+ suspendCoroutine { cont ->
+ authorizationService.performTokenRequest(request, clientAuthentication) { response, exception ->
+ if (exception != null) {
+ cont.resumeWith(Result.failure(exception))
+ } else {
+ cont.resumeWith(Result.success(response))
+ }
+ }
+ }
+ }
+ }
+
+ suspend fun getFreshToken(): String? = withContext(Dispatchers.IO) {
+ suspendCoroutine { cont ->
+ authStateManager
+ .current
+ .performActionWithFreshTokens(authorizationService) { _, idToken, exception ->
+ if (exception == null) {
+ cont.resumeWith(Result.success(idToken))
+ } else {
+ cont.resumeWith(Result.failure(exception))
+ }
+ }
+ }
+ }
+
+ fun signOut() {
+ // discard the authorization and token state, but retain the configuration and
+ // dynamic client registration (if applicable), to save from retrieving them again.
+ val currentState = authStateManager.current
+ val clearedState = AuthState(currentState.authorizationServiceConfiguration!!)
+ if (currentState.lastRegistrationResponse != null) {
+ clearedState.update(currentState.lastRegistrationResponse)
+ }
+ authStateManager.replace(clearedState)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/Configuration.kt b/app/src/main/java/org/tasks/auth/Configuration.kt
new file mode 100644
index 000000000..281788cab
--- /dev/null
+++ b/app/src/main/java/org/tasks/auth/Configuration.kt
@@ -0,0 +1,221 @@
+/*
+ * 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.content.Intent
+import android.net.Uri
+import android.text.TextUtils
+import dagger.hilt.android.qualifiers.ApplicationContext
+import net.openid.appauth.connectivity.ConnectionBuilder
+import net.openid.appauth.connectivity.DefaultConnectionBuilder
+import okio.Buffer
+import okio.buffer
+import okio.source
+import org.json.JSONException
+import org.json.JSONObject
+import org.tasks.R
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Reads and validates the demo app configuration from `res/raw/auth_config.json`. Configuration
+ * changes are detected by comparing the hash of the last known configuration to the read
+ * configuration. When a configuration change is detected, the app state is reset.
+ */
+@Singleton
+class Configuration @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private var configJson: JSONObject? = null
+ private var configHash: String? = null
+
+ /**
+ * Returns a description of the configuration error, if the configuration is invalid.
+ */
+ var configurationError: String? = null
+ var clientId: String? = null
+ private set
+ private var mScope: String? = null
+ private var mRedirectUri: Uri? = null
+ var discoveryUri: Uri? = null
+ private set
+ var authEndpointUri: Uri? = null
+ private set
+ var tokenEndpointUri: Uri? = null
+ private set
+ var registrationEndpointUri: Uri? = null
+ private set
+ var userInfoEndpointUri: Uri? = null
+ private set
+ private var isHttpsRequired = false
+ private set
+
+ /**
+ * Indicates whether the configuration has changed from the last known valid state.
+ */
+ fun hasConfigurationChanged(): Boolean {
+ val lastHash = lastKnownConfigHash
+ return configHash != lastHash
+ }
+
+ /**
+ * Indicates whether the current configuration is valid.
+ */
+ val isValid: Boolean
+ get() = configurationError == null
+
+ /**
+ * Indicates that the current configuration should be accepted as the "last known valid"
+ * configuration.
+ */
+ fun acceptConfiguration() {
+ prefs.edit().putString(KEY_LAST_HASH, configHash).apply()
+ }
+
+ val scope: String
+ get() = mScope!!
+
+ val redirectUri: Uri
+ get() = mRedirectUri!!
+
+ val connectionBuilder: ConnectionBuilder = DefaultConnectionBuilder.INSTANCE
+
+ private val lastKnownConfigHash: String?
+ get() = prefs.getString(KEY_LAST_HASH, null)
+
+ @Throws(InvalidConfigurationException::class)
+ private fun readConfiguration() {
+ val configSource = context.resources.openRawResource(R.raw.auth_config).source().buffer()
+ val configData = Buffer()
+ configJson = try {
+ configSource.readAll(configData)
+ JSONObject(configData.readString(StandardCharsets.UTF_8))
+ } catch (ex: IOException) {
+ throw InvalidConfigurationException(
+ "Failed to read configuration: " + ex.message)
+ } catch (ex: JSONException) {
+ throw InvalidConfigurationException(
+ "Unable to parse configuration: " + ex.message)
+ }
+ configHash = configData.sha256().base64()
+ clientId = getConfigString("client_id")
+ mScope = getRequiredConfigString("authorization_scope")
+ mRedirectUri = getRequiredConfigUri("redirect_uri")
+ if (!isRedirectUriRegistered) {
+ throw InvalidConfigurationException(
+ "redirect_uri is not handled by any activity in this app! "
+ + "Ensure that the appAuthRedirectScheme in your build.gradle file "
+ + "is correctly configured, or that an appropriate intent filter "
+ + "exists in your app manifest.")
+ }
+ if (getConfigString("discovery_uri") == null) {
+ authEndpointUri = getRequiredConfigWebUri("authorization_endpoint_uri")
+ tokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri")
+ userInfoEndpointUri = getRequiredConfigWebUri("user_info_endpoint_uri")
+ if (clientId == null) {
+ registrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri")
+ }
+ } else {
+ discoveryUri = getRequiredConfigWebUri("discovery_uri")
+ }
+ isHttpsRequired = configJson!!.optBoolean("https_required", true)
+ }
+
+ private fun getConfigString(propName: String?): String? {
+ var value = configJson!!.optString(propName) ?: return null
+ value = value.trim { it <= ' ' }
+ return if (TextUtils.isEmpty(value)) {
+ null
+ } else value
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ private fun getRequiredConfigString(propName: String): String {
+ return getConfigString(propName)
+ ?: throw InvalidConfigurationException(
+ "$propName is required but not specified in the configuration")
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ fun getRequiredConfigUri(propName: String): Uri {
+ val uriStr = getRequiredConfigString(propName)
+ val uri: Uri
+ uri = try {
+ Uri.parse(uriStr)
+ } catch (ex: Throwable) {
+ throw InvalidConfigurationException("$propName could not be parsed", ex)
+ }
+ if (!uri.isHierarchical || !uri.isAbsolute) {
+ throw InvalidConfigurationException(
+ "$propName must be hierarchical and absolute")
+ }
+ if (!TextUtils.isEmpty(uri.encodedUserInfo)) {
+ throw InvalidConfigurationException("$propName must not have user info")
+ }
+ if (!TextUtils.isEmpty(uri.encodedQuery)) {
+ throw InvalidConfigurationException("$propName must not have query parameters")
+ }
+ if (!TextUtils.isEmpty(uri.encodedFragment)) {
+ throw InvalidConfigurationException("$propName must not have a fragment")
+ }
+ return uri
+ }
+
+ @Throws(InvalidConfigurationException::class)
+ fun getRequiredConfigWebUri(propName: String): Uri {
+ val uri = getRequiredConfigUri(propName)
+ val scheme = uri.scheme
+ if (TextUtils.isEmpty(scheme) || !("http" == scheme || "https" == scheme)) {
+ throw InvalidConfigurationException(
+ "$propName must have an http or https scheme")
+ }
+ return uri
+ }
+
+ // ensure that the redirect URI declared in the configuration is handled by some activity
+ // in the app, by querying the package manager speculatively
+ private val isRedirectUriRegistered: Boolean
+ get() {
+ // ensure that the redirect URI declared in the configuration is handled by some activity
+ // in the app, by querying the package manager speculatively
+ val redirectIntent = Intent()
+ redirectIntent.setPackage(context.packageName)
+ redirectIntent.action = Intent.ACTION_VIEW
+ redirectIntent.addCategory(Intent.CATEGORY_BROWSABLE)
+ redirectIntent.data = mRedirectUri
+ return !context.packageManager.queryIntentActivities(redirectIntent, 0).isEmpty()
+ }
+
+ class InvalidConfigurationException : Exception {
+ internal constructor(reason: String?) : super(reason) {}
+ internal constructor(reason: String?, cause: Throwable?) : super(reason, cause) {}
+ }
+
+ companion object {
+ private const val PREFS_NAME = "config"
+ private const val KEY_LAST_HASH = "lastHash"
+ }
+
+ init {
+ try {
+ readConfiguration()
+ } catch (ex: InvalidConfigurationException) {
+ configurationError = ex.message
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/OauthSignIn.kt b/app/src/main/java/org/tasks/auth/OauthSignIn.kt
deleted file mode 100644
index 3de668412..000000000
--- a/app/src/main/java/org/tasks/auth/OauthSignIn.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.tasks.auth
-
-interface OauthSignIn {
- val idToken: String?
- val email: String?
- val id: String?
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/SignInActivity.kt b/app/src/main/java/org/tasks/auth/SignInActivity.kt
new file mode 100644
index 000000000..36b884ccb
--- /dev/null
+++ b/app/src/main/java/org/tasks/auth/SignInActivity.kt
@@ -0,0 +1,286 @@
+/*
+ * Copyright 2015 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.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.annotation.AnyThread
+import androidx.annotation.MainThread
+import androidx.annotation.WorkerThread
+import androidx.browser.customtabs.CustomTabsIntent
+import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import net.openid.appauth.*
+import org.tasks.R
+import org.tasks.injection.InjectingAppCompatActivity
+import org.tasks.themes.ThemeColor
+import timber.log.Timber
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors.newSingleThreadExecutor
+import java.util.concurrent.atomic.AtomicReference
+import javax.inject.Inject
+
+/**
+ * Demonstrates the usage of the AppAuth to authorize a user with an OAuth2 / OpenID Connect
+ * provider. Based on the configuration provided in `res/raw/auth_config.json`, the code
+ * contained here will:
+ *
+ * - Retrieve an OpenID Connect discovery document for the provider, or use a local static
+ * configuration.
+ * - Utilize dynamic client registration, if no static client id is specified.
+ * - Initiate the authorization request using the built-in heuristics or a user-selected browser.
+ *
+ * _NOTE_: From a clean checkout of this project, the authorization service is not configured.
+ * Edit `res/values/auth_config.xml` to provide the required configuration properties. See the
+ * README.md in the app/ directory for configuration instructions, and the adjacent IDP-specific
+ * instructions.
+ */
+@AndroidEntryPoint
+class SignInActivity : InjectingAppCompatActivity() {
+ @Inject lateinit var authService: AuthorizationService
+ @Inject lateinit var authStateManager: AuthStateManager
+ @Inject lateinit var configuration: Configuration
+ @Inject lateinit var themeColor: ThemeColor
+
+ private val viewModel: SignInViewModel by viewModels()
+
+ private val mClientId = AtomicReference()
+ private val mAuthRequest = AtomicReference()
+ private val mAuthIntent = AtomicReference()
+ private var mAuthIntentLatch = CountDownLatch(1)
+ private val mExecutor: ExecutorService = newSingleThreadExecutor()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (authStateManager.current.isAuthorized &&
+ !configuration.hasConfigurationChanged()) {
+ Timber.i("User is already authenticated, signing out")
+ authService.signOut()
+ }
+ if (!configuration.isValid) {
+ displayError(configuration.configurationError)
+ return
+ }
+ if (configuration.hasConfigurationChanged()) {
+ // discard any existing authorization state due to the change of configuration
+ Timber.i("Configuration change detected, discarding old state")
+ authStateManager.replace(AuthState())
+ configuration.acceptConfiguration()
+ }
+ mExecutor.submit { initializeAppAuth() }
+ }
+
+ override fun onStop() {
+ super.onStop()
+
+ mExecutor.shutdownNow()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ authService.dispose()
+ }
+
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ if (requestCode == RC_AUTH) {
+ if (resultCode == RESULT_OK) {
+ lifecycleScope.launch {
+ viewModel.handleResult(data!!)
+ authStateManager.current.authorizationException?.let { e ->
+ displayError(e.message)
+ }
+ finish()
+ }
+ } else {
+ displayError(getString(R.string.authorization_cancelled))
+ }
+ } else {
+ super.onActivityResult(requestCode, resultCode, data)
+ }
+ }
+
+ @MainThread
+ fun startAuth() {
+ // WrongThread inference is incorrect for lambdas
+ // noinspection WrongThread
+ mExecutor.submit { doAuth() }
+ }
+
+ /**
+ * Initializes the authorization service configuration if necessary, either from the local
+ * static values or by retrieving an OpenID discovery document.
+ */
+ @WorkerThread
+ private fun initializeAppAuth() {
+ Timber.i("Initializing AppAuth")
+ if (authStateManager.current.authorizationServiceConfiguration != null) {
+ // configuration is already created, skip to client initialization
+ Timber.i("auth config already established")
+ initializeClient()
+ return
+ }
+
+ // if we are not using discovery, build the authorization service configuration directly
+ // from the static configuration values.
+ if (configuration.discoveryUri == null) {
+ Timber.i("Creating auth config from res/raw/auth_config.json")
+ val config = AuthorizationServiceConfiguration(
+ configuration.authEndpointUri!!,
+ configuration.tokenEndpointUri!!,
+ configuration.registrationEndpointUri)
+ authStateManager.replace(AuthState(config))
+ initializeClient()
+ return
+ }
+
+ // WrongThread inference is incorrect for lambdas
+ // noinspection WrongThread
+ Timber.i("Retrieving OpenID discovery doc")
+ AuthorizationServiceConfiguration.fetchFromUrl(
+ configuration.discoveryUri!!, { config: AuthorizationServiceConfiguration?, ex: AuthorizationException? -> handleConfigurationRetrievalResult(config, ex) },
+ configuration.connectionBuilder)
+ }
+
+ @MainThread
+ private fun handleConfigurationRetrievalResult(
+ config: AuthorizationServiceConfiguration?,
+ ex: AuthorizationException?) {
+ if (config == null) {
+ Timber.i(ex, "Failed to retrieve discovery document")
+ displayError("Failed to retrieve discovery document: " + ex!!.message)
+ return
+ }
+ Timber.i("Discovery document retrieved")
+ authStateManager.replace(AuthState(config))
+ mExecutor.submit { initializeClient() }
+ }
+
+ /**
+ * Initiates a dynamic registration request if a client ID is not provided by the static
+ * configuration.
+ */
+ @WorkerThread
+ private fun initializeClient() {
+ if (configuration.clientId != null) {
+ Timber.i("Using static client ID: %s", configuration.clientId)
+ // use a statically configured client ID
+ mClientId.set(configuration.clientId)
+ runOnUiThread { initializeAuthRequest() }
+ return
+ }
+ val lastResponse = authStateManager.current.lastRegistrationResponse
+ if (lastResponse != null) {
+ Timber.i("Using dynamic client ID: %s", lastResponse.clientId)
+ // already dynamically registered a client ID
+ mClientId.set(lastResponse.clientId)
+ runOnUiThread { initializeAuthRequest() }
+ return
+ }
+
+ // WrongThread inference is incorrect for lambdas
+ // noinspection WrongThread
+ Timber.i("Dynamically registering client")
+ val registrationRequest = RegistrationRequest.Builder(
+ authStateManager.current.authorizationServiceConfiguration!!, listOf(configuration.redirectUri))
+ .setTokenEndpointAuthenticationMethod(ClientSecretBasic.NAME)
+ .build()
+ authService.performRegistrationRequest(
+ registrationRequest) { response: RegistrationResponse?, ex: AuthorizationException? -> handleRegistrationResponse(response, ex) }
+ }
+
+ @MainThread
+ private fun handleRegistrationResponse(
+ response: RegistrationResponse?,
+ ex: AuthorizationException?) {
+ authStateManager.updateAfterRegistration(response, ex)
+ if (response == null) {
+ Timber.i(ex, "Failed to dynamically register client")
+ displayErrorLater("Failed to register client: " + ex!!.message)
+ return
+ }
+ Timber.i("Dynamically registered client: %s", response.clientId)
+ mClientId.set(response.clientId)
+ initializeAuthRequest()
+ return
+ }
+
+ /**
+ * Performs the authorization request, using the browser selected in the spinner,
+ * and a user-provided `login_hint` if available.
+ */
+ @WorkerThread
+ private fun doAuth() {
+ try {
+ mAuthIntentLatch.await()
+ } catch (ex: InterruptedException) {
+ Timber.w("Interrupted while waiting for auth intent")
+ }
+ val intent = authService.getAuthorizationRequestIntent(
+ mAuthRequest.get(),
+ mAuthIntent.get())
+ startActivityForResult(intent, RC_AUTH)
+ }
+
+ @MainThread
+ private fun displayError(error: String?) {
+ Timber.e(error)
+ setResult(RESULT_CANCELED, Intent().putExtra(EXTRA_ERROR, error))
+ finish()
+ }
+
+ // WrongThread inference is incorrect in this case
+ @AnyThread
+ private fun displayErrorLater(error: String) {
+ runOnUiThread { displayError(error) }
+ }
+
+ @MainThread
+ private fun initializeAuthRequest() {
+ createAuthRequest()
+ warmUpBrowser()
+ startAuth()
+ }
+
+ private fun warmUpBrowser() {
+ mAuthIntentLatch = CountDownLatch(1)
+ mExecutor.execute {
+ Timber.i("Warming up browser instance for auth request")
+ mAuthIntent.set(authService.createCustomTabsIntent(
+ mAuthRequest.get().toUri(),
+ themeColor.primaryColor
+ ))
+ mAuthIntentLatch.countDown()
+ }
+ }
+
+ private fun createAuthRequest() {
+ Timber.i("Creating auth request")
+ val authRequestBuilder = AuthorizationRequest.Builder(
+ authStateManager.current.authorizationServiceConfiguration!!,
+ mClientId.get()!!,
+ ResponseTypeValues.CODE,
+ configuration.redirectUri)
+ .setScope(configuration.scope)
+ mAuthRequest.set(authRequestBuilder.build())
+ }
+
+ companion object {
+ const val EXTRA_ERROR = "extra_error"
+ private const val RC_AUTH = 100
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/SignInViewModel.kt b/app/src/main/java/org/tasks/auth/SignInViewModel.kt
new file mode 100644
index 000000000..ff5ccb42c
--- /dev/null
+++ b/app/src/main/java/org/tasks/auth/SignInViewModel.kt
@@ -0,0 +1,89 @@
+package org.tasks.auth
+
+import android.content.Context
+import android.content.Intent
+import android.util.Base64
+import androidx.hilt.lifecycle.ViewModelInject
+import androidx.lifecycle.ViewModel
+import com.todoroo.astrid.helper.UUIDHelper
+import dagger.hilt.android.qualifiers.ApplicationContext
+import net.openid.appauth.AuthorizationException
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.ClientAuthentication.UnsupportedAuthenticationMethod
+import org.json.JSONObject
+import org.tasks.R
+import org.tasks.caldav.CaldavClientProvider
+import org.tasks.data.CaldavAccount
+import org.tasks.data.CaldavDao
+import timber.log.Timber
+
+class SignInViewModel @ViewModelInject constructor(
+ @ApplicationContext private val context: Context,
+ private val authStateManager: AuthStateManager,
+ private val authorizationService: AuthorizationService,
+ private val provider: CaldavClientProvider,
+ private val caldavDao: CaldavDao
+) : ViewModel() {
+ suspend fun handleResult(intent: Intent): CaldavAccount? {
+ val response = AuthorizationResponse.fromIntent(intent)
+ val ex = AuthorizationException.fromIntent(intent)
+
+ if (response != null || ex != null) {
+ authStateManager.updateAfterAuthorization(response, ex)
+ }
+
+ if (response?.authorizationCode != null) {
+ authStateManager.updateAfterAuthorization(response, ex)
+ exchangeAuthorizationCode(response)
+ }
+ val auth = authStateManager.current
+ if (!auth.isAuthorized) {
+ return null
+ }
+ val idToken = auth.idToken
+ val parts: List = idToken!!.split(".")
+ val payloadJson = JSONObject(String(Base64.decode(parts[1], Base64.DEFAULT)))
+ val sub = payloadJson.getString("sub")
+ val username = "google_$sub"
+ val email = payloadJson.getString("email")
+ val homeSet = provider
+ .forUrl(
+ "${context.getString(R.string.tasks_caldav_url)}/google_login",
+ token = idToken
+ )
+ .setForeground()
+ .homeSet(token = idToken)
+ return caldavDao.getAccount(CaldavAccount.TYPE_TASKS, username)
+ ?.apply {
+ error = null
+ caldavDao.update(this)
+ }
+ ?: CaldavAccount().apply {
+ accountType = CaldavAccount.TYPE_TASKS
+ uuid = UUIDHelper.newUUID()
+ url = homeSet
+ this.username = username
+ name = email
+ caldavDao.insert(this)
+ }
+ }
+
+ private suspend fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) {
+ val request = authorizationResponse.createTokenExchangeRequest()
+ val clientAuthentication = try {
+ authStateManager.current.clientAuthentication
+ } catch (ex: UnsupportedAuthenticationMethod) {
+ throw ex
+ }
+ try {
+ authorizationService.performTokenRequest(request, clientAuthentication)?.let {
+ authStateManager.updateAfterTokenResponse(it, null)
+ if (authStateManager.current.isAuthorized) {
+ Timber.d("Authorization successful")
+ }
+ }
+ } catch (e: AuthorizationException) {
+ authStateManager.updateAfterTokenResponse(null, e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/auth/TasksAccountSettingsActivity.kt b/app/src/main/java/org/tasks/auth/TasksAccountSettingsActivity.kt
index 569dc18f7..8e1b24cf8 100644
--- a/app/src/main/java/org/tasks/auth/TasksAccountSettingsActivity.kt
+++ b/app/src/main/java/org/tasks/auth/TasksAccountSettingsActivity.kt
@@ -9,10 +9,13 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.tasks.R
import org.tasks.caldav.BaseCaldavAccountSettingsActivity
+import javax.inject.Inject
@AndroidEntryPoint
class TasksAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolbar.OnMenuItemClickListener {
+ @Inject lateinit var authorizationService: AuthorizationService
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -61,6 +64,17 @@ class TasksAccountSettingsActivity : BaseCaldavAccountSettingsActivity(), Toolba
override suspend fun updateAccount() = updateAccount(caldavAccount!!.url)
+ override suspend fun removeAccount() {
+ authorizationService.signOut()
+ super.removeAccount()
+ }
+
+ override fun onStop() {
+ super.onStop()
+
+ authorizationService.dispose()
+ }
+
override val helpUrl: String
get() = getString(R.string.help_url_sync)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt b/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt
index dc2e3123a..40047d7cc 100644
--- a/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt
+++ b/app/src/main/java/org/tasks/caldav/AddCaldavAccountViewModel.kt
@@ -13,4 +13,8 @@ class AddCaldavAccountViewModel @ViewModelInject constructor(
.setForeground()
.homeSet(username, password) }
}
+
+ override fun onCleared() {
+ provider.dispose()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt b/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt
index 9990b522e..105738773 100644
--- a/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt
+++ b/app/src/main/java/org/tasks/caldav/CaldavClientProvider.kt
@@ -12,9 +12,9 @@ import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.internal.tls.OkHostnameVerifier
import org.tasks.DebugNetworkInterceptor
+import org.tasks.auth.AuthorizationService
import org.tasks.billing.Inventory
import org.tasks.data.CaldavAccount
-import org.tasks.gtasks.PlayServices
import org.tasks.preferences.Preferences
import org.tasks.security.KeyStoreEncryption
import java.util.concurrent.TimeUnit
@@ -26,8 +26,8 @@ class CaldavClientProvider @Inject constructor(
private val encryption: KeyStoreEncryption,
private val preferences: Preferences,
private val interceptor: DebugNetworkInterceptor,
- private val playServices: PlayServices,
- private val inventory: Inventory
+ private val inventory: Inventory,
+ private val authorizationService: AuthorizationService
) {
suspend fun forUrl(
url: String?,
@@ -66,8 +66,8 @@ class CaldavClientProvider @Inject constructor(
token: String? = null
): Interceptor? {
return when {
- account?.isTasksOrg == true -> playServices.getSignedInAccount()?.let {
- TokenInterceptor(it.idToken!!, inventory)
+ account?.isTasksOrg == true -> authorizationService.getFreshToken()?.let {
+ TokenInterceptor(it, inventory)
}
username?.isNotBlank() == true && password?.isNotBlank() == true ->
BasicDigestAuthHandler(null, username, password)
@@ -104,4 +104,8 @@ class CaldavClientProvider @Inject constructor(
return builder.build()
}
+
+ fun dispose() {
+ authorizationService.dispose()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt
index c266335ea..4d473af59 100644
--- a/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt
+++ b/app/src/main/java/org/tasks/caldav/CaldavSynchronizer.kt
@@ -116,6 +116,8 @@ class CaldavSynchronizer @Inject constructor(
} catch (e: DavException) {
setError(account, e.message)
firebase.reportException(e)
+ } finally {
+ provider.dispose()
}
}
diff --git a/app/src/main/java/org/tasks/caldav/CreateCalendarViewModel.kt b/app/src/main/java/org/tasks/caldav/CreateCalendarViewModel.kt
index 51f50d28f..c89a7ac78 100644
--- a/app/src/main/java/org/tasks/caldav/CreateCalendarViewModel.kt
+++ b/app/src/main/java/org/tasks/caldav/CreateCalendarViewModel.kt
@@ -10,4 +10,8 @@ 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()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/DeleteCalendarViewModel.kt b/app/src/main/java/org/tasks/caldav/DeleteCalendarViewModel.kt
index 346e5074d..f10548ba3 100644
--- a/app/src/main/java/org/tasks/caldav/DeleteCalendarViewModel.kt
+++ b/app/src/main/java/org/tasks/caldav/DeleteCalendarViewModel.kt
@@ -13,4 +13,8 @@ class DeleteCalendarViewModel @ViewModelInject constructor(
calendar.url?.let { provider.forAccount(account, it).deleteCollection() }
}
}
+
+ override fun onCleared() {
+ provider.dispose()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt b/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt
index 347ec6e48..69544fe0b 100644
--- a/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt
+++ b/app/src/main/java/org/tasks/caldav/UpdateCaldavAccountViewModel.kt
@@ -9,4 +9,8 @@ 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()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/caldav/UpdateCalendarViewModel.kt b/app/src/main/java/org/tasks/caldav/UpdateCalendarViewModel.kt
index 6d81c9b7f..73a0c7923 100644
--- a/app/src/main/java/org/tasks/caldav/UpdateCalendarViewModel.kt
+++ b/app/src/main/java/org/tasks/caldav/UpdateCalendarViewModel.kt
@@ -13,4 +13,8 @@ class UpdateCalendarViewModel @ViewModelInject constructor(
calendar.url?.let { provider.forAccount(account, it).updateCollection(name, color) }
}
}
+
+ override fun onCleared() {
+ provider.dispose()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/tasks/preferences/MainPreferences.kt b/app/src/main/java/org/tasks/preferences/MainPreferences.kt
index 77a0656a2..02034dd7f 100644
--- a/app/src/main/java/org/tasks/preferences/MainPreferences.kt
+++ b/app/src/main/java/org/tasks/preferences/MainPreferences.kt
@@ -9,10 +9,12 @@ import com.todoroo.astrid.gtasks.auth.GtasksLoginActivity
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.LocalBroadcastManager
import org.tasks.R
+import org.tasks.auth.SignInActivity
import org.tasks.jobs.WorkManager
import org.tasks.preferences.fragments.MainSettingsFragment
import org.tasks.preferences.fragments.Synchronization.Companion.REQUEST_CALDAV_SETTINGS
import org.tasks.preferences.fragments.Synchronization.Companion.REQUEST_GOOGLE_TASKS
+import org.tasks.preferences.fragments.Synchronization.Companion.REQUEST_TASKS_ORG
import org.tasks.sync.SyncAdapters
import org.tasks.ui.Toaster
import javax.inject.Inject
@@ -58,6 +60,13 @@ class MainPreferences : BasePreferences() {
} else {
data?.getStringExtra(GtasksLoginActivity.EXTRA_ERROR)?.let { toaster.longToast(it) }
}
+ } else if (requestCode == REQUEST_TASKS_ORG) {
+ if (resultCode == Activity.RESULT_OK) {
+ syncAdapters.sync(true)
+ workManager.updateBackgroundSync()
+ } else {
+ data?.getStringExtra(SignInActivity.EXTRA_ERROR)?.let { toaster.longToast(it) }
+ }
} else {
super.onActivityResult(requestCode, resultCode, data)
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 19736b2ac..e5da2ec75 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -665,4 +665,5 @@ File %1$s contained %2$s.\n\n
Current subscription: %s
Purchases updated
Follow r/tasks
+ Authorization cancelled
diff --git a/app/src/release/res/raw/auth_config.json b/app/src/release/res/raw/auth_config.json
new file mode 100644
index 000000000..e74d5f210
--- /dev/null
+++ b/app/src/release/res/raw/auth_config.json
@@ -0,0 +1,11 @@
+{
+ "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",
+ "authorization_endpoint_uri": "",
+ "token_endpoint_uri": "",
+ "registration_endpoint_uri": "",
+ "user_info_endpoint_uri": "",
+ "https_required": true
+}
diff --git a/app/src/release/res/values/keys.xml b/app/src/release/res/values/keys.xml
index 704942e20..dfdae5954 100644
--- a/app/src/release/res/values/keys.xml
+++ b/app/src/release/res/values/keys.xml
@@ -1,6 +1,5 @@
-
+
Tasks
https://caldav.tasks.org
- 363426363175-op3tqa3qir2qkm7dtj6jr6mp4hudmsgs.apps.googleusercontent.com
\ No newline at end of file
diff --git a/deps_fdroid.txt b/deps_fdroid.txt
index 29b77bc5f..da97f059e 100644
--- a/deps_fdroid.txt
+++ b/deps_fdroid.txt
@@ -385,5 +385,33 @@
+| +--- org.apache.commons:commons-collections4:4.1
+| +--- org.apache.commons:commons-lang3:3.8.1 -> 3.9
+| \--- commons-codec:commons-codec:1.7 -> 1.11
-+\--- com.github.QuadFlask:colorpicker:0.0.15
-+ \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*)
+++--- 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 (*)
++\--- net.openid:appauth:0.7.1
++ \--- androidx.browser:browser:1.0.0
++ +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ +--- androidx.interpolator:interpolator:1.0.0 (*)
++ +--- androidx.collection:collection:1.0.0 -> 1.1.0 (*)
++ \--- androidx.legacy:legacy-support-core-ui:1.0.0
++ +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ +--- androidx.legacy:legacy-support-core-utils:1.0.0 (*)
++ +--- androidx.customview:customview:1.0.0 (*)
++ +--- androidx.viewpager:viewpager:1.0.0 (*)
++ +--- androidx.coordinatorlayout:coordinatorlayout:1.0.0 -> 1.1.0 (*)
++ +--- androidx.drawerlayout:drawerlayout:1.0.0 (*)
++ +--- androidx.slidingpanelayout:slidingpanelayout:1.0.0
++ | +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ | +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ | \--- androidx.customview:customview:1.0.0 (*)
++ +--- androidx.interpolator:interpolator:1.0.0 (*)
++ +--- androidx.swiperefreshlayout:swiperefreshlayout:1.0.0 -> 1.1.0 (*)
++ +--- androidx.asynclayoutinflater:asynclayoutinflater:1.0.0
++ | +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ | \--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ \--- androidx.cursoradapter:cursoradapter:1.0.0 (*)
diff --git a/deps_googleplay.txt b/deps_googleplay.txt
index 8891d89b7..376723743 100644
--- a/deps_googleplay.txt
+++ b/deps_googleplay.txt
@@ -246,21 +246,6 @@
+| +--- com.google.auto.value:auto-value-annotations:1.6.2 -> 1.7.4
+| \--- com.google.code.gson:gson:2.8.5 -> 2.8.6
++--- com.android.billingclient:billing:1.2.2
-++--- com.google.android.gms:play-services-auth:19.0.0
-+| +--- androidx.fragment:fragment:1.0.0 -> 1.2.5 (*)
-+| +--- androidx.loader:loader:1.0.0 (*)
-+| +--- com.google.android.gms:play-services-auth-api-phone:17.0.0
-+| | +--- com.google.android.gms:play-services-base:17.0.0 -> 17.3.0 (*)
-+| | +--- com.google.android.gms:play-services-basement:17.0.0 -> 17.3.0 (*)
-+| | \--- com.google.android.gms:play-services-tasks:17.0.0 -> 17.1.0 (*)
-+| +--- com.google.android.gms:play-services-auth-base:17.0.0
-+| | +--- androidx.collection:collection:1.0.0 -> 1.1.0 (*)
-+| | +--- com.google.android.gms:play-services-base:17.0.0 -> 17.3.0 (*)
-+| | +--- com.google.android.gms:play-services-basement:17.0.0 -> 17.3.0 (*)
-+| | \--- com.google.android.gms:play-services-tasks:17.0.0 -> 17.1.0 (*)
-+| +--- com.google.android.gms:play-services-base:17.1.0 -> 17.3.0 (*)
-+| +--- com.google.android.gms:play-services-basement:17.1.1 -> 17.3.0 (*)
-+| \--- com.google.android.gms:play-services-tasks:17.0.0 -> 17.1.0 (*)
++--- com.gitlab.bitfireAT:dav4jvm:2.1.1
+| +--- org.jetbrains.kotlin:kotlin-stdlib:1.3.72 -> 1.4.10 (*)
+| \--- org.apache.commons:commons-lang3:3.9
@@ -512,5 +497,33 @@
+| +--- org.apache.commons:commons-collections4:4.1
+| +--- org.apache.commons:commons-lang3:3.8.1 -> 3.9
+| \--- commons-codec:commons-codec:1.7 -> 1.11
-+\--- com.github.QuadFlask:colorpicker:0.0.15
-+ \--- androidx.appcompat:appcompat:1.1.0 -> 1.2.0 (*)
+++--- 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 (*)
++\--- net.openid:appauth:0.7.1
++ \--- androidx.browser:browser:1.0.0
++ +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ +--- androidx.interpolator:interpolator:1.0.0 (*)
++ +--- androidx.collection:collection:1.0.0 -> 1.1.0 (*)
++ \--- androidx.legacy:legacy-support-core-ui:1.0.0
++ +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ +--- androidx.legacy:legacy-support-core-utils:1.0.0 (*)
++ +--- androidx.customview:customview:1.0.0 (*)
++ +--- androidx.viewpager:viewpager:1.0.0 (*)
++ +--- androidx.coordinatorlayout:coordinatorlayout:1.0.0 -> 1.1.0 (*)
++ +--- androidx.drawerlayout:drawerlayout:1.0.0 (*)
++ +--- androidx.slidingpanelayout:slidingpanelayout:1.0.0
++ | +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ | +--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ | \--- androidx.customview:customview:1.0.0 (*)
++ +--- androidx.interpolator:interpolator:1.0.0 (*)
++ +--- androidx.swiperefreshlayout:swiperefreshlayout:1.0.0 -> 1.1.0 (*)
++ +--- androidx.asynclayoutinflater:asynclayoutinflater:1.0.0
++ | +--- androidx.annotation:annotation:1.0.0 -> 1.1.0
++ | \--- androidx.core:core:1.0.0 -> 1.3.2 (*)
++ \--- androidx.cursoradapter:cursoradapter:1.0.0 (*)