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 (*)