mirror of https://github.com/tasks/tasks
Replace Google Sign In with App Auth
parent
47189e0466
commit
26a4b16235
@ -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
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<CaldavAccount?>() {
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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<AuthState>()
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package org.tasks.auth
|
|
||||||
|
|
||||||
interface OauthSignIn {
|
|
||||||
val idToken: String?
|
|
||||||
val email: String?
|
|
||||||
val id: String?
|
|
||||||
}
|
|
||||||
@ -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<String?>()
|
||||||
|
private val mAuthRequest = AtomicReference<AuthorizationRequest>()
|
||||||
|
private val mAuthIntent = AtomicReference<CustomTabsIntent>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources>
|
||||||
<string name="app_name">Tasks</string>
|
<string name="app_name">Tasks</string>
|
||||||
<string name="tasks_caldav_url">https://caldav.tasks.org</string>
|
<string name="tasks_caldav_url">https://caldav.tasks.org</string>
|
||||||
<string name="google_sign_in" tools:ignore="TypographyDashes">363426363175-op3tqa3qir2qkm7dtj6jr6mp4hudmsgs.apps.googleusercontent.com</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
Loading…
Reference in New Issue