diff --git a/app/build.gradle b/app/build.gradle index 66f3414527e3a8e058478d06b6a84bb29bf252df..b07c211f2a92415b7919754f1202e9b705a0c081 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,6 +26,10 @@ android { buildConfigField "String", "userAgent", "\"DAVx5\"" + manifestPlaceholders = [ + 'appAuthRedirectScheme': 'com.googleusercontent.apps.628867657910-7ade6gut5rhabdgjq6k4rln9i1u9ppca' + ] + testInstrumentationRunner "at.bitfire.davdroid.CustomTestRunner" kapt { @@ -151,7 +155,8 @@ dependencies { implementation "org.apache.commons:commons-lang3:${versions.commonsLang}" //noinspection GradleDependency implementation "org.apache.commons:commons-text:${versions.commonsText}" - + //google auth + 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 c867faddbe9731b8c96e4c00c9cbefd82e5411e8..282c54b2c73c103f118fca9cf5320a9c67926985 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -280,6 +280,71 @@ android:resource="@xml/debug_paths" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java b/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..10652a3fc5bf8b1b3cea0c4573f8e96056f0ec7c --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/authorization/IdentityProvider.java @@ -0,0 +1,256 @@ +package at.bitfire.davdroid.authorization; + +/* + * Copyright 2015 The AppAuth 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. + */ + +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import foundation.e.accountmanager.R; + +import net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.AuthorizationServiceConfiguration.RetrieveConfigurationCallback; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * An abstraction of identity providers, containing all necessary info for the demo app. + */ +public class IdentityProvider +{ + + /** + * Value used to indicate that a configured property is not specified or required. + */ + public static final int NOT_SPECIFIED = -1; + + public static final IdentityProvider GOOGLE = new IdentityProvider( + "Google", + R.string.google_discovery_uri, + NOT_SPECIFIED, // auth endpoint is discovered + NOT_SPECIFIED, // token endpoint is discovered + R.string.google_client_id, + NOT_SPECIFIED, // client secret is not required for Google + R.string.google_auth_redirect_uri, + R.string.google_scope_string, + R.string.google_name); + + public static final List 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/Credentials.kt b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt index f2f4758b1185a3000dd4896e43b2c498ec182ad8..c3becfeaf945f31fa3499c2afdf4a55d3bef3647 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Credentials.kt @@ -1,18 +1,44 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ +/* + * 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 + */ -package at.bitfire.davdroid.db +package foundation.e.accountmanager.model -data class Credentials( - val userName: String? = null, - val password: String? = null, - val certificateAlias: String? = null +class Credentials( + val userName: String? = null, + val password: String? = null, + val accessToken: String? = null, + val refreshToken: String? = null, + val certificateAlias: String? = 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() && !accessToken.isNullOrEmpty() + && !refreshToken.isNullOrEmpty() -> + 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 eb009afb55e2e8cbb4aded1425e8801d9bc0783a..539779f5b8eca611d676d29ba512c555d5ee6c4e 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -15,83 +15,48 @@ import android.os.Bundle import android.os.Parcel import android.os.RemoteException import android.provider.CalendarContract -import android.provider.CalendarContract.ExtendedProperties import android.provider.ContactsContract -import android.util.Base64 -import androidx.annotation.WorkerThread import androidx.core.content.ContextCompat -import androidx.preference.PreferenceManager -import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.closeCompat -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.db.Credentials -import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.resource.LocalTask -import at.bitfire.davdroid.resource.TaskUtils -import at.bitfire.davdroid.syncadapter.SyncUtils - +import foundation.e.accountmanager.* +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Credentials +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.resource.LocalTask import foundation.e.ical4android.AndroidCalendar import foundation.e.ical4android.TaskProvider import foundation.e.ical4android.TaskProvider.ProviderName.OpenTasks import foundation.e.vcard4android.ContactsStorageException import foundation.e.vcard4android.GroupMethod - -import at.techbee.jtx.JtxContract.asSyncAdapter -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.property.Url -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.HttpUrl import org.apache.commons.lang3.StringUtils import org.dmfs.tasks.contract.TaskContract -import java.io.ByteArrayInputStream -import java.io.ObjectInputStream import java.util.logging.Level /** * Manages settings of an account. * - * @param context Required to access account settings - * @param argAccount Account to take settings from. If this account is an address book account, - * settings will be taken from the corresponding main account instead. - * * @throws InvalidAccountException on construction when the account doesn't exist (anymore) - * @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_ */ -@Suppress("FunctionName") class AccountSettings( val context: Context, - argAccount: Account + val account: Account ) { - @EntryPoint - @InstallIn(SingletonComponent::class) - interface AccountSettingsEntryPoint { - fun appDatabase(): AppDatabase - fun settingsManager(): SettingsManager - } - companion object { - const val CURRENT_VERSION = 13 + const val CURRENT_VERSION = 10 const val KEY_SETTINGS_VERSION = "version" - const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks" - const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars" - - /** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */ - const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks" - const val KEY_USERNAME = "user_name" + const val KEY_ACCESS_TOKEN = "access_token" + const val KEY_REFRESH_TOKEN = "refresh_token" const val KEY_CERTIFICATE_ALIAS = "certificate_alias" const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false) + const val WIFI_ONLY_DEFAULT = false const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs /** Time range limitation to the past [in days]. Values: @@ -110,108 +75,47 @@ class AccountSettings( */ const val KEY_DEFAULT_ALARM = "default_alarm" - /** Whether DAVx5 sets the local calendar color to the value from service DB at every sync - value = *null* (not existing): true (default); - "0" false */ + /* Whether DAVx5 sets the local calendar color to the value from service DB at every sync + value = null (not existing) true (default) + "0" false */ const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors" - /** Whether DAVx5 populates and uses CalendarContract.Colors - value = *null* (not existing) false (default); - "1" true */ + /* Whether DAVx5 populates and uses CalendarContract.Colors + value = null (not existing) false (default) + "1" true */ const val KEY_EVENT_COLORS = "event_colors" /** Contact group method: - *null (not existing)* groups as separate vCards (default); - "CATEGORIES" groups are per-contact CATEGORIES + value = null (not existing) groups as separate VCards (default) + "CATEGORIES" groups are per-contact CATEGORIES */ const val KEY_CONTACT_GROUP_METHOD = "contact_group_method" - /** UI preference: Show only personal collections - value = *null* (not existing) show all collections (default); - "1" show only personal collections */ - const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal" - const val SYNC_INTERVAL_MANUALLY = -1L - - fun initialUserData(credentials: Credentials?): Bundle { + fun initialUserData(credentials: Credentials): Bundle { val bundle = Bundle(2) bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) - if (credentials != null) { - if (credentials.userName != null) + when (credentials.type) { + Credentials.Type.UsernamePassword -> + bundle.putString(KEY_USERNAME, credentials.userName) + Credentials.Type.OAuth -> bundle.putString(KEY_USERNAME, credentials.userName) - if (credentials.certificateAlias != null) + Credentials.Type.ClientCertificate -> bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) } return bundle } - fun repairSyncIntervals(context: Context) { - val addressBooksAuthority = context.getString(R.string.address_books_authority) - val taskAuthority = TaskUtils.currentProvider(context)?.authority - - val am = AccountManager.get(context) - for (account in am.getAccountsByType(context.getString(R.string.account_type))) - try { - val settings = AccountSettings(context, account) - - // repair address book sync - settings.getSavedAddressbooksSyncInterval()?.let { shouldBe -> - val current = settings.getSyncInterval(addressBooksAuthority) - if (current != shouldBe) { - Logger.log.warning("${account.name}: $addressBooksAuthority sync interval should be $shouldBe but is $current -> setting to $current") - settings.setSyncInterval(addressBooksAuthority, shouldBe) - } - } - - // repair calendar sync - settings.getSavedCalendarsSyncInterval()?.let { shouldBe -> - val current = settings.getSyncInterval(CalendarContract.AUTHORITY) - if (current != shouldBe) { - Logger.log.warning("${account.name}: ${CalendarContract.AUTHORITY} sync interval should be $shouldBe but is $current -> setting to $current") - settings.setSyncInterval(CalendarContract.AUTHORITY, shouldBe) - } - } - - if (taskAuthority != null) - // repair calendar sync - settings.getSavedTasksSyncInterval()?.let { shouldBe -> - val current = settings.getSyncInterval(taskAuthority) - if (current != shouldBe) { - Logger.log.warning("${account.name}: $taskAuthority sync interval should be $shouldBe but is $current -> setting to $current") - settings.setSyncInterval(taskAuthority, shouldBe) - } - } - } catch (ignored: InvalidAccountException) { - // account doesn't exist (anymore) - } - } - } - val db = EntryPointAccessors.fromApplication(context, AccountSettingsEntryPoint::class.java).appDatabase() - val settings = EntryPointAccessors.fromApplication(context, AccountSettingsEntryPoint::class.java).settingsManager() - val accountManager: AccountManager = AccountManager.get(context) - val account: Account + val settings = Settings.getInstance(context) init { - when (argAccount.type) { - context.getString(R.string.account_type_address_book) -> { - /* argAccount is an address book account, which is not a main account. However settings are - stored in the main account, so resolve and use the main account instead. */ - account = LocalAddressBook.mainAccount(context, argAccount) - } - context.getString(R.string.account_type) -> - account = argAccount - else -> - throw IllegalArgumentException("Account type not supported") - } - - // synchronize because account migration must only be run one time synchronized(AccountSettings::class.java) { val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account) var version = 0 @@ -230,9 +134,11 @@ class AccountSettings( // authentication settings fun credentials() = Credentials( - accountManager.getUserData(account, KEY_USERNAME), - accountManager.getPassword(account), - accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) + accountManager.getUserData(account, KEY_USERNAME), + accountManager.getPassword(account), + accountManager.getUserData(account, KEY_ACCESS_TOKEN), + accountManager.getUserData(account, KEY_REFRESH_TOKEN), + accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS) ) fun credentials(credentials: Credentials) { @@ -254,92 +160,28 @@ class AccountSettings( SYNC_INTERVAL_MANUALLY } - /** - * Sets the sync interval and enables/disables automatic sync for the given account and authority. - * Does *not* call [ContentResolver.setIsSyncable]. - * - * This method blocks until the settings have arrived in the sync framework, so it should not - * be called from the UI thread. - * - * @param authority sync authority (like [CalendarContract.AUTHORITY]) - * @param seconds if [SYNC_INTERVAL_MANUALLY]: automatic sync will be disabled; - * otherwise: automatic sync will be enabled and set to the given number of seconds - * - * @return whether the sync interval was successfully set - */ - @WorkerThread - fun setSyncInterval(authority: String, seconds: Long): Boolean { - /* Ugly hack: because there is no callback for when the sync status/interval has been - updated, we need to make this call blocking. */ - val setInterval: () -> Boolean = - if (seconds == SYNC_INTERVAL_MANUALLY) { - { - Logger.log.fine("Disabling automatic sync of $account/$authority") - ContentResolver.setSyncAutomatically(account, authority, false) - - /* return */ !ContentResolver.getSyncAutomatically(account, authority) - } - } else { - { - Logger.log.fine("Setting automatic sync of $account/$authority to $seconds seconds") - ContentResolver.setSyncAutomatically(account, authority, true) - ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds) - - /* return */ ContentResolver.getSyncAutomatically(account, authority) && - ContentResolver.getPeriodicSyncs(account, authority).firstOrNull()?.period == seconds - } - } - - // try up to 10 times with 100 ms pause - var success = false - for (idxTry in 0 until 10) { - success = setInterval() - if (success) - break - Thread.sleep(100) + fun setSyncInterval(authority: String, seconds: Long) { + if (seconds == SYNC_INTERVAL_MANUALLY) { + ContentResolver.setSyncAutomatically(account, authority, false) + } else { + ContentResolver.setSyncAutomatically(account, authority, true) + ContentResolver.addPeriodicSync(account, authority, Bundle(), seconds) } - - if (!success) - return false - - // store sync interval in account settings (used when the provider is switched) - when { - authority == context.getString(R.string.address_books_authority) -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS, seconds.toString()) - - authority == CalendarContract.AUTHORITY -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_CALENDARS, seconds.toString()) - - TaskProvider.ProviderName.values().any { it.authority == authority } -> - accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, seconds.toString()) - } - - return true } - fun getSavedAddressbooksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_ADDRESSBOOKS)?.toLong() - fun getSavedCalendarsSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_CALENDARS)?.toLong() - fun getSavedTasksSyncInterval() = accountManager.getUserData(account, KEY_SYNC_INTERVAL_TASKS)?.toLong() - - fun getSyncWifiOnly() = - if (settings.containsKey(KEY_WIFI_ONLY)) - settings.getBoolean(KEY_WIFI_ONLY) - else - accountManager.getUserData(account, KEY_WIFI_ONLY) != null + fun getSyncWifiOnly() = if (settings.has(KEY_WIFI_ONLY)) + settings.getBoolean(KEY_WIFI_ONLY) ?: WIFI_ONLY_DEFAULT + else + accountManager.getUserData(account, KEY_WIFI_ONLY) != null fun setSyncWiFiOnly(wiFiOnly: Boolean) = - accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) - - fun getSyncWifiOnlySSIDs(): List? = - if (getSyncWifiOnly()) { - val strSsids = if (settings.containsKey(KEY_WIFI_ONLY_SSIDS)) - settings.getString(KEY_WIFI_ONLY_SSIDS) - else - accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS) - strSsids?.split(',') - } else - null + accountManager.setUserData(account, KEY_WIFI_ONLY, if (wiFiOnly) "1" else null) + + fun getSyncWifiOnlySSIDs(): List? = (if (settings.has(KEY_WIFI_ONLY_SSIDS)) + settings.getString(KEY_WIFI_ONLY_SSIDS) + else + accountManager.getUserData(account, KEY_WIFI_ONLY_SSIDS))?.split(',') fun setSyncWifiOnlySSIDs(ssids: List?) = - accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(","))) + accountManager.setUserData(account, KEY_WIFI_ONLY_SSIDS, StringUtils.trimToNull(ssids?.joinToString(","))) // CalDAV settings @@ -357,7 +199,7 @@ class AccountSettings( } fun setTimeRangePastDays(days: Int?) = - accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString()) + accountManager.setUserData(account, KEY_TIME_RANGE_PAST_DAYS, (days ?: -1).toString()) /** * Takes the default alarm setting (in this order) from @@ -369,8 +211,8 @@ class AccountSettings( * non-full-day event without reminder. *null*: No default reminders shall be created. */ fun getDefaultAlarm() = - accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?: - settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 } + accountManager.getUserData(account, KEY_DEFAULT_ALARM)?.toInt() ?: + settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 } /** * Sets the default alarm value in the local account settings, if the new value differs @@ -382,31 +224,31 @@ class AccountSettings( * start of every non-full-day event without reminder. *null*: No default reminders shall be created. */ fun setDefaultAlarm(minBefore: Int?) = - accountManager.setUserData(account, KEY_DEFAULT_ALARM, - if (minBefore == settings.getIntOrNull(KEY_DEFAULT_ALARM)?.takeIf { it != -1 }) - null - else - minBefore?.toString()) - - fun getManageCalendarColors() = if (settings.containsKey(KEY_MANAGE_CALENDAR_COLORS)) - settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) + accountManager.setUserData(account, KEY_DEFAULT_ALARM, + if (minBefore == settings.getInt(KEY_DEFAULT_ALARM).takeIf { it != -1 }) + null + else + minBefore?.toString()) + + fun getManageCalendarColors() = if (settings.has(KEY_MANAGE_CALENDAR_COLORS)) + settings.getBoolean(KEY_MANAGE_CALENDAR_COLORS) ?: false else accountManager.getUserData(account, KEY_MANAGE_CALENDAR_COLORS) == null fun setManageCalendarColors(manage: Boolean) = - accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0") + accountManager.setUserData(account, KEY_MANAGE_CALENDAR_COLORS, if (manage) null else "0") - fun getEventColors() = if (settings.containsKey(KEY_EVENT_COLORS)) - settings.getBoolean(KEY_EVENT_COLORS) - else - accountManager.getUserData(account, KEY_EVENT_COLORS) != null + fun getEventColors() = if (settings.has(KEY_EVENT_COLORS)) + settings.getBoolean(KEY_EVENT_COLORS) ?: false + else + accountManager.getUserData(account, KEY_EVENT_COLORS) != null fun setEventColors(useColors: Boolean) = - accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null) + accountManager.setUserData(account, KEY_EVENT_COLORS, if (useColors) "1" else null) // CardDAV settings fun getGroupMethod(): GroupMethod { val name = settings.getString(KEY_CONTACT_GROUP_METHOD) ?: - accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD) + accountManager.getUserData(account, KEY_CONTACT_GROUP_METHOD) if (name != null) try { return GroupMethod.valueOf(name) @@ -421,28 +263,6 @@ class AccountSettings( } - // UI settings - - /** - * Whether only personal collections should be shown. - * - * @return [Pair] of values: - * - * 1. (first) whether only personal collections should be shown - * 2. (second) whether the user shall be able to change the setting (= setting not locked) - */ - fun getShowOnlyPersonal(): Pair = - when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) { - 0 -> Pair(false, false) - 1 -> Pair(true, false) - else /* including -1 */ -> Pair(accountManager.getUserData(account, KEY_SHOW_ONLY_PERSONAL) != null, true) - } - - fun setShowOnlyPersonal(showOnlyPersonal: Boolean) { - accountManager.setUserData(account, KEY_SHOW_ONLY_PERSONAL, if (showOnlyPersonal) "1" else null) - } - - // update from previous account settings private fun update(baseVersion: Int) { @@ -462,127 +282,6 @@ class AccountSettings( } - @Suppress("unused","FunctionName") - /** - * Not a per-account migration, but not a database migration, too, so it fits best there. - * Best future solution would be that SettingsManager manages versions and migrations. - * - * Updates proxy settings from override_proxy_* to proxy_type, proxy_host, proxy_port. - */ - private fun update_12_13() { - // proxy settings are managed by SharedPreferencesProvider - val preferences = PreferenceManager.getDefaultSharedPreferences(context) - - // old setting names - val overrideProxy = "override_proxy" - val overrideProxyHost = "override_proxy_host" - val overrideProxyPort = "override_proxy_port" - - val edit = preferences.edit() - if (preferences.contains(overrideProxy)) { - if (preferences.getBoolean(overrideProxy, false)) - // override_proxy set, migrate to proxy_type = HTTP - edit.putInt(Settings.PROXY_TYPE, Settings.PROXY_TYPE_HTTP) - edit.remove(overrideProxy) - } - if (preferences.contains(overrideProxyHost)) { - preferences.getString(overrideProxyHost, null)?.let { host -> - edit.putString(Settings.PROXY_HOST, host) - } - edit.remove(overrideProxyHost) - } - if (preferences.contains(overrideProxyPort)) { - val port = preferences.getInt(overrideProxyPort, 0) - if (port != 0) - edit.putInt(Settings.PROXY_PORT, port) - edit.remove(overrideProxyPort) - } - edit.apply() - } - - - @Suppress("unused","FunctionName") - /** - * Store event URLs as URL (extended property) instead of unknown property. At the same time, - * convert legacy unknown properties to the current format. - */ - private fun update_11_12() { - if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) - context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use { provider -> - // Attention: CalendarProvider does NOT limit the results of the ExtendedProperties query - // to the given account! So all extended properties will be processed number-of-accounts times. - val extUri = ExtendedProperties.CONTENT_URI.asSyncAdapter(account) - - provider.query(extUri, arrayOf( - ExtendedProperties._ID, // idx 0 - ExtendedProperties.NAME, // idx 1 - ExtendedProperties.VALUE // idx 2 - ), null, null, null)?.use { cursor -> - while (cursor.moveToNext()) { - val id = cursor.getLong(0) - val rawValue = cursor.getString(2) - - val uri by lazy { - ContentUris.withAppendedId(ExtendedProperties.CONTENT_URI, id).asSyncAdapter(account) - } - - when (cursor.getString(1)) { - UnknownProperty.CONTENT_ITEM_TYPE -> { - // unknown property; check whether it's a URL - try { - val property = UnknownProperty.fromJsonString(rawValue) - if (property is Url) { // rewrite to MIMETYPE_URL - val newValues = ContentValues(2) - newValues.put(ExtendedProperties.NAME, AndroidEvent.MIMETYPE_URL) - newValues.put(ExtendedProperties.VALUE, property.value) - provider.update(uri, newValues, null, null) - } - } catch (e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't rewrite URL from unknown property to ${AndroidEvent.MIMETYPE_URL}", e) - } - } - "unknown-property" -> { - // unknown property (deprecated format); convert to current format - try { - val stream = ByteArrayInputStream(Base64.decode(rawValue, Base64.NO_WRAP)) - ObjectInputStream(stream).use { - (it.readObject() as? Property)?.let { property -> - // rewrite to current format - val newValues = ContentValues(2) - newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) - newValues.put(ExtendedProperties.VALUE, UnknownProperty.toJsonString(property)) - provider.update(uri, newValues, null, null) - } - } - } catch(e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't rewrite deprecated unknown property to current format", e) - } - } - "unknown-property.v2" -> { - // unknown property (deprecated MIME type); rewrite to current MIME type - val newValues = ContentValues(1) - newValues.put(ExtendedProperties.NAME, UnknownProperty.CONTENT_ITEM_TYPE) - provider.update(uri, newValues, null, null) - } - } - } - } - } - } - - @Suppress("unused","FunctionName") - /** - * The tasks sync interval should be stored in account settings. It's used to set the sync interval - * again when the tasks provider is switched. - */ - private fun update_10_11() { - TaskUtils.currentProvider(context)?.let { provider -> - val interval = getSyncInterval(provider.authority) - if (interval != null) - accountManager.setUserData(account, KEY_SYNC_INTERVAL_TASKS, interval.toString()) - } - } - @Suppress("unused","FunctionName") /** * Task synchronization now handles alarms, categories, relations and unknown properties. @@ -592,17 +291,16 @@ class AccountSettings( **/ private fun update_9_10() { TaskProvider.acquire(context, OpenTasks)?.use { provider -> - val tasksUri = provider.tasksUri().asSyncAdapter(account) + val tasksUri = TaskProvider.syncAdapterUri(provider.tasksUri(), account) val emptyETag = ContentValues(1) emptyETag.putNull(LocalTask.COLUMN_ETAG) provider.client.update(tasksUri, emptyETag, "${TaskContract.Tasks._DIRTY}=0 AND ${TaskContract.Tasks._DELETED}=0", null) } - @SuppressLint("Recycle") if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_CALENDAR) == PackageManager.PERMISSION_GRANTED) context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.let { provider -> - provider.update(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(account), - AndroidCalendar.calendarBaseValues, null, null) + provider.update(AndroidCalendar.syncAdapterURI(CalendarContract.Calendars.CONTENT_URI, account), + AndroidCalendar.calendarBaseValues, null, null) provider.closeCompat() } } @@ -613,6 +311,7 @@ class AccountSettings( * Disable it on those accounts for the future. */ private fun update_8_9() { + val db = AppDatabase.getInstance(context) val hasCalDAV = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) != null if (!hasCalDAV && ContentResolver.getIsSyncable(account, OpenTasks.authority) != 0) { Logger.log.info("Disabling OpenTasks sync for $account") @@ -630,10 +329,10 @@ class AccountSettings( TaskProvider.acquire(context, OpenTasks)?.use { provider -> // ETag is now in sync_version instead of sync1 // UID is now in _uid instead of sync2 - provider.client.query(provider.tasksUri().asSyncAdapter(account), - arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2), - "${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?", - arrayOf(account.type, account.name), null)!!.use { cursor -> + provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account), + arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2), + "${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?", + arrayOf(account.type, account.name), null)!!.use { cursor -> while (cursor.moveToNext()) { val id = cursor.getLong(0) val eTag = cursor.getString(1) @@ -645,8 +344,8 @@ class AccountSettings( values.putNull(TaskContract.Tasks.SYNC2) Logger.log.log(Level.FINER, "Updating task $id", values) provider.client.update( - ContentUris.withAppendedId(provider.tasksUri(), id).asSyncAdapter(account), - values, null, null) + TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account), + values, null, null) } } } @@ -689,7 +388,7 @@ class AccountSettings( parcel.unmarshall(raw, 0, raw.size) parcel.setDataPosition(0) val params = parcel.readBundle()!! - val url = params.getString("url")?.toHttpUrlOrNull() + val url = params.getString("url")?.let { HttpUrl.parse(it) } if (url == null) Logger.log.info("No address book URL, ignoring account") else { @@ -706,12 +405,12 @@ class AccountSettings( newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name) newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type) val affected = provider.update(ContactsContract.RawContacts.CONTENT_URI.buildUpon() - .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) - .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) - .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(), - newAccount, - "${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?", - arrayOf(account.name, account.type)) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name) + .appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(), + newAccount, + "${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?", + arrayOf(account.name, account.type)) Logger.log.info("$affected contacts moved to new address book") } @@ -730,14 +429,14 @@ class AccountSettings( // request sync of new address book account ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1) - setSyncInterval(context.getString(R.string.address_books_authority), 4*3600) + setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) } /* Android 7.1.1 OpenTasks fix */ @Suppress("unused") private fun update_4_5() { // call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available - SyncUtils.updateTaskSync(context) + PackageChangedReceiver.updateTaskSync(context) } @Suppress("unused") diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..09fce35c6926c07abaaf3888d15b8bd495d44d32 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/EeloAccountAuthenticatorService.kt @@ -0,0 +1,116 @@ + +/* + * 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 + */ +package at.bitfire.davdroid.syncadapter + + +import android.accounts.* +import android.app.Service +import android.content.Context +import android.content.Intent +import android.database.DatabaseUtils +import android.os.Bundle +import androidx.annotation.WorkerThread +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 foundation.e.accountmanager.R +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread + +/** + * Account authenticator for the eelo account type. + * + * Gets started when an eelo account is removed, too, so it also watches for account removals + * and contains the corresponding cleanup code. + */ +class EeloAccountAuthenticatorService : Service(), OnAccountsUpdateListener { + + companion object { + + fun cleanupAccounts(context: Context) { + Logger.log.info("Cleaning up orphaned accounts") + + val accountManager = AccountManager.get(context) + val accountNames = accountManager.getAccountsByType(context.getString(R.string.account_type)) + .map { it.name } + + // delete orphaned address book accounts + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) + .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_EELO) + 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(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} 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..e9cb400bfc5c13d6c9725563db23eda9a68978c0 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,117 @@ +/* + * 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.database.DatabaseUtils +import android.os.Bundle +import androidx.annotation.WorkerThread +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 foundation.e.accountmanager.R + + + + +import java.util.* +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 = accountManager.getAccountsByType(context.getString(R.string.account_type)) + .map { it.name } + + // delete orphaned address book accounts + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) + .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(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null + override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array?) = null + + } +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt index 6074304790d3fac8c0ce1aadc4129b3760f4deec..9af3d3cc79039576809acd2f38fe6ce7aff0cb3a 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/DavResourceFinder.kt @@ -2,7 +2,6 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ package at.bitfire.davdroid.ui.setup - import android.content.Context import foundation.e.dav4jvm.DavResource import foundation.e.dav4jvm.Response @@ -10,13 +9,11 @@ import foundation.e.dav4jvm.UrlUtils import foundation.e.dav4jvm.exception.DavException import foundation.e.dav4jvm.exception.HttpException import foundation.e.dav4jvm.property.* - -import at.bitfire.davdroid.DavUtils -import at.bitfire.davdroid.HttpClient -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.log.StringHandler +import foundation.e.accountmanager.DavUtils +import foundation.e.accountmanager.HttpClient +import foundation.e.accountmanager.log.StringHandler +import foundation.e.accountmanager.model.Collection import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.builder.ReflectionToStringBuilder import org.xbill.DNS.Lookup import org.xbill.DNS.Type @@ -30,8 +27,8 @@ import java.util.logging.Level import java.util.logging.Logger class DavResourceFinder( - val context: Context, - private val loginModel: LoginModel + val context: Context, + private val loginModel: LoginModel ): AutoCloseable { enum class Service(val wellKnownName: String) { @@ -41,22 +38,17 @@ class DavResourceFinder( override fun toString() = wellKnownName } - val log: Logger = Logger.getLogger("davx5.DavResourceFinder") + val log = Logger.getLogger("davdroid.DavResourceFinder") private val logBuffer = StringHandler() init { log.level = Level.FINEST log.addHandler(logBuffer) } - var encountered401 = false - - private val httpClient: HttpClient = HttpClient.Builder(context, logger = log).let { - loginModel.credentials?.let { credentials -> - it.addAuthentication(null, credentials) - } - it.setForeground(true) - it.build() - } + private val httpClient: HttpClient = HttpClient.Builder(context, logger = log) + .addAuthentication(null, loginModel.credentials!!) + .setForeground(true) + .build() override fun close() { httpClient.close() @@ -76,14 +68,14 @@ class DavResourceFinder( cardDavConfig = findInitialConfiguration(Service.CARDDAV) } catch (e: Exception) { log.log(Level.INFO, "CardDAV service detection failed", e) - processException(e) + rethrowIfInterrupted(e) } try { calDavConfig = findInitialConfiguration(Service.CALDAV) } catch (e: Exception) { log.log(Level.INFO, "CalDAV service detection failed", e) - processException(e) + rethrowIfInterrupted(e) } } catch(e: Exception) { // we have been interrupted; reset results so that an error message will be shown @@ -92,9 +84,8 @@ class DavResourceFinder( } return Configuration( - cardDavConfig, calDavConfig, - encountered401, - logBuffer.toString() + cardDavConfig, calDavConfig, + logBuffer.toString() ) } @@ -110,11 +101,11 @@ class DavResourceFinder( log.info("Finding initial ${service.wellKnownName} service configuration") if (baseURI.scheme.equals("http", true) || baseURI.scheme.equals("https", true)) { - baseURI.toHttpUrlOrNull()?.let { baseURL -> + HttpUrl.get(baseURI)?.let { baseURL -> // remember domain for service discovery // try service discovery only for https:// URLs because only secure service discovery is implemented - if (baseURL.scheme.equals("https", true)) - discoveryFQDN = baseURL.host + if (baseURL.scheme().equals("https", true)) + discoveryFQDN = baseURL.host() checkUserGivenURL(baseURL, service, config) @@ -123,7 +114,7 @@ class DavResourceFinder( config.principal = getCurrentUserPrincipal(baseURL.resolve("/.well-known/" + service.wellKnownName)!!, service) } catch(e: Exception) { log.log(Level.FINE, "Well-known URL detection failed", e) - processException(e) + rethrowIfInterrupted(e) } } } else if (baseURI.scheme.equals("mailto", true)) { @@ -142,14 +133,28 @@ class DavResourceFinder( config.principal = discoverPrincipalUrl(it, service) } catch(e: Exception) { log.log(Level.FINE, "$service service discovery failed", e) - processException(e) + rethrowIfInterrupted(e) } } - // detect email address - if (service == Service.CALDAV) - config.principal?.let { - config.emails.addAll(queryEmailAddress(it)) + if (config.principal != null && service == Service.CALDAV) + // query email address (CalDAV scheduling: calendar-user-address-set) + try { + DavResource(httpClient.okHttpClient, config.principal!!, null, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + response[CalendarUserAddressSet::class.java]?.let { addressSet -> + for (href in addressSet.hrefs) + try { + val uri = URI(href) + if (uri.scheme.equals("mailto", true)) + config.email = uri.schemeSpecificPart + } catch(e: URISyntaxException) { + log.log(Level.WARNING, "Couldn't parse user address", e) + } + } + } + } catch(e: Exception) { + log.log(Level.WARNING, "Couldn't query user email address", e) + rethrowIfInterrupted(e) } // return config or null if config doesn't contain useful information @@ -163,23 +168,23 @@ class DavResourceFinder( private fun checkUserGivenURL(baseURL: HttpUrl, service: Service, config: Configuration.ServiceInfo) { log.info("Checking user-given URL: $baseURL") - val davBase = DavResource(httpClient.okHttpClient, baseURL, log) + val davBase = DavResource(httpClient.okHttpClient, baseURL, loginModel.credentials!!.accessToken, log) try { when (service) { Service.CARDDAV -> { davBase.propfind(0, - ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, - AddressbookHomeSet.NAME, - CurrentUserPrincipal.NAME + ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME, + AddressbookHomeSet.NAME, + CurrentUserPrincipal.NAME ) { response, _ -> scanCardDavResponse(response, config) } } Service.CALDAV -> { davBase.propfind(0, - ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, - CalendarHomeSet.NAME, - CurrentUserPrincipal.NAME + ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, + CalendarHomeSet.NAME, + CurrentUserPrincipal.NAME ) { response, _ -> scanCalDavResponse(response, config) } @@ -187,37 +192,10 @@ class DavResourceFinder( } } catch(e: Exception) { log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e) - processException(e) + rethrowIfInterrupted(e) } } - /** - * Queries a user's email address using CalDAV scheduling: calendar-user-address-set. - * @param principal principal URL of the user - * @return list of found email addresses (empty if none) - */ - fun queryEmailAddress(principal: HttpUrl): List { - val mailboxes = LinkedList() - try { - DavResource(httpClient.okHttpClient, principal, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> - response[CalendarUserAddressSet::class.java]?.let { addressSet -> - for (href in addressSet.hrefs) - try { - val uri = URI(href) - if (uri.scheme.equals("mailto", true)) - mailboxes.add(uri.schemeSpecificPart) - } catch(e: URISyntaxException) { - log.log(Level.WARNING, "Couldn't parse user address", e) - } - } - } - } catch(e: Exception) { - log.log(Level.WARNING, "Couldn't query user email address", e) - processException(e) - } - return mailboxes - } - /** * If [dav] references an address book, an address book home set, and/or a princiapl, * it will added to, config.collections, config.homesets and/or config.principal. @@ -279,7 +257,7 @@ class DavResourceFinder( principal = dav.requestedUrl.resolve(it) } - // Is it a calendar and/or principal? + // Is it a calendar book and/or principal? dav[ResourceType::class.java]?.let { if (it.types.contains(ResourceType.CALENDAR)) { val info = Collection.fromDavResponse(dav)!! @@ -296,7 +274,7 @@ class DavResourceFinder( for (href in homeSet.hrefs) { dav.requestedUrl.resolve(href)?.let { val location = UrlUtils.withTrailingSlash(it) - log.info("Found calendar home-set at $location") + log.info("Found calendar book home-set at $location") config.homeSets += location } } @@ -313,7 +291,7 @@ class DavResourceFinder( fun providesService(url: HttpUrl, service: Service): Boolean { var provided = false try { - DavResource(httpClient.okHttpClient, url, log).options { capabilities, _ -> + DavResource(httpClient.okHttpClient, url, loginModel.credentials!!.accessToken, log).options { capabilities, _ -> if ((service == Service.CARDDAV && capabilities.contains("addressbook")) || (service == Service.CALDAV && capabilities.contains("calendar-access"))) provided = true @@ -335,7 +313,7 @@ class DavResourceFinder( * @return principal URL, or null if none found */ @Throws(IOException::class, HttpException::class, DavException::class) - fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { + private fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? { val scheme: String val fqdn: String var port = 443 @@ -343,11 +321,9 @@ class DavResourceFinder( val query = "_${service.wellKnownName}s._tcp.$domain" log.fine("Looking up SRV records for $query") - val srvLookup = Lookup(query, Type.SRV) DavUtils.prepareLookup(context, srvLookup) - val srv = DavUtils.selectSRVRecord(srvLookup.run().orEmpty()) - + val srv = DavUtils.selectSRVRecord(srvLookup.run()) if (srv != null) { // choose SRV record to use (query may return multiple SRV records) scheme = "https" @@ -375,10 +351,10 @@ class DavResourceFinder( for (path in paths) try { val initialContextPath = HttpUrl.Builder() - .scheme(scheme) - .host(fqdn).port(port) - .encodedPath(path) - .build() + .scheme(scheme) + .host(fqdn).port(port) + .encodedPath(path) + .build() log.info("Trying to determine principal from initial context path=$initialContextPath") val principal = getCurrentUserPrincipal(initialContextPath, service) @@ -386,7 +362,7 @@ class DavResourceFinder( principal?.let { return it } } catch(e: Exception) { log.log(Level.WARNING, "No resource found", e) - processException(e) + rethrowIfInterrupted(e) } return null } @@ -401,7 +377,7 @@ class DavResourceFinder( @Throws(IOException::class, HttpException::class, DavException::class) fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? { var principal: HttpUrl? = null - DavResource(httpClient.okHttpClient, url, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> + DavResource(httpClient.okHttpClient, url, loginModel.credentials!!.accessToken, log).propfind(0, CurrentUserPrincipal.NAME) { response, _ -> response[CurrentUserPrincipal::class.java]?.href?.let { href -> response.requestedUrl.resolve(href)?.let { log.info("Found current-user-principal: $it") @@ -418,15 +394,11 @@ class DavResourceFinder( } /** - * Processes a thrown exception likes this: - * - * - If the Exception is an [UnauthorizedException] (HTTP 401), [encountered401] is set to *true*. - * - Re-throws the exception if it signals that the current thread was interrupted to stop the current operation. + * Re-throws the exception if it signals that the current thread was interrupted + * to stop the current operation. */ - private fun processException(e: Exception) { - if (e is UnauthorizedException) - encountered401 = true - else if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException) + private fun rethrowIfInterrupted(e: Exception) { + if ((e is InterruptedIOException && e !is SocketTimeoutException) || e is InterruptedException) throw e } @@ -434,19 +406,18 @@ class DavResourceFinder( // data classes class Configuration( - val cardDAV: ServiceInfo?, - val calDAV: ServiceInfo?, + val cardDAV: ServiceInfo?, + val calDAV: ServiceInfo?, - val encountered401: Boolean, - val logs: String + val logs: String ) { data class ServiceInfo( - var principal: HttpUrl? = null, - val homeSets: MutableSet = HashSet(), - val collections: MutableMap = HashMap(), + var principal: HttpUrl? = null, + val homeSets: MutableSet = HashSet(), + val collections: MutableMap = HashMap(), - val emails: MutableList = LinkedList() + var email: String? = null ) override fun toString(): String { 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..a082f3fad55add941df30a8ef1f5f43db39d6861 --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt @@ -0,0 +1,359 @@ +package at.bitfire.davdroid.ui.setup + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.MailTo +import android.os.* +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import foundation.e.dav4jvm.Constants + +import foundation.e.accountmanager.authorization.IdentityProvider + +import foundation.e.accountmanager.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 java.util.logging.Level +import foundation.e.accountmanager.databinding.FragmentGoogleAuthenticatorBinding +import foundation.e.accountmanager.model.Credentials +import kotlinx.android.synthetic.main.fragment_google_authenticator.* + +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 + + 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) + + if (!with(it) { getBooleanExtra(LoginActivity.ACCOUNT_PROVIDER_GOOGLE_AUTH_COMPLETE, false) }) { + // 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 { + // TODO Handle error + } + } + + if (idp.name == getString(R.string.google_name)) { + // Get configurations for the Google account provider + idp.retrieveConfig(context, retrieveCallback) + } + } + } + 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 { + // TODO Handle error + } + } + } + } + + return v.root + } + + private fun makeAuthRequest( + serviceConfig: AuthorizationServiceConfiguration, + idp: IdentityProvider) { + + 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()) + + activity?.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) { + 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) { + authorizationService?.performTokenRequest( + request, this) + } + + override fun onTokenRequestCompleted(response: TokenResponse?, ex: AuthorizationException?) { + authState?.update(response, ex) + progress_bar.visibility = View.GONE + auth_token_success_text_view.visibility = View.VISIBLE + + getAccountInfo() + } + + private fun getAccountInfo() { + val discoveryDoc = getDiscoveryDocFromIntent(activity!!.intent) + + if (!authState!!.isAuthorized + || discoveryDoc == null + || discoveryDoc.userinfoEndpoint == null) { + //TODO Error occurred + } + else { + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + fetchUserInfo() + 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() { + if (authState!!.authorizationServiceConfiguration == null) { + // TODO Handle error due to unavailable service configuration + return + } + + authState!!.performActionWithFreshTokens(authorizationService!!, AuthState.AuthStateAction { accessToken, _, ex -> + if (ex != null) { + // TODO An exception occurred, handle error + 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) { + // TODO Handle error due to malformed URL + 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) { + // TODO Handle network error + } + catch (jsonEx: JSONException) { + // TODO Handle JSON parse error + } + finally { + if (userInfoResponse != null) { + try { + userInfoResponse.close() + } + catch (ioEx: IOException) { + // TODO Handle network exception while closing response stream + } + + } + } + }) + } + + @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 (userInfoJson != null) { + try { + + var emailAddress = "" + if (userInfoJson!!.has("email")) { + emailAddress = userInfoJson!!.getString("email") + } + + if (validate(emailAddress, authState!!.accessToken!!, authState!!.refreshToken!!)) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + + /*account.setName(name) + account.setEmailAddress(emailAddress) + account.setAuthToken(authState!!.accessToken) + account.setRefreshToken(authState!!.refreshToken)*/ + } + catch (ex: JSONException) { + // TODO Handle JSON parse error + } + + } + else { + //TODO Handle error + } + + } + + private fun validate(emailAddress: String, accessToken: String, refreshToken: String): Boolean { + var valid = false + + fun validateUrl() { + model.baseUrlError.value = null + try { + val uri = URI("https://www.google.com/calendar/dav/$emailAddress/events") + 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, accessToken, refreshToken, 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 e03c3989969426c7bbf9507d9dd63f4946c53fde..de06c142b5190f639103bfbfd681cae7d3fb31cf 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 @@ -41,7 +41,10 @@ class LoginActivity: AppCompatActivity() { * When set, the password field will be set to this value. */ const val EXTRA_PASSWORD = "password" - + 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 @@ -63,9 +66,27 @@ class LoginActivity: AppCompatActivity() { } if (fragment != null) { - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, fragment) - .commit() + when (intent.getStringExtra(SETUP_ACCOUNT_PROVIDER_TYPE)) { + ACCOUNT_PROVIDER_EELO -> { + // Set the eelo Contacts and Calendar service URL + intent.putExtra(EXTRA_URL, "https://drive.eelo.io") + // first call, add first login fragment + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, fragment) + .commit() + } + ACCOUNT_PROVIDER_GOOGLE -> { + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, GoogleAuthenticatorFragment()) + .commit() + } + else -> + // first call, add first login fragment + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, fragment) + .commit() + } + } else Logger.log.severe("Couldn't create LoginFragment") } 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..317a6952ac1c15a9b7700e41e437b4fbcfefa77f --- /dev/null +++ b/app/src/main/res/layout/fragment_google_authenticator.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/email_providers_auth_config.xml b/app/src/main/res/values/email_providers_auth_config.xml new file mode 100644 index 0000000000000000000000000000000000000000..78fff1469345fce50a2f2ca1cd2d804dc93d314d --- /dev/null +++ b/app/src/main/res/values/email_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 + + + + 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 e37ce368c7bde6c7e2ff508833915009be9717e6..76bd7dd904bedb816d02e2d05a6c93015c716e5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,10 @@ DAVx⁵ https://www.davx5.com/ davx5app + Google + bitfire.at.davdroid.google + eelo + bitfire.at.davdroid.eelo Account does not exist (anymore) bitfire.at.davdroid diff --git a/app/src/main/res/xml/eelo_account_authenticator.xml b/app/src/main/res/xml/eelo_account_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..0f39608e6e3715d0281bdb296c153cbcda15a23c --- /dev/null +++ b/app/src/main/res/xml/eelo_account_authenticator.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/xml/google_account_authenticator.xml b/app/src/main/res/xml/google_account_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..1d053be69b9f42ea879a6db800d47bafcff9ca6c --- /dev/null +++ b/app/src/main/res/xml/google_account_authenticator.xml @@ -0,0 +1,14 @@ + + + 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