diff --git a/app/build.gradle b/app/build.gradle
index dace8855f73142315e4d13249220fedbd5215f2a..417df1af904a5532c588452b5cfeec4553ed4011 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,6 +12,7 @@ android {
compileSdkVersion 32
buildToolsVersion '32.0.0'
+
defaultConfig {
applicationId "at.bitfire.davdroid"
@@ -152,6 +153,8 @@ dependencies {
//noinspection GradleDependency
implementation "org.apache.commons:commons-text:${versions.commonsText}"
+ implementation 'net.openid:appauth:0.7.0'
+
// for tests
androidTestImplementation "com.google.dagger:hilt-android-testing:${versions.hilt}"
kaptAndroidTest "com.google.dagger:hilt-android-compiler:${versions.hilt}"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 99f49fac6d0757c45c4acfa29b39f6fc185a18a1..4fe931f7570e34c513cd2723a8aab397ed6d555a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -150,6 +150,62 @@
android:label="@string/webdav_add_mount_title"
android:parentActivityName=".ui.webdav.WebdavMountsActivity" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PROVIDERS = Arrays.asList(
+ GOOGLE);
+
+ public static List getEnabledProviders(Context context)
+ {
+ ArrayList providers = new ArrayList<>();
+ for (IdentityProvider provider : PROVIDERS)
+ {
+ provider.readConfiguration(context);
+ providers.add(provider);
+ }
+ return providers;
+ }
+
+ @NonNull
+ public final String name;
+
+ @StringRes
+ public final int buttonContentDescriptionRes;
+
+ @StringRes
+ private final int mDiscoveryEndpointRes;
+
+ @StringRes
+ private final int mAuthEndpointRes;
+
+ @StringRes
+ private final int mTokenEndpointRes;
+
+ @StringRes
+ private final int mClientIdRes;
+
+ @StringRes
+ private final int mClientSecretRes;
+
+ @StringRes
+ private final int mRedirectUriRes;
+
+ @StringRes
+ private final int mScopeRes;
+
+ private boolean mConfigurationRead = false;
+ private Uri mDiscoveryEndpoint;
+ private Uri mAuthEndpoint;
+ private Uri mTokenEndpoint;
+ private String mClientId;
+ private String mClientSecret;
+ private Uri mRedirectUri;
+ private String mScope;
+
+ IdentityProvider(
+ @NonNull String name,
+ @StringRes int discoveryEndpointRes,
+ @StringRes int authEndpointRes,
+ @StringRes int tokenEndpointRes,
+ @StringRes int clientIdRes,
+ @StringRes int clientSecretRes,
+ @StringRes int redirectUriRes,
+ @StringRes int scopeRes,
+ @StringRes int buttonContentDescriptionRes)
+ {
+ if (!isSpecified(discoveryEndpointRes)
+ && !isSpecified(authEndpointRes)
+ && !isSpecified(tokenEndpointRes))
+ {
+ throw new IllegalArgumentException(
+ "the discovery endpoint or the auth and token endpoints must be specified");
+ }
+
+ this.name = name;
+ this.mDiscoveryEndpointRes = discoveryEndpointRes;
+ this.mAuthEndpointRes = authEndpointRes;
+ this.mTokenEndpointRes = tokenEndpointRes;
+ this.mClientIdRes = checkSpecified(clientIdRes, "clientIdRes");
+ this.mClientSecretRes = clientSecretRes;
+ this.mRedirectUriRes = checkSpecified(redirectUriRes, "redirectUriRes");
+ this.mScopeRes = checkSpecified(scopeRes, "scopeRes");
+ this.buttonContentDescriptionRes =
+ checkSpecified(buttonContentDescriptionRes, "buttonContentDescriptionRes");
+ }
+
+ /**
+ * This must be called before any of the getters will function.
+ */
+ public void readConfiguration(Context context)
+ {
+ if (mConfigurationRead)
+ {
+ return;
+ }
+
+ Resources res = context.getResources();
+
+ mDiscoveryEndpoint = isSpecified(mDiscoveryEndpointRes)
+ ? getUriResource(res, mDiscoveryEndpointRes, "discoveryEndpointRes")
+ : null;
+ mAuthEndpoint = isSpecified(mAuthEndpointRes)
+ ? getUriResource(res, mAuthEndpointRes, "authEndpointRes")
+ : null;
+ mTokenEndpoint = isSpecified(mTokenEndpointRes)
+ ? getUriResource(res, mTokenEndpointRes, "tokenEndpointRes")
+ : null;
+ mClientId = res.getString(mClientIdRes);
+ mClientSecret = isSpecified(mClientSecretRes) ? res.getString(mClientSecretRes) : null;
+ mRedirectUri = getUriResource(res, mRedirectUriRes, "mRedirectUriRes");
+ mScope = res.getString(mScopeRes);
+
+ mConfigurationRead = true;
+ }
+
+ private void checkConfigurationRead()
+ {
+ if (!mConfigurationRead)
+ {
+ throw new IllegalStateException("Configuration not read");
+ }
+ }
+
+ @Nullable
+ public Uri getDiscoveryEndpoint()
+ {
+ checkConfigurationRead();
+ return mDiscoveryEndpoint;
+ }
+
+ @Nullable
+ public Uri getAuthEndpoint()
+ {
+ checkConfigurationRead();
+ return mAuthEndpoint;
+ }
+
+ @Nullable
+ public Uri getTokenEndpoint()
+ {
+ checkConfigurationRead();
+ return mTokenEndpoint;
+ }
+
+ @NonNull
+ public String getClientId()
+ {
+ checkConfigurationRead();
+ return mClientId;
+ }
+
+ @Nullable
+ public String getClientSecret()
+ {
+ checkConfigurationRead();
+ return mClientSecret;
+ }
+
+ @NonNull
+ public Uri getRedirectUri()
+ {
+ checkConfigurationRead();
+ return mRedirectUri;
+ }
+
+ @NonNull
+ public String getScope()
+ {
+ checkConfigurationRead();
+ return mScope;
+ }
+
+ public void retrieveConfig(Context context,
+ RetrieveConfigurationCallback callback)
+ {
+ readConfiguration(context);
+ if (getDiscoveryEndpoint() != null)
+ {
+ AuthorizationServiceConfiguration.fetchFromUrl(mDiscoveryEndpoint, callback);
+ }
+ else
+ {
+ AuthorizationServiceConfiguration config =
+ new AuthorizationServiceConfiguration(mAuthEndpoint, mTokenEndpoint, null);
+ callback.onFetchConfigurationCompleted(config, null);
+ }
+ }
+
+ private static boolean isSpecified(int value)
+ {
+ return value != NOT_SPECIFIED;
+ }
+
+ private static int checkSpecified(int value, String valueName)
+ {
+ if (value == NOT_SPECIFIED)
+ {
+ throw new IllegalArgumentException(valueName + " must be specified");
+ }
+ return value;
+ }
+
+ private static Uri getUriResource(Resources res, @StringRes int resId, String resName)
+ {
+ return Uri.parse(res.getString(resId));
+ }
+}
diff --git a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt
index 2ed99e26f96970406c7a01b99f944a0dcae858bf..b4b80ef3db80a3fa1357dc694e76714c18d62a1e 100644
--- a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt
+++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt
@@ -252,7 +252,6 @@ abstract class AppDatabase: RoomDatabase() {
// helpers
-
fun dump(writer: Writer, ignoreTables: Array) {
val db = openHelper.readableDatabase
db.beginTransactionNonExclusive()
diff --git a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt
index f2f4758b1185a3000dd4896e43b2c498ec182ad8..9b43df98c57312a950eaf2e169b64a8d8ba56967 100644
--- a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt
+++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt
@@ -4,15 +4,41 @@
package at.bitfire.davdroid.db
+import net.openid.appauth.AuthState
+import java.net.URI
+
+
data class Credentials(
- val userName: String? = null,
- val password: String? = null,
- val certificateAlias: String? = null
+ val userName: String? = null,
+ val password: String? = null,
+ val authState: AuthState? = null,
+ val certificateAlias: String? = null,
+ val serverUri: URI? = null
) {
- override fun toString(): String {
- val maskedPassword = "*****".takeIf { password != null }
- return "Credentials(userName=$userName, password=$maskedPassword, certificateAlias=$certificateAlias)"
+ enum class Type {
+ UsernamePassword,
+ OAuth,
+ ClientCertificate
}
-}
\ No newline at end of file
+ val type: Type
+
+ init {
+ type = when {
+ !certificateAlias.isNullOrEmpty() ->
+ Type.ClientCertificate
+ !userName.isNullOrEmpty() && (authState != null) ->
+ Type.OAuth
+ !userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
+ Type.UsernamePassword
+ else ->
+ throw IllegalArgumentException("Invalid account type/credentials")
+ }
+ }
+
+ override fun toString() =
+ "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
+
+}
+
diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt
index 8d414c121ab07cd9c17585c76b5b4fa6f05ef8c1..babbb08443bfd841b451a41db410fbe0d807dad0 100644
--- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt
+++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt
@@ -47,6 +47,7 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.property.Url
+import net.openid.appauth.AuthState
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
import org.dmfs.tasks.contract.TaskContract
@@ -89,6 +90,9 @@ class AccountSettings(
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
const val KEY_USERNAME = "user_name"
+ const val KEY_EMAIL_ADDRESS = "email_address"
+ const val KEY_AUTH_STATE = "auth_state"
+
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
@@ -139,10 +143,12 @@ class AccountSettings(
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
if (credentials != null) {
+ bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName)
if (credentials.userName != null)
bundle.putString(KEY_USERNAME, credentials.userName)
if (credentials.certificateAlias != null)
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
+
}
return bundle
@@ -229,16 +235,45 @@ class AccountSettings(
// authentication settings
- fun credentials() = Credentials(
- accountManager.getUserData(account, KEY_USERNAME),
- accountManager.getPassword(account),
- accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
- )
+// fun credentials() = Credentials(
+//
+//// accountManager.getUserData(account, KEY_USERNAME),
+//// accountManager.getPassword(account),
+//// null,
+//// accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
+// )
+
+ fun credentials(): Credentials {
+ if (accountManager.getUserData(account, KEY_AUTH_STATE).isNullOrEmpty()) {
+ return Credentials(
+ accountManager.getUserData(account, KEY_USERNAME),
+ accountManager.getPassword(account),
+ null,
+ accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS))
+ }
+ else {
+ return Credentials(
+ accountManager.getUserData(account, KEY_USERNAME),
+ accountManager.getPassword(account),
+ AuthState.jsonDeserialize(accountManager.getUserData(account, KEY_AUTH_STATE)),
+ accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS))
+ }
+ }
+
fun credentials(credentials: Credentials) {
- accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
- accountManager.setPassword(account, credentials.password)
- accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
+ if (credentials.authState == null) {
+ accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
+ accountManager.setPassword(account, credentials.password)
+ accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
+ }
+ else {
+ accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
+ accountManager.setPassword(account, credentials.password)
+ accountManager.setUserData(account, KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
+ accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
+ }
+
}
diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt
new file mode 100644
index 0000000000000000000000000000000000000000..57165ec4393b2295c8889851db534732e5f840ba
--- /dev/null
+++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt
@@ -0,0 +1,155 @@
+package foundation.e.accountmanager.syncadapter
+
+/*
+ * Copyright ECORP SAS 2022
+ * Copyright © Ricki Hirner (bitfire web engineering).
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the GNU Public License v3.0
+ * which accompanies this distribution, and is available at
+ * http://www.gnu.org/licenses/gpl.html
+ */
+
+import android.accounts.*
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import at.bitfire.davdroid.db.AppDatabase
+import at.bitfire.davdroid.log.Logger
+import at.bitfire.davdroid.resource.LocalAddressBook
+import at.bitfire.davdroid.ui.setup.LoginActivity
+import at.bitfire.davdroid.R
+import at.bitfire.davdroid.settings.AccountSettings
+import net.openid.appauth.AuthState
+import net.openid.appauth.AuthorizationService
+import java.util.logging.Level
+import kotlin.concurrent.thread
+
+/**
+ * Account authenticator for the Google account type.
+ *
+ * Gets started when a Google account is removed, too, so it also watches for account removals
+ * and contains the corresponding cleanup code.
+ */
+class GoogleAccountAuthenticatorService : Service(), OnAccountsUpdateListener {
+
+ companion object {
+
+ fun cleanupAccounts(context: Context) {
+ Logger.log.info("Cleaning up orphaned accounts")
+
+ val accountManager = AccountManager.get(context)
+
+ val accountNames = HashSet()
+ val accounts = ArrayList()
+ accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { accounts.add(it) }
+ accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { accounts.add(it) }
+ accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { accounts.add(it) }
+ for (account in accounts.toTypedArray())
+ accountNames += account.name
+
+ // delete orphaned address book accounts
+ val addressBookAccounts = ArrayList()
+ accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { addressBookAccounts.add(it) }
+ accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { addressBookAccounts.add(it) }
+ accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { addressBookAccounts.add(it) }
+ addressBookAccounts.map { LocalAddressBook(context, it, null) }
+ .forEach {
+ try {
+ if (!accountNames.contains(it.mainAccount.name))
+ it.delete()
+ } catch(e: Exception) {
+ Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
+ }
+ }
+
+ // delete orphaned services in DB
+ val db = AppDatabase.getInstance(context)
+ val serviceDao = db.serviceDao()
+ if (accountNames.isEmpty())
+ serviceDao.deleteAll()
+ else
+ serviceDao.deleteExceptAccounts(accountNames.toTypedArray())
+ }
+
+ }
+
+ private lateinit var accountManager: AccountManager
+ private lateinit var accountAuthenticator: AccountAuthenticator
+
+ override fun onCreate() {
+ accountManager = AccountManager.get(this)
+ accountManager.addOnAccountsUpdatedListener(this, null, true)
+
+ accountAuthenticator = AccountAuthenticator(this)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ accountManager.removeOnAccountsUpdatedListener(this)
+ }
+
+ override fun onBind(intent: Intent?) =
+ accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
+
+
+ override fun onAccountsUpdated(accounts: Array?) {
+ thread {
+ cleanupAccounts(this)
+ }
+ }
+
+
+ private class AccountAuthenticator(
+ val context: Context
+ ) : AbstractAccountAuthenticator(context) {
+
+ override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array?, options: Bundle?): Bundle {
+ val intent = Intent(context, LoginActivity::class.java)
+ intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+ intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE)
+ val bundle = Bundle(1)
+ bundle.putParcelable(AccountManager.KEY_INTENT, intent)
+ return bundle
+ }
+
+ override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
+ override fun getAuthTokenLabel(p0: String?) = null
+ override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
+ override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
+ override fun getAuthToken(response: AccountAuthenticatorResponse?, account: Account?, authTokenType: String?, options: Bundle?): Bundle {
+ val accountManager = AccountManager.get(context)
+ val authState = AuthState.jsonDeserialize(accountManager.getUserData(account, AccountSettings.KEY_AUTH_STATE))
+
+ if (authState != null) {
+ if (authState.needsTokenRefresh) {
+ val tokenRequest = authState.createTokenRefreshRequest()
+
+ AuthorizationService(context).performTokenRequest(tokenRequest) { tokenResponse, ex ->
+ authState.update(tokenResponse, ex)
+ accountManager.setUserData(account, AccountSettings.KEY_AUTH_STATE, authState.jsonSerializeString())
+ val result = Bundle()
+ result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name)
+ result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
+ result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken)
+ response?.onResult(result)
+ }
+ }
+ else {
+ val result = Bundle()
+ result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name)
+ result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
+ result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken)
+ return result
+ }
+ }
+
+ val result = Bundle()
+ result.putInt(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION)
+ return result
+ }
+ override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null
+
+ }
+}
+
diff --git a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt
index b4a58e02fe022d9eadafdacfefc434cbdc519986..3407a6616546f869a3e75e4e6546f856ce885f12 100644
--- a/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt
+++ b/app/src/main/java/at/bitfire/davdroid/ui/account/SettingsActivity.kt
@@ -226,14 +226,14 @@ class SettingsActivity: AppCompatActivity() {
prefUserName.summary = credentials.userName
prefUserName.text = credentials.userName
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newUserName ->
- model.updateCredentials(Credentials(newUserName as String, credentials.password, credentials.certificateAlias))
+ model.updateCredentials(Credentials(newUserName as String, credentials.password))
false
}
if (credentials.userName != null) {
prefPassword.isVisible = true
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newPassword ->
- model.updateCredentials(Credentials(credentials.userName, newPassword as String, credentials.certificateAlias))
+ model.updateCredentials(Credentials(credentials.userName, newPassword as String))
false
}
} else
@@ -242,7 +242,7 @@ class SettingsActivity: AppCompatActivity() {
prefCertAlias.summary = credentials.certificateAlias ?: getString(R.string.settings_certificate_alias_empty)
prefCertAlias.setOnPreferenceClickListener {
KeyChain.choosePrivateKeyAlias(requireActivity(), { newAlias ->
- model.updateCredentials(Credentials(credentials.userName, credentials.password, newAlias))
+ model.updateCredentials(Credentials(certificateAlias = newAlias))
}, null, null, null, -1, credentials.certificateAlias)
true
}
diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt
index 445079025c282ff85b97e9a500042ca53e3c18fa..ea7522aee2844af0bd8b48fc7d2703ef884d3cc9 100644
--- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt
+++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DefaultLoginCredentialsFragment.kt
@@ -179,7 +179,7 @@ class DefaultLoginCredentialsFragment : Fragment() {
loginModel.credentials = when {
// username/password and client certificate
model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == true ->
- Credentials(username, password, alias)
+ Credentials(username, password, null)
// user/name password only
model.loginUseUsernamePassword.value == true && model.loginUseClientCertificate.value == false ->
diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1dde9909909a525ad4341a491ac26ae14902730d
--- /dev/null
+++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt
@@ -0,0 +1,423 @@
+package at.bitfire.davdroid.ui.setup
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.*
+import androidx.fragment.app.Fragment
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+
+import at.bitfire.davdroid.R
+import androidx.lifecycle.ViewModelProviders
+import net.openid.appauth.*
+import org.json.JSONException
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.net.*
+import org.json.JSONObject
+import java.util.HashMap
+
+import android.net.ConnectivityManager
+import android.text.Layout
+import android.text.SpannableString
+import android.text.style.AlignmentSpan
+import android.widget.Toast
+import at.bitfire.davdroid.authorization.IdentityProvider
+import at.bitfire.davdroid.databinding.FragmentGoogleAuthenticatorBinding
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+
+import at.bitfire.davdroid.db.Credentials
+
+class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenResponseCallback {
+
+ private lateinit var model: GoogleAuthenticatorModel
+ private lateinit var loginModel: LoginModel
+
+ private val extraAuthServiceDiscovery = "authServiceDiscovery"
+ private val extraClientSecret = "clientSecret"
+
+ private var authState: AuthState? = null
+ private var authorizationService: AuthorizationService? = null
+
+ private val bufferSize = 1024
+ private var userInfoJson: JSONObject? = null
+
+ private fun isNetworkAvailable(): Boolean {
+ val connectivityManager = activity!!.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val activeNetworkInfo = connectivityManager.activeNetworkInfo
+ return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?): View? {
+
+ model = ViewModelProviders.of(this).get(GoogleAuthenticatorModel::class.java)
+ loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java)
+
+ // Initialise the authorization service
+ authorizationService = AuthorizationService(context!!)
+
+ val v = FragmentGoogleAuthenticatorBinding.inflate(inflater, container, false)
+ v.lifecycleOwner = this
+ v.model = model
+
+ activity?.intent?.let {
+ model.initialize(it)
+ val builder = MaterialAlertDialogBuilder(context!!, R.style.CustomAlertDialogStyle)
+
+ if (!with(it) { getBooleanExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, false) }) {
+ val title = SpannableString(getString(R.string.google_alert_title))
+ // alert dialog title align center
+ title.setSpan(
+ AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER),
+ 0,
+ title.length,
+ 0
+ )
+
+ builder.setTitle(title)
+ builder.setMessage(getString(R.string.google_alert_message))
+ builder.setPositiveButton(android.R.string.yes) { dialog, which ->
+ // Get all the account providers
+ val providers = IdentityProvider.getEnabledProviders(context)
+
+ // Iterate over the account providers
+ for (idp in providers) {
+ val retrieveCallback = AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex ->
+ if (ex == null && serviceConfiguration != null) {
+ makeAuthRequest(serviceConfiguration, idp)
+ } else if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ } else {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+ }
+
+ if (idp.name == getString(R.string.google_name)) {
+ // Get configurations for the Google account provider
+ idp.retrieveConfig(context, retrieveCallback)
+ }
+ }
+ }
+ builder.setCancelable(false)
+
+ val dialog = builder.create()
+ dialog.show()
+
+ }
+ else {
+ if (authState == null) {
+ val response = AuthorizationResponse.fromIntent(activity!!.intent)
+ val ex = AuthorizationException.fromIntent(activity!!.intent)
+ authState = AuthState(response, ex)
+
+ if (response != null) {
+ exchangeAuthorizationCode(response)
+ } else if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ } else {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+ }
+ }
+ }
+
+ return v.root
+ }
+
+ private fun makeAuthRequest(
+ serviceConfig: AuthorizationServiceConfiguration,
+ idp: IdentityProvider) {
+
+ if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ val authRequest = AuthorizationRequest.Builder(
+ serviceConfig,
+ idp.clientId,
+ ResponseTypeValues.CODE,
+ idp.redirectUri)
+ .setScope(idp.scope)
+ .build()
+
+ authorizationService?.performAuthorizationRequest(
+ authRequest,
+ createPostAuthorizationIntent(
+ context!!,
+ authRequest,
+ serviceConfig.discoveryDoc,
+ idp.clientSecret),
+ authorizationService?.createCustomTabsIntentBuilder()!!
+ .build())
+
+ requireActivity().setResult(Activity.RESULT_OK)
+ requireActivity().finish()
+ }
+
+ private fun createPostAuthorizationIntent(
+ context: Context,
+ request: AuthorizationRequest,
+ discoveryDoc: AuthorizationServiceDiscovery?,
+ clientSecret: String?): PendingIntent {
+ val intent = Intent(context, LoginActivity::class.java)
+
+ if (discoveryDoc != null) {
+ intent.putExtra(extraAuthServiceDiscovery, discoveryDoc.docJson.toString())
+ }
+
+ if (clientSecret != null) {
+ intent.putExtra(extraClientSecret, clientSecret)
+ }
+
+ intent.putExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE, LoginActivity.ACCOUNT_PROVIDER_GOOGLE)
+ intent.putExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, true)
+
+ return PendingIntent.getActivity(context, request.hashCode(), intent, 0)
+ }
+
+ private fun exchangeAuthorizationCode(authorizationResponse: AuthorizationResponse) {
+ if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ val additionalParams = HashMap()
+ if (getClientSecretFromIntent(activity!!.intent) != null) {
+ additionalParams["client_secret"] = getClientSecretFromIntent(activity!!.intent)
+ }
+ performTokenRequest(authorizationResponse.createTokenExchangeRequest(additionalParams))
+ }
+
+ private fun getClientSecretFromIntent(intent: Intent): String? {
+ return if (!intent.hasExtra(extraClientSecret)) {
+ null
+ }
+ else intent.getStringExtra(extraClientSecret)
+ }
+
+
+ private fun performTokenRequest(request: TokenRequest) {
+ if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ authorizationService?.performTokenRequest(
+ request, this)
+ }
+
+ override fun onTokenRequestCompleted(response: TokenResponse?, ex: AuthorizationException?) {
+ authState?.update(response, ex)
+
+ getAccountInfo()
+ }
+
+ private fun getAccountInfo() {
+ if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent)
+
+ if (!authState!!.isAuthorized
+ || discoveryDoc == null
+ || discoveryDoc.userinfoEndpoint == null) {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+ else {
+ object : AsyncTask() {
+ override fun doInBackground(vararg params: Void): Void? {
+ if (fetchUserInfo()) {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ return null
+ }
+ }.execute()
+ }
+ }
+
+ private fun getDiscoveryDocFromIntent(intent: Intent): AuthorizationServiceDiscovery? {
+ if (!intent.hasExtra(extraAuthServiceDiscovery)) {
+ return null
+ }
+ val discoveryJson = intent.getStringExtra(extraAuthServiceDiscovery)
+ try {
+ return AuthorizationServiceDiscovery(JSONObject(discoveryJson))
+ }
+ catch (ex: JSONException) {
+ throw IllegalStateException("Malformed JSON in discovery doc")
+ }
+ catch (ex: AuthorizationServiceDiscovery.MissingArgumentException) {
+ throw IllegalStateException("Malformed JSON in discovery doc")
+ }
+
+ }
+
+ private fun fetchUserInfo(): Boolean {
+ var error = false
+
+ if (authState!!.authorizationServiceConfiguration == null) {
+ return true
+ }
+
+ authState!!.performActionWithFreshTokens(authorizationService!!, AuthState.AuthStateAction { accessToken, _, ex ->
+ if (ex != null) {
+ error = true
+ return@AuthStateAction
+ }
+
+ val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent)
+ ?: throw IllegalStateException("no available discovery doc")
+
+ val userInfoEndpoint: URL
+ try {
+ userInfoEndpoint = URL(discoveryDoc.userinfoEndpoint!!.toString())
+ }
+ catch (urlEx: MalformedURLException) {
+ error = true
+ return@AuthStateAction
+ }
+
+ var userInfoResponse: InputStream? = null
+ try {
+ val conn = userInfoEndpoint.openConnection() as HttpURLConnection
+ conn.setRequestProperty("Authorization", "Bearer " + accessToken!!)
+ conn.instanceFollowRedirects = false
+ userInfoResponse = conn.inputStream
+ val response = readStream(userInfoResponse)
+ updateUserInfo(JSONObject(response))
+ }
+ catch (ioEx: IOException) {
+ error = true
+ }
+ catch (jsonEx: JSONException) {
+ error = true
+ }
+ finally {
+ if (userInfoResponse != null) {
+ try {
+ userInfoResponse.close()
+ }
+ catch (ioEx: IOException) {
+ error = true
+ }
+
+ }
+ }
+ })
+
+ return error
+ }
+
+ @Throws(IOException::class)
+ private fun readStream(stream: InputStream?): String {
+ val br = BufferedReader(InputStreamReader(stream!!))
+ val buffer = CharArray(bufferSize)
+ val sb = StringBuilder()
+ var readCount = br.read(buffer)
+ while (readCount != -1) {
+ sb.append(buffer, 0, readCount)
+ readCount = br.read(buffer)
+ }
+ return sb.toString()
+ }
+
+ private fun updateUserInfo(jsonObject: JSONObject) {
+ Handler(Looper.getMainLooper()).post {
+ userInfoJson = jsonObject
+ onAccountInfoGotten()
+ }
+ }
+
+ private fun onAccountInfoGotten() {
+ if (!isNetworkAvailable()) {
+ Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ if (userInfoJson != null) {
+ try {
+
+ var emailAddress = ""
+ if (userInfoJson!!.has("email")) {
+ emailAddress = userInfoJson!!.getString("email")
+ }
+
+ if (validate(emailAddress, authState!!))
+ requireFragmentManager().beginTransaction()
+ .replace(android.R.id.content, DetectConfigurationFragment(), null)
+ .addToBackStack(null)
+ .commit()
+
+ }
+ catch (ex: JSONException) {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ }
+ else {
+ Toast.makeText(context, "Login failed, please try again later", Toast.LENGTH_LONG).show()
+ activity!!.finish()
+ }
+
+ }
+
+ private fun validate(emailAddress: String, authState: AuthState): Boolean {
+ var valid = false
+
+ fun validateUrl() {
+ model.baseUrlError.value = null
+ try {
+ val uri = URI("https://apidata.googleusercontent.com/caldav/v2/$emailAddress/user")
+ if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) {
+ valid = true
+ loginModel.baseURI = uri
+ } else
+ model.baseUrlError.value = getString(R.string.login_url_must_be_http_or_https)
+ } catch (e: Exception) {
+ model.baseUrlError.value = e.localizedMessage
+ }
+ }
+
+ when {
+
+ model.loginWithUrlAndTokens.value == true -> {
+ validateUrl()
+
+ model.usernameError.value = null
+
+ if (loginModel.baseURI != null) {
+ valid = true
+ loginModel.credentials = Credentials(emailAddress, null, authState, null)
+ }
+ }
+
+ }
+
+ return valid
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ authorizationService?.dispose()
+ }
+
+
+}
+
diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6adf20ff60db64985cfb2c5069e4c69fc74b2bda
--- /dev/null
+++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorModel.kt
@@ -0,0 +1,49 @@
+package at.bitfire.davdroid.ui.setup
+
+import android.content.Intent
+import android.net.Uri
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class GoogleAuthenticatorModel: ViewModel() {
+
+ private var initialized = false
+
+ val loginWithUrlAndTokens = MutableLiveData()
+
+ val baseUrl = MutableLiveData()
+ val baseUrlError = MutableLiveData()
+
+ val emailAddress = MutableLiveData()
+ val emailAddressError = MutableLiveData()
+
+ val username = MutableLiveData()
+ val usernameError = MutableLiveData()
+
+ val password = MutableLiveData()
+ val passwordError = MutableLiveData()
+
+ val certificateAlias = MutableLiveData()
+ val certificateAliasError = MutableLiveData()
+
+ init {
+ loginWithUrlAndTokens.value = true
+ }
+
+ fun initialize(intent: Intent) {
+ if (initialized)
+ return
+
+ // we've got initial login data
+ val givenUrl = intent.getStringExtra(LoginActivity.EXTRA_URL)
+ val givenUsername = intent.getStringExtra(LoginActivity.EXTRA_USERNAME)
+ val givenPassword = intent.getStringExtra(LoginActivity.EXTRA_PASSWORD)
+
+ baseUrl.value = givenUrl
+
+ password.value = givenPassword
+
+ initialized = true
+ }
+
+}
diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt
index 801f827297eb75c14ec9cdb4341dd31620d227ed..fecaf076dfd22f88b82713fa8f8d8d824c949196 100644
--- a/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt
+++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/LoginActivity.kt
@@ -47,7 +47,7 @@ class LoginActivity: AppCompatActivity() {
const val SETUP_ACCOUNT_PROVIDER_TYPE = "setup_account_provider_type"
const val ACCOUNT_PROVIDER_EELO = "eelo"
const val ACCOUNT_PROVIDER_GOOGLE = "google"
-
+ const val ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE = "google_auth_complete"
}
@Inject
@@ -78,7 +78,11 @@ class LoginActivity: AppCompatActivity() {
.commit()
}
ACCOUNT_PROVIDER_GOOGLE -> {
- Log.i(TAG, "starting google account sign-in")
+ Log.e(TAG, "starting google account sign-in")
+ supportFragmentManager.beginTransaction()
+ .replace(android.R.id.content, GoogleAuthenticatorFragment())
+ .commit()
+
}
else ->
// first call, add first login fragment
diff --git a/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt b/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt
index 87ceb5aa02b09ac90cbf7204e809d8651e69dbb2..647dc2d55547569f3808871a2017aa705766d215 100644
--- a/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt
+++ b/app/src/main/java/at/bitfire/davdroid/webdav/CredentialsStore.kt
@@ -43,6 +43,7 @@ class CredentialsStore(context: Context) {
return Credentials(
prefs.getString(keyName(mountId, USER_NAME), null),
prefs.getString(keyName(mountId, PASSWORD), null),
+ null,
prefs.getString(keyName(mountId, CERTIFICATE_ALIAS), null)
)
}
diff --git a/app/src/main/res/layout/fragment_google_authenticator.xml b/app/src/main/res/layout/fragment_google_authenticator.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9880209992282fe6edcdc833c674893136c44f70
--- /dev/null
+++ b/app/src/main/res/layout/fragment_google_authenticator.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/account_providers_auth_config.xml b/app/src/main/res/values/account_providers_auth_config.xml
new file mode 100644
index 0000000000000000000000000000000000000000..b3b10fdbaa873cc965b9d52e768ab19a89bc1349
--- /dev/null
+++ b/app/src/main/res/values/account_providers_auth_config.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ Google
+
+ 100496780587-pbiu5eudcjm6cge2phduc6mt8mgbsmsr.apps.googleusercontent.com
+
+ https://accounts.google.com/.well-known/openid-configuration
+
+ openid profile email https://www.googleapis.com/auth/carddav https://www.googleapis.com/auth/calendar https://mail.google.com/
+
+
+ com.googleusercontent.apps.100496780587-pbiu5eudcjm6cge2phduc6mt8mgbsmsr:/oauth2redirect
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index eebfb386f865d68bc96a66fb0005e947fc53a8d5..cdcbc91b1691c109858f028c432cc731e82b32eb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,9 +8,16 @@
Account does not exist (anymore)
bitfire.at.davdroid
- Google
+ Google /e/
bitfire.at.davdroid.google
+ e.foundation.webdav.eelo
+
+
+ WARNING
+ /e/ will report a fake device model to Google to protect your privacy.\nYou can check which one on Google\'s Device Activity after you log in.
+
+
at.bitfire.davdroid.address_book
/e/
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 75ffbab8903697fcac6c9666d46b4af1b803cd8a..5deef593421bdb0833f2b72b70ac155a535d64af 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -23,6 +23,7 @@
#aee571
#4b830d
@android:color/black
+ @color/primaryTextColor
#ff6d00
#ff9e40
@@ -86,4 +87,25 @@
@color/secondaryLightColor
+
+
+
+
+
+
diff --git a/gradle.properties b/gradle.properties
index c6bf06227a4bf6557135f618573cf62c3cf6fad4..52bbc61602c7ef486b5feb97faed45fd854b79a7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,3 +16,4 @@ android.useAndroidX=true
org.gradle.daemon=true
org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M"
org.gradle.parallel=true
+android.enableJetifier=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 92f06b50fd65b47b5578f5427b9fee66dcaae5ae..02b1c085de35d05284647c274a8111c907fc2bd1 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,6 @@
+#Fri Jul 29 11:01:22 IST 2022
distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip
-zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME